Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Core.Entities;
namespace PowderCoating.Web.Controllers;
[Authorize]
public class AccountController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public AccountController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
}
/// <summary>
/// Shows the forced password-change form for newly created accounts. Redirects to the dashboard immediately if the MustChangePassword claim is absent, preventing direct navigation by users who do not need to change their password.
/// </summary>
[HttpGet]
public IActionResult ChangeInitialPassword()
{
if (User.FindFirst("MustChangePassword")?.Value != "true")
return RedirectToAction("Index", "Dashboard");
return View();
}
/// <summary>
/// Processes the forced initial password change. After a successful change the MustChangePassword claim is removed and the auth cookie is refreshed so the user is not challenged again in the same session. Redirects to the Registration Welcome page as the next onboarding step.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ChangeInitialPassword(
string currentPassword, string newPassword, string confirmPassword)
{
if (User.FindFirst("MustChangePassword")?.Value != "true")
return RedirectToAction("Index", "Dashboard");
if (string.IsNullOrWhiteSpace(newPassword) || newPassword != confirmPassword)
{
ModelState.AddModelError(string.Empty, "New passwords do not match.");
return View();
}
var user = await _userManager.GetUserAsync(User);
if (user == null)
return Challenge();
var result = await _userManager.ChangePasswordAsync(user, currentPassword, newPassword);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
ModelState.AddModelError(string.Empty, error.Description);
return View();
}
await _userManager.RemoveClaimAsync(user, new System.Security.Claims.Claim("MustChangePassword", "true"));
await _signInManager.RefreshSignInAsync(user);
return RedirectToAction("Welcome", "Registration");
}
}
@@ -0,0 +1,705 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OfficeOpenXml;
using OfficeOpenXml.Style;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Drawing;
using System.IO.Compression;
using System.Security.Claims;
using System.Text;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Self-service data export for company administrators.
/// Intentionally bypasses the subscription gate (added to SubscriptionMiddleware.SkipPaths)
/// so that expired/cancelled accounts can still retrieve their data.
/// This is the tenant-scoped counterpart to <see cref="DataExportController"/>, which is
/// SuperAdmin-only and can export any company's data. Here the authenticated user's own
/// <c>CompanyId</c> claim is used to scope all queries, preventing cross-tenant data leakage.
/// All sheet queries still use <c>IgnoreQueryFilters()</c> because the global EF filter ties
/// results to the current <c>ITenantContext</c> (which may be null or mismatched for inactive
/// accounts), but data is explicitly filtered to the user's own <c>CompanyId</c>.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class AccountDataExportController : Controller
{
private readonly ApplicationDbContext _db;
private readonly ILogger<AccountDataExportController> _logger;
/// <summary>
/// Initializes the controller and sets the EPPlus license context to NonCommercial.
/// EPPlus 5+ requires an explicit license declaration before any <see cref="ExcelPackage"/>
/// is constructed; omitting this causes a runtime exception on the first export.
/// </summary>
public AccountDataExportController(ApplicationDbContext db, ILogger<AccountDataExportController> logger)
{
_db = db;
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
_logger = logger;
}
/// <summary>
/// Renders the export selection page where the company admin can choose which data types
/// to export and in which format (XLSX or CSV/ZIP).
/// </summary>
[HttpGet]
public IActionResult Index()
{
return View();
}
/// <summary>
/// Accepts the selected sheet names and format, resolves the caller's company ID from the
/// <c>CompanyId</c> JWT/cookie claim, and delegates to the appropriate format builder.
/// The company name is loaded from the database (not from claims) to guarantee a current,
/// authoritative value for the file name and metadata sheet.
/// Logs the export for audit trail purposes so SuperAdmins can see when a tenant exported data.
/// </summary>
/// <param name="sheets">Array of entity/sheet names selected on the form (e.g. "Customers", "Jobs").</param>
/// <param name="format">Output format: <c>"xlsx"</c> (default) or <c>"csv"</c>.</param>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Export(string[] sheets, string format = "xlsx")
{
var companyId = GetCompanyId();
if (companyId == 0)
{
TempData["Error"] = "Unable to determine your company. Please sign in again.";
return RedirectToAction(nameof(Index));
}
if (sheets == null || sheets.Length == 0)
{
TempData["Error"] = "Select at least one data type to export.";
return RedirectToAction(nameof(Index));
}
var company = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == companyId && !c.IsDeleted);
if (company == null) return NotFound();
var safeName = string.Concat(company.CompanyName.Split(Path.GetInvalidFileNameChars()));
var ordered = OrderSheets(sheets);
_logger.LogInformation("Company {CompanyId} ({CompanyName}) self-service export: {Sheets} as {Format}",
companyId, company.CompanyName, string.Join(", ", ordered), format);
if (format == "csv")
return await ExportAsCsv(companyId, company.CompanyName, safeName, ordered);
return await ExportAsXlsx(companyId, company.CompanyName, safeName, ordered);
}
/// <summary>
/// Reads the <c>CompanyId</c> claim from the authenticated user's identity.
/// Returns 0 (invalid) when the claim is absent or cannot be parsed, so callers can
/// guard against unauthenticated or misconfigured sessions without throwing exceptions.
/// </summary>
private int GetCompanyId()
{
var claim = User.FindFirst("CompanyId")?.Value;
return int.TryParse(claim, out var id) ? id : 0;
}
// ── XLSX ─────────────────────────────────────────────────────────────────
/// <summary>
/// Builds an XLSX workbook entirely in memory with one worksheet per selected data type,
/// plus a leading "Export Info" metadata sheet, and returns it as a file download.
/// In-memory construction avoids creating temp files on disk, removing the need for
/// cleanup logic and reducing surface area for file-system path traversal attacks.
/// </summary>
/// <param name="companyId">Tenant company ID; all sheet queries are filtered to this value.</param>
/// <param name="companyName">Human-readable company name for the metadata sheet.</param>
/// <param name="safeName">Filesystem-safe company name for the download file name.</param>
/// <param name="ordered">Sheet names in canonical order to include.</param>
private async Task<IActionResult> ExportAsXlsx(int companyId, string companyName, string safeName, string[] ordered)
{
using var package = new ExcelPackage();
var headerColor = Color.FromArgb(31, 78, 121);
foreach (var sheet in ordered)
{
switch (sheet)
{
case "Customers": await AddCustomersSheet(package, companyId, headerColor); break;
case "Jobs": await AddJobsSheet(package, companyId, headerColor); break;
case "Quotes": await AddQuotesSheet(package, companyId, headerColor); break;
case "Invoices": await AddInvoicesSheet(package, companyId, headerColor); break;
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
}
}
AddMetadataSheet(package, companyName, ordered);
var fileName = $"{safeName}_Export_{DateTime.UtcNow:yyyyMMdd}.xlsx";
return File(package.GetAsByteArray(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
fileName);
}
// ── CSV ──────────────────────────────────────────────────────────────────
/// <summary>
/// Builds a ZIP archive in memory containing one CSV file per selected data type plus an
/// "Export_Info.csv" metadata file, and returns it as a file download.
/// <c>leaveOpen: true</c> is passed so the underlying <see cref="MemoryStream"/> remains
/// usable after <see cref="ZipArchive.Dispose"/> is called, allowing <c>ms.ToArray()</c>
/// to capture the fully finalised bytes.
/// </summary>
/// <param name="companyId">Tenant company ID; all CSV queries are filtered to this value.</param>
/// <param name="companyName">Human-readable company name for the metadata CSV.</param>
/// <param name="safeName">Filesystem-safe company name for the download file name.</param>
/// <param name="ordered">Sheet names in canonical order to include.</param>
private async Task<IActionResult> ExportAsCsv(int companyId, string companyName, string safeName, string[] ordered)
{
using var ms = new MemoryStream();
using var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true);
var meta = new StringBuilder();
meta.AppendLine("Field,Value");
meta.AppendLine($"Company,{CsvEscape(companyName)}");
meta.AppendLine($"Exported At,{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
meta.AppendLine($"Sheets,{CsvEscape(string.Join("; ", ordered))}");
WriteCsvEntry(zip, "Export_Info.csv", meta.ToString());
foreach (var sheet in ordered)
{
switch (sheet)
{
case "Customers": WriteCsvEntry(zip, "Customers.csv", await BuildCustomersCsv(companyId)); break;
case "Jobs": WriteCsvEntry(zip, "Jobs.csv", await BuildJobsCsv(companyId)); break;
case "Quotes": WriteCsvEntry(zip, "Quotes.csv", await BuildQuotesCsv(companyId)); break;
case "Invoices": WriteCsvEntry(zip, "Invoices.csv", await BuildInvoicesCsv(companyId)); break;
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
}
}
zip.Dispose();
ms.Position = 0;
var fileName = $"{safeName}_Export_{DateTime.UtcNow:yyyyMMdd}.zip";
return File(ms.ToArray(), "application/zip", fileName);
}
/// <summary>
/// Writes a CSV string as a named entry inside an open <see cref="ZipArchive"/>.
/// The UTF-8 BOM (<c>encoderShouldEmitUTF8Identifier: true</c>) ensures Excel opens the
/// file with correct encoding without requiring an explicit import wizard step.
/// </summary>
/// <param name="zip">The open archive to add the entry to.</param>
/// <param name="entryName">Entry file name inside the ZIP (e.g. "Customers.csv").</param>
/// <param name="content">Complete CSV text content to write.</param>
private static void WriteCsvEntry(ZipArchive zip, string entryName, string content)
{
var entry = zip.CreateEntry(entryName, System.IO.Compression.CompressionLevel.Optimal);
using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
writer.Write(content);
}
// ── Sheet builders ───────────────────────────────────────────────────────
/// <summary>
/// Adds an "Export Info" worksheet as the first sheet of the workbook, recording the company
/// name, export timestamp, and list of included data sheets.
/// <c>MoveToStart</c> ensures this orientation sheet always appears at tab position 1,
/// regardless of when it is added relative to data sheets.
/// </summary>
private void AddMetadataSheet(ExcelPackage pkg, string companyName, string[] sheets)
{
var ws = pkg.Workbook.Worksheets.Add("Export Info");
pkg.Workbook.Worksheets.MoveToStart("Export Info");
ws.Cells[1, 1].Value = "Company"; ws.Cells[1, 2].Value = companyName;
ws.Cells[2, 1].Value = "Exported At"; ws.Cells[2, 2].Value = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss UTC");
ws.Cells[3, 1].Value = "Sheets"; ws.Cells[3, 2].Value = string.Join(", ", sheets);
ws.Column(1).Width = 20; ws.Column(2).Width = 40;
ws.Cells[1, 1, 3, 1].Style.Font.Bold = true;
}
/// <summary>
/// Adds a "Customers" worksheet with one row per non-deleted customer belonging to the
/// authenticated user's company. <c>IgnoreQueryFilters()</c> bypasses the global EF
/// multi-tenancy filter (which relies on <c>ITenantContext</c>) in favour of the explicit
/// <c>CompanyId == companyId</c> predicate, making the filter independent of middleware state.
/// </summary>
private async Task AddCustomersSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Customers.AsNoTracking().IgnoreQueryFilters()
.Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Customers");
var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone",
"Commercial", "City", "State", "Active", "Credit Limit", "Current Balance", "Created At" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2; var c = data[i];
ws.Cells[r, 1].Value = c.Id; ws.Cells[r, 2].Value = c.CompanyName;
ws.Cells[r, 3].Value = c.ContactFirstName; ws.Cells[r, 4].Value = c.ContactLastName;
ws.Cells[r, 5].Value = c.Email; ws.Cells[r, 6].Value = c.Phone;
ws.Cells[r, 7].Value = c.IsCommercial ? "Yes" : "No";
ws.Cells[r, 8].Value = c.City; ws.Cells[r, 9].Value = c.State;
ws.Cells[r, 10].Value = c.IsActive ? "Yes" : "No";
ws.Cells[r, 11].Value = c.CreditLimit; ws.Cells[r, 12].Value = c.CurrentBalance;
ws.Cells[r, 13].Value = c.CreatedAt.ToString("yyyy-MM-dd");
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Jobs" worksheet with one row per non-deleted job belonging to the company.
/// Job status and priority are lookup-table entities (not enums) stored in
/// <c>JobStatusLookup</c> and a parallel priority table; they are eagerly loaded so their
/// <c>DisplayName</c> property is available without additional queries.
/// If a lookup navigation is null (data anomaly), the raw FK integer is written as a fallback.
/// </summary>
private async Task AddJobsSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Jobs.AsNoTracking().IgnoreQueryFilters()
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
.OrderByDescending(j => j.CreatedAt).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Jobs");
var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority",
"Description", "Due Date", "Final Price", "Created At" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2; var j = data[i];
ws.Cells[r, 1].Value = j.Id;
ws.Cells[r, 2].Value = j.JobNumber;
ws.Cells[r, 3].Value = j.Customer?.CompanyName ?? $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim();
ws.Cells[r, 4].Value = j.JobStatus?.DisplayName ?? j.JobStatusId.ToString();
ws.Cells[r, 5].Value = j.JobPriority?.DisplayName ?? j.JobPriorityId.ToString();
ws.Cells[r, 6].Value = j.Description;
ws.Cells[r, 7].Value = j.DueDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 8].Value = j.FinalPrice;
ws.Cells[r, 9].Value = j.CreatedAt.ToString("yyyy-MM-dd");
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Quotes" worksheet with one row per non-deleted quote belonging to the company.
/// Prospect-only quotes (before they are linked to a customer record) show
/// <c>ProspectCompanyName</c>; fully linked quotes fall back to the customer FK integer when
/// the navigation cannot be resolved — ensuring no row has a blank identifier column.
/// </summary>
private async Task AddQuotesSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Quotes.AsNoTracking().IgnoreQueryFilters()
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
.Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Quotes");
var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status",
"Quote Date", "Expiration Date", "Subtotal", "Tax", "Total" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2; var q = data[i];
ws.Cells[r, 1].Value = q.Id; ws.Cells[r, 2].Value = q.QuoteNumber;
ws.Cells[r, 3].Value = string.IsNullOrEmpty(q.ProspectCompanyName) ? $"Customer #{q.CustomerId}" : q.ProspectCompanyName;
ws.Cells[r, 4].Value = q.QuoteStatus?.DisplayName ?? q.QuoteStatusId.ToString();
ws.Cells[r, 5].Value = q.QuoteDate.ToString("yyyy-MM-dd");
ws.Cells[r, 6].Value = q.ExpirationDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 7].Value = q.SubTotal; ws.Cells[r, 8].Value = q.TaxAmount; ws.Cells[r, 9].Value = q.Total;
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds an "Invoices" worksheet with one row per non-deleted invoice belonging to the company.
/// <c>BalanceDue</c> is a computed property (<c>Total - AmountPaid</c>) reflecting partial
/// payment state without an additional aggregation query.
/// Eagerly loads <c>Customer</c> so the customer name is available for the display column.
/// </summary>
private async Task AddInvoicesSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Invoices.AsNoTracking().IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
.Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Invoices");
var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date",
"Due Date", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2; var inv = data[i];
var cust = inv.Customer != null
? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim())
: $"Customer #{inv.CustomerId}";
ws.Cells[r, 1].Value = inv.Id; ws.Cells[r, 2].Value = inv.InvoiceNumber;
ws.Cells[r, 3].Value = cust; ws.Cells[r, 4].Value = inv.Status.ToString();
ws.Cells[r, 5].Value = inv.InvoiceDate.ToString("yyyy-MM-dd");
ws.Cells[r, 6].Value = inv.DueDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 7].Value = inv.SubTotal; ws.Cells[r, 8].Value = inv.TaxAmount;
ws.Cells[r, 9].Value = inv.Total; ws.Cells[r, 10].Value = inv.AmountPaid;
ws.Cells[r, 11].Value = inv.BalanceDue;
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds an "Inventory" worksheet with one row per non-deleted inventory item for the company.
/// Items are ordered alphabetically so the exported list matches the order users typically
/// see in the application's inventory index view.
/// </summary>
private async Task AddInventorySheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.InventoryItems.AsNoTracking().IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && !i.IsDeleted).OrderBy(i => i.Name).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Inventory");
var headers = new[] { "ID", "Name", "SKU", "Category", "Qty on Hand",
"Unit", "Unit Cost", "Reorder Point", "Manufacturer", "Color" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2; var inv = data[i];
ws.Cells[r, 1].Value = inv.Id; ws.Cells[r, 2].Value = inv.Name;
ws.Cells[r, 3].Value = inv.SKU; ws.Cells[r, 4].Value = inv.Category;
ws.Cells[r, 5].Value = inv.QuantityOnHand; ws.Cells[r, 6].Value = inv.UnitOfMeasure;
ws.Cells[r, 7].Value = inv.UnitCost; ws.Cells[r, 8].Value = inv.ReorderPoint;
ws.Cells[r, 9].Value = inv.Manufacturer; ws.Cells[r, 10].Value = inv.ColorName;
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds an "Equipment" worksheet with one row per non-deleted equipment record for the company.
/// Equipment status is stored as an enum and serialised via <c>ToString()</c> for a readable label.
/// </summary>
private async Task AddEquipmentSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Equipment.AsNoTracking().IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId && !e.IsDeleted).OrderBy(e => e.EquipmentName).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Equipment");
var headers = new[] { "ID", "Name", "Type", "Serial Number", "Model",
"Status", "Purchase Date", "Purchase Price", "Next Maintenance" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2; var e = data[i];
ws.Cells[r, 1].Value = e.Id; ws.Cells[r, 2].Value = e.EquipmentName;
ws.Cells[r, 3].Value = e.EquipmentType; ws.Cells[r, 4].Value = e.SerialNumber;
ws.Cells[r, 5].Value = e.Model; ws.Cells[r, 6].Value = e.Status.ToString();
ws.Cells[r, 7].Value = e.PurchaseDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 8].Value = e.PurchasePrice;
ws.Cells[r, 9].Value = e.NextScheduledMaintenance?.ToString("yyyy-MM-dd");
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Vendors" worksheet with one row per non-deleted vendor for the company.
/// </summary>
private async Task AddVendorsSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Vendors.AsNoTracking().IgnoreQueryFilters()
.Where(s => s.CompanyId == companyId && !s.IsDeleted).OrderBy(s => s.CompanyName).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Vendors");
var headers = new[] { "ID", "Company Name", "Contact", "Email", "Phone", "City", "State", "Preferred", "Active" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2; var s = data[i];
ws.Cells[r, 1].Value = s.Id; ws.Cells[r, 2].Value = s.CompanyName;
ws.Cells[r, 3].Value = s.ContactName; ws.Cells[r, 4].Value = s.Email;
ws.Cells[r, 5].Value = s.Phone; ws.Cells[r, 6].Value = s.City;
ws.Cells[r, 7].Value = s.State;
ws.Cells[r, 8].Value = s.IsPreferred ? "Yes" : "No";
ws.Cells[r, 9].Value = s.IsActive ? "Yes" : "No";
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Shop Workers" worksheet with one row per non-deleted shop worker for the company.
/// </summary>
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2; var w = data[i];
ws.Cells[r, 1].Value = w.Id; ws.Cells[r, 2].Value = w.Name;
ws.Cells[r, 3].Value = w.Role.ToString(); ws.Cells[r, 4].Value = w.Phone;
ws.Cells[r, 5].Value = w.Email; ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
ws.Cells[r, 7].Value = w.Notes;
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Users" worksheet with one row per user belonging to the company.
/// The <c>IsDeleted</c> predicate is intentionally omitted because ASP.NET Identity users
/// use <c>IsActive = false</c> as their soft-deletion mechanism, not the base-entity
/// <c>IsDeleted</c> flag. All users (active and inactive) are included so the export
/// provides a complete workforce record for compliance and audit purposes.
/// </summary>
private async Task AddUsersSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Users.AsNoTracking().IgnoreQueryFilters()
.Where(u => u.CompanyId == companyId).OrderBy(u => u.LastName).ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Users");
var headers = new[] { "ID", "First Name", "Last Name", "Email", "Role", "Active", "Hire Date", "Last Login", "Created At" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2; var u = data[i];
ws.Cells[r, 1].Value = u.Id; ws.Cells[r, 2].Value = u.FirstName;
ws.Cells[r, 3].Value = u.LastName; ws.Cells[r, 4].Value = u.Email;
ws.Cells[r, 5].Value = u.CompanyRole; ws.Cells[r, 6].Value = u.IsActive ? "Yes" : "No";
ws.Cells[r, 7].Value = u.HireDate.ToString("yyyy-MM-dd");
ws.Cells[r, 8].Value = u.LastLoginDate?.ToString("yyyy-MM-dd") ?? "Never";
ws.Cells[r, 9].Value = u.CreatedAt.ToString("yyyy-MM-dd");
}
AutoFit(ws, headers.Length);
}
// ── CSV builders ─────────────────────────────────────────────────────────
/// <summary>
/// Builds the customers CSV string for the company.
/// Column names match <see cref="CustomerImportDto"/> exactly so the file can be re-imported
/// via Tools → Bulk Import without any manual header editing.
/// </summary>
private async Task<string> BuildCustomersCsv(int companyId)
{
var data = await _db.Customers.AsNoTracking().IgnoreQueryFilters()
.Include(c => c.PricingTier)
.Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes");
foreach (var c in data)
{
var customerType = c.IsCommercial ? "Commercial" : "Non-Commercial";
sb.AppendLine($"{CsvEscape(c.CompanyName)},{CsvEscape(c.ContactFirstName)},{CsvEscape(c.ContactLastName)},{CsvEscape(c.Email)},{CsvEscape(c.Phone)},{CsvEscape(c.MobilePhone)},{CsvEscape(c.Address)},{CsvEscape(c.City)},{CsvEscape(c.State)},{CsvEscape(c.ZipCode)},{CsvEscape(c.Country)},{customerType},{CsvEscape(c.PricingTier?.TierName)},{c.CreditLimit},{CsvEscape(c.PaymentTerms)},{c.IsTaxExempt.ToString().ToLower()},{CsvEscape(c.TaxId)},{c.IsActive.ToString().ToLower()},{CsvEscape(c.GeneralNotes)}");
}
return sb.ToString();
}
/// <summary>
/// Builds the jobs CSV string for the company.
/// Column names match <see cref="JobImportDto"/> exactly so the file can be re-imported.
/// CustomerEmail is included (not the display name) because the importer resolves the customer FK by email.
/// </summary>
private async Task<string> BuildJobsCsv(int companyId)
{
var data = await _db.Jobs.AsNoTracking().IgnoreQueryFilters()
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
.OrderByDescending(j => j.CreatedAt).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,FinalPrice,CustomerPO,SpecialInstructions,Notes");
foreach (var j in data)
{
var customerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
? j.Customer.CompanyName
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim();
sb.AppendLine($"{CsvEscape(j.JobNumber)},{CsvEscape(j.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(j.JobStatus?.DisplayName)},{CsvEscape(j.JobPriority?.DisplayName)},{j.ScheduledDate?.ToString("yyyy-MM-dd")},{j.DueDate?.ToString("yyyy-MM-dd")},{j.FinalPrice},{CsvEscape(j.CustomerPO)},{CsvEscape(j.SpecialInstructions)},{CsvEscape(j.InternalNotes)}");
}
return sb.ToString();
}
/// <summary>
/// Builds the quotes CSV string for the company.
/// Column names match <see cref="QuoteImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildQuotesCsv(int companyId)
{
var data = await _db.Quotes.AsNoTracking().IgnoreQueryFilters()
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
.Include(q => q.Customer).Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,Subtotal,TaxAmount,Total,Notes,TermsAndConditions");
foreach (var q in data)
{
var customerName = !string.IsNullOrWhiteSpace(q.Customer?.CompanyName)
? q.Customer.CompanyName
: $"{q.Customer?.ContactFirstName} {q.Customer?.ContactLastName}".Trim();
sb.AppendLine($"{CsvEscape(q.QuoteNumber)},{CsvEscape(q.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(q.ProspectCompanyName)},{CsvEscape(q.ProspectContactName)},{CsvEscape(q.ProspectEmail)},{CsvEscape(q.ProspectPhone)},{CsvEscape(q.QuoteStatus?.DisplayName)},{q.QuoteDate:yyyy-MM-dd},{q.ExpirationDate?.ToString("yyyy-MM-dd")},{q.SubTotal},{q.TaxAmount},{q.Total},{CsvEscape(q.Notes)},{CsvEscape(q.Terms)}");
}
return sb.ToString();
}
/// <summary>
/// Builds the invoices CSV string for the company, ordered newest-first.
/// Customer name resolution mirrors the XLSX sheet: company name preferred, with
/// first+last name concatenation as the fallback for non-commercial customers.
/// </summary>
private async Task<string> BuildInvoicesCsv(int companyId)
{
var data = await _db.Invoices.AsNoTracking().IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
.Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Subtotal,Tax,Total,Amount Paid,Balance Due");
foreach (var inv in data)
{
var cust = inv.Customer != null
? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim())
: $"Customer #{inv.CustomerId}";
sb.AppendLine($"{inv.Id},{CsvEscape(inv.InvoiceNumber)},{CsvEscape(cust)},{inv.Status},{inv.InvoiceDate:yyyy-MM-dd},{inv.DueDate?.ToString("yyyy-MM-dd")},{inv.SubTotal},{inv.TaxAmount},{inv.Total},{inv.AmountPaid},{inv.BalanceDue}");
}
return sb.ToString();
}
/// <summary>
/// Builds the inventory CSV string for the company.
/// Column names match <see cref="InventoryItemImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildInventoryCsv(int companyId)
{
var data = await _db.InventoryItems.AsNoTracking().IgnoreQueryFilters()
.Include(i => i.PrimaryVendor)
.Include(i => i.InventoryCategory)
.Where(i => i.CompanyId == companyId && !i.IsDeleted).OrderBy(i => i.Name).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("SKU,ItemName,Description,CategoryName,Manufacturer,ManufacturerPartNumber,ColorName,ColorCode,Finish,VendorName,VendorPartNumber,QuantityInStock,UnitOfMeasure,UnitCost,LastPurchasePrice,ReorderPoint,ReorderQuantity,MinimumStock,MaximumStock,CoverageSqFtPerLb,TransferEfficiencyPct,Location,IsActive,Notes");
foreach (var i in data)
{
var categoryName = i.InventoryCategory?.DisplayName ?? i.Category;
sb.AppendLine($"{CsvEscape(i.SKU)},{CsvEscape(i.Name)},{CsvEscape(i.Description)},{CsvEscape(categoryName)},{CsvEscape(i.Manufacturer)},{CsvEscape(i.ManufacturerPartNumber)},{CsvEscape(i.ColorName)},{CsvEscape(i.ColorCode)},{CsvEscape(i.Finish)},{CsvEscape(i.PrimaryVendor?.CompanyName)},{CsvEscape(i.VendorPartNumber)},{i.QuantityOnHand},{CsvEscape(i.UnitOfMeasure)},{i.UnitCost},{i.LastPurchasePrice},{i.ReorderPoint},{i.ReorderQuantity},{i.MinimumStock},{i.MaximumStock},{i.CoverageSqFtPerLb},{i.TransferEfficiency},{CsvEscape(i.Location)},{i.IsActive.ToString().ToLower()},{CsvEscape(i.Notes)}");
}
return sb.ToString();
}
/// <summary>
/// Builds the equipment CSV string for the company.
/// Column names match <see cref="EquipmentImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildEquipmentCsv(int companyId)
{
var data = await _db.Equipment.AsNoTracking().IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId && !e.IsDeleted).OrderBy(e => e.EquipmentName).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("EquipmentName,EquipmentNumber,EquipmentType,Manufacturer,Model,SerialNumber,PurchaseDate,PurchasePrice,WarrantyExpiration,Location,RecommendedMaintenanceIntervalDays,Status,IsActive,Notes");
foreach (var e in data)
sb.AppendLine($"{CsvEscape(e.EquipmentName)},{CsvEscape(e.EquipmentNumber)},{CsvEscape(e.EquipmentType)},{CsvEscape(e.Manufacturer)},{CsvEscape(e.Model)},{CsvEscape(e.SerialNumber)},{e.PurchaseDate?.ToString("yyyy-MM-dd")},{e.PurchasePrice},{e.WarrantyExpiration?.ToString("yyyy-MM-dd")},{CsvEscape(e.Location)},{e.RecommendedMaintenanceIntervalDays},{e.Status},{e.IsActive.ToString().ToLower()},{CsvEscape(e.Notes)}");
return sb.ToString();
}
/// <summary>
/// Builds the vendors CSV string for the company.
/// Column names match <see cref="VendorImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildVendorsCsv(int companyId)
{
var data = await _db.Vendors.AsNoTracking().IgnoreQueryFilters()
.Where(s => s.CompanyId == companyId && !s.IsDeleted).OrderBy(s => s.CompanyName).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("CompanyName,ContactName,Email,Phone,Address,City,State,ZipCode,Country,Website,AccountNumber,TaxId,PaymentTerms,CreditLimit,IsPreferred,IsActive,Notes");
foreach (var s in data)
sb.AppendLine($"{CsvEscape(s.CompanyName)},{CsvEscape(s.ContactName)},{CsvEscape(s.Email)},{CsvEscape(s.Phone)},{CsvEscape(s.Address)},{CsvEscape(s.City)},{CsvEscape(s.State)},{CsvEscape(s.ZipCode)},{CsvEscape(s.Country)},{CsvEscape(s.Website)},{CsvEscape(s.AccountNumber)},{CsvEscape(s.TaxId)},{CsvEscape(s.PaymentTerms)},{s.CreditLimit},{s.IsPreferred.ToString().ToLower()},{s.IsActive.ToString().ToLower()},{CsvEscape(s.Notes)}");
return sb.ToString();
}
/// <summary>
/// Builds the shop workers CSV string for the company.
/// Column names match <see cref="ShopWorkerImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildShopWorkersCsv(int companyId)
{
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
foreach (var w in data)
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
return sb.ToString();
}
/// <summary>
/// Builds the users CSV string for the company.
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> predicate is omitted because
/// Identity users use <c>IsActive</c> for soft-deletion; all users are exported for
/// completeness and compliance.
/// </summary>
private async Task<string> BuildUsersCsv(int companyId)
{
var data = await _db.Users.AsNoTracking().IgnoreQueryFilters()
.Where(u => u.CompanyId == companyId).OrderBy(u => u.LastName).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("ID,First Name,Last Name,Email,Role,Active,Hire Date,Last Login,Created At");
foreach (var u in data)
sb.AppendLine($"{CsvEscape(u.Id)},{CsvEscape(u.FirstName)},{CsvEscape(u.LastName)},{CsvEscape(u.Email)},{CsvEscape(u.CompanyRole)},{(u.IsActive?"Yes":"No")},{u.HireDate:yyyy-MM-dd},{u.LastLoginDate?.ToString("yyyy-MM-dd")??"Never"},{u.CreatedAt:yyyy-MM-dd}");
return sb.ToString();
}
// ── Utilities ────────────────────────────────────────────────────────────
/// <summary>
/// Writes a bold, white-on-dark-blue header row to the first row of the given worksheet.
/// The styling is consistent across both the SuperAdmin export and the self-service export
/// so that exported files look identical regardless of which controller produced them.
/// </summary>
/// <param name="ws">Target worksheet; row 1 is always used for headers.</param>
/// <param name="headers">Ordered header labels for each column.</param>
/// <param name="bgColor">Header row background fill colour.</param>
private static void WriteHeader(ExcelWorksheet ws, string[] headers, Color bgColor)
{
for (int c = 0; c < headers.Length; c++)
{
var cell = ws.Cells[1, c + 1];
cell.Value = headers[c];
cell.Style.Font.Bold = true;
cell.Style.Font.Color.SetColor(Color.White);
cell.Style.Fill.PatternType = ExcelFillStyle.Solid;
cell.Style.Fill.BackgroundColor.SetColor(bgColor);
}
}
/// <summary>
/// Auto-fits all columns to their content width and caps each column at 50 characters.
/// The 50-character cap prevents free-text fields (job descriptions, notes) from creating
/// columns so wide they make the spreadsheet awkward to navigate.
/// </summary>
/// <param name="ws">The worksheet to auto-fit.</param>
/// <param name="colCount">Total number of data columns, for the width-cap loop.</param>
private static void AutoFit(ExcelWorksheet ws, int colCount)
{
ws.Cells[ws.Dimension?.Address ?? "A1"].AutoFitColumns();
for (int c = 1; c <= colCount; c++)
if (ws.Column(c).Width > 50) ws.Column(c).Width = 50;
}
/// <summary>
/// Returns the subset of selected sheet names reordered into the canonical export sequence
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → ShopWorkers → Users).
/// Guarantees consistent file layout regardless of the order check-boxes were ticked on the form.
/// Sheet names not in the canonical list are silently dropped.
/// </summary>
private static string[] OrderSheets(string[] sheets)
{
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" };
return order.Where(sheets.Contains).ToArray();
}
/// <summary>
/// RFC 4180-compliant CSV field escaper. Wraps the value in double-quotes and doubles any
/// embedded double-quotes when the value contains a comma, double-quote, carriage return, or
/// line feed. Returns an empty string for null to avoid bare "null" literals in CSV output.
/// </summary>
/// <param name="value">Field value to escape; accepts any nullable object.</param>
private static string CsvEscape(object? value)
{
if (value == null) return "";
var s = value.ToString() ?? "";
if (s.Contains(',') || s.Contains('"') || s.Contains('\n') || s.Contains('\r'))
return $"\"{s.Replace("\"", "\"\"")}\"";
return s;
}
}
@@ -0,0 +1,581 @@
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using System.IO.Compression;
using System.Text;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public class AccountingExportController : Controller
{
private readonly ApplicationDbContext _context;
private readonly ITenantContext _tenantContext;
private readonly PowderCoating.Application.Interfaces.IAuditService _auditService;
public AccountingExportController(ApplicationDbContext context, ITenantContext tenantContext,
PowderCoating.Application.Interfaces.IAuditService auditService)
{
_context = context;
_tenantContext = tenantContext;
_auditService = auditService;
}
/// <summary>
/// Displays the accounting data export page. Pre-fills the start date to the first day of
/// the current month and the end date to today so the user sees a sensible default range
/// without having to type dates.
/// </summary>
public IActionResult Index()
{
ViewBag.DefaultStart = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1).ToString("yyyy-MM-dd");
ViewBag.DefaultEnd = DateTime.Now.ToString("yyyy-MM-dd");
return View();
}
/// <summary>
/// Produces a ZIP archive containing the company's financial data for the requested date
/// range in either QuickBooks Desktop IIF format or generic CSV format.
/// <para>
/// <b>QuickBooks IIF</b>: Generates three files that must be imported into QuickBooks
/// Desktop in order (customers → invoices/payments → expenses/bills). IIF is a tab-delimited
/// format with header rows (<c>!CUST</c>, <c>!TRNS</c>, <c>!SPL</c>, <c>!ENDTRNS</c>) that
/// QuickBooks parses as transaction records. Account names in the IIF files must exactly match
/// the company's QuickBooks chart of accounts, so users are advised to review account names
/// before importing.
/// </para>
/// <para>
/// <b>CSV</b>: Generates seven files covering customers, invoice headers, invoice line items,
/// payments received, expenses, vendor bills, and bill payments. Suitable for import into any
/// spreadsheet or accounting tool that accepts CSV.
/// </para>
/// The end date is extended to end-of-day (<c>AddDays(1).AddTicks(-1)</c>) so that records
/// created at any time on the end date are included. The ZIP is streamed directly from a
/// <c>MemoryStream</c> (not written to disk) to avoid temporary file management on the server.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Export(DateTime startDate, DateTime endDate, string format)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var start = startDate.Date;
var end = endDate.Date.AddDays(1).AddTicks(-1);
// ── Load data ─────────────────────────────────────────────────────────
var invoices = await _context.Invoices
.Include(i => i.InvoiceItems)
.Include(i => i.Payments)
.Include(i => i.Customer)
.Where(i => !i.IsDeleted && i.CompanyId == companyId
&& i.InvoiceDate >= start && i.InvoiceDate <= end)
.OrderBy(i => i.InvoiceDate)
.ToListAsync();
var expenses = await _context.Set<PowderCoating.Core.Entities.Expense>()
.Include(e => e.Vendor)
.Include(e => e.ExpenseAccount)
.Include(e => e.PaymentAccount)
.Where(e => !e.IsDeleted && e.CompanyId == companyId
&& e.Date >= start && e.Date <= end)
.OrderBy(e => e.Date)
.ToListAsync();
var bills = await _context.Set<PowderCoating.Core.Entities.Bill>()
.Include(b => b.Vendor)
.Include(b => b.LineItems).ThenInclude(l => l.Account)
.Include(b => b.Payments)
.Where(b => !b.IsDeleted && b.CompanyId == companyId
&& b.BillDate >= start && b.BillDate <= end)
.OrderBy(b => b.BillDate)
.ToListAsync();
var customers = await _context.Customers
.Where(c => !c.IsDeleted && c.CompanyId == companyId)
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
.ToListAsync();
// ── Build ZIP ─────────────────────────────────────────────────────────
using var ms = new MemoryStream();
using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
if (format == "quickbooks")
{
AddEntry(zip, "1_customers.iif", BuildIifCustomers(customers));
AddEntry(zip, "2_invoices_payments.iif", BuildIifInvoicesAndPayments(invoices));
AddEntry(zip, "3_expenses_bills.iif", BuildIifExpensesAndBills(expenses, bills));
AddEntry(zip, "README.txt", BuildReadme("QuickBooks Desktop (IIF)", startDate, endDate));
}
else
{
AddEntry(zip, "customers.csv", BuildCsvCustomers(customers));
AddEntry(zip, "invoices.csv", BuildCsvInvoices(invoices));
AddEntry(zip, "invoice_line_items.csv", BuildCsvInvoiceLineItems(invoices));
AddEntry(zip, "payments_received.csv", BuildCsvPayments(invoices));
AddEntry(zip, "expenses.csv", BuildCsvExpenses(expenses));
AddEntry(zip, "bills.csv", BuildCsvBills(bills));
AddEntry(zip, "bill_payments.csv", BuildCsvBillPayments(bills));
AddEntry(zip, "README.txt", BuildReadme("CSV", startDate, endDate));
}
}
var fileName = $"accounting-export-{startDate:yyyy-MM-dd}-to-{endDate:yyyy-MM-dd}.zip";
await _auditService.LogAsync("Exported", "AccountingExport",
$"{format} export {startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}",
new { format, startDate = startDate.ToString("yyyy-MM-dd"), endDate = endDate.ToString("yyyy-MM-dd"),
invoiceCount = invoices.Count, billCount = bills.Count, expenseCount = expenses.Count });
return File(ms.ToArray(), "application/zip", fileName);
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>
/// Writes a UTF-8 text file entry into a <see cref="ZipArchive"/>. Using
/// <c>CompressionLevel.Optimal</c> yields significant size reductions on repetitive CSV/IIF
/// text without meaningful latency for export files of this scale.
/// </summary>
private static void AddEntry(ZipArchive zip, string name, string content)
{
var entry = zip.CreateEntry(name, CompressionLevel.Optimal);
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
writer.Write(content);
}
/// <summary>
/// Formats a single value for inclusion in a CSV field. Values containing commas, double
/// quotes, or newlines are wrapped in double quotes and internal double quotes are escaped by
/// doubling them (RFC 4180 compliance).
/// </summary>
private static string CsvVal(object? v)
{
if (v == null) return "";
var s = v.ToString() ?? "";
if (s.Contains(',') || s.Contains('"') || s.Contains('\n'))
return $"\"{s.Replace("\"", "\"\"")}\"";
return s;
}
/// <summary>
/// Joins a set of field values into a single RFC 4180-compliant CSV row by applying
/// <see cref="CsvVal"/> to each field and joining with commas.
/// </summary>
private static string CsvRow(params object?[] fields)
=> string.Join(",", fields.Select(CsvVal));
/// <summary>
/// Formats a date in the <c>MM/dd/yyyy</c> format required by QuickBooks IIF transaction
/// records. QuickBooks Desktop does not accept ISO 8601 dates in IIF imports.
/// </summary>
private static string IifDate(DateTime d) => d.ToString("MM/dd/yyyy");
/// <summary>
/// Returns the display name for a customer: company name when available, otherwise
/// first + last name concatenated. This mirrors the display logic in the UI and ensures
/// IIF/CSV records identify the customer consistently across all export files.
/// </summary>
private static string CustomerName(PowderCoating.Core.Entities.Customer? c)
{
if (c == null) return "Unknown";
return !string.IsNullOrWhiteSpace(c.CompanyName)
? c.CompanyName
: $"{c.ContactFirstName} {c.ContactLastName}".Trim();
}
// ── IIF Builders ──────────────────────────────────────────────────────────
/// <summary>
/// Builds the QuickBooks IIF customer list file. Each customer is emitted as a <c>CUST</c>
/// record row with the <c>!CUST</c> header. The <c>TAXABLE</c> field is the inverse of
/// <c>IsTaxExempt</c>: a tax-exempt customer is "N" (not taxable), consistent with how
/// QuickBooks represents taxability.
/// </summary>
private static string BuildIifCustomers(List<PowderCoating.Core.Entities.Customer> customers)
{
var sb = new StringBuilder();
sb.AppendLine("!CUST\tNAME\tCOMPANYNAME\tFIRSTNAME\tLASTNAME\tBILLADDR1\tBILLADDR2\tPHONE1\tEMAIL\tTERMS\tTAXABLE\tLIMIT");
foreach (var c in customers)
{
var name = CustomerName(c);
var addr2 = !string.IsNullOrWhiteSpace(c.City)
? $"{c.City}, {c.State} {c.ZipCode}".Trim()
: "";
sb.Append("CUST\t");
sb.Append($"{name}\t");
sb.Append($"{c.CompanyName ?? ""}\t");
sb.Append($"{c.ContactFirstName ?? ""}\t");
sb.Append($"{c.ContactLastName ?? ""}\t");
sb.Append($"{c.Address ?? ""}\t");
sb.Append($"{addr2}\t");
sb.Append($"{c.Phone ?? ""}\t");
sb.Append($"{c.Email ?? ""}\t");
sb.Append($"{c.PaymentTerms ?? ""}\t");
sb.Append(c.IsTaxExempt ? "N\t" : "Y\t");
sb.AppendLine($"{c.CreditLimit}");
}
return sb.ToString();
}
/// <summary>
/// Builds the IIF file for invoices and their associated payments. Each invoice is emitted
/// as an <c>INVOICE</c> transaction: a <c>TRNS</c> header row debiting Accounts Receivable,
/// followed by <c>SPL</c> lines crediting the Sales account for each line item (amounts are
/// negated on split lines because IIF splits use the opposite sign from the transaction
/// header). Tax and discount lines are appended as additional split rows when applicable.
/// Each payment is emitted as a separate <c>PAYMENT</c> transaction immediately after its
/// invoice block. The bank account is determined by
/// <see cref="MapPaymentMethodToAccount"/> which maps payment method to a QB account name.
/// </summary>
private static string BuildIifInvoicesAndPayments(List<PowderCoating.Core.Entities.Invoice> invoices)
{
var sb = new StringBuilder();
sb.AppendLine("!TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO\tDUEDATE\tTERMS");
sb.AppendLine("!SPL\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO\tQNTY\tPRICE\tINVITEM");
sb.AppendLine("!ENDTRNS");
foreach (var inv in invoices)
{
var cust = CustomerName(inv.Customer);
var date = IifDate(inv.InvoiceDate);
var due = inv.DueDate.HasValue ? IifDate(inv.DueDate.Value) : date;
var terms = inv.Terms ?? "";
sb.AppendLine($"TRNS\tINVOICE\t{date}\tAccounts Receivable\t{cust}\t{inv.Total}\t{inv.InvoiceNumber}\t{inv.Notes ?? ""}\t{due}\t{terms}");
foreach (var item in inv.InvoiceItems.OrderBy(i => i.DisplayOrder))
{
sb.AppendLine($"SPL\tINVOICE\t{date}\tSales\t{cust}\t{-item.TotalPrice}\t{inv.InvoiceNumber}\t{item.Description}\t{item.Quantity}\t{item.UnitPrice}\t{item.Description}");
}
if (inv.TaxAmount > 0)
sb.AppendLine($"SPL\tINVOICE\t{date}\tSales Tax Payable\t{cust}\t{-inv.TaxAmount}\t{inv.InvoiceNumber}\tSales Tax\t1\t{inv.TaxAmount}\tSales Tax");
if (inv.DiscountAmount > 0)
sb.AppendLine($"SPL\tINVOICE\t{date}\tDiscounts Given\t{cust}\t{inv.DiscountAmount}\t{inv.InvoiceNumber}\tDiscount\t1\t{-inv.DiscountAmount}\tDiscount");
sb.AppendLine("ENDTRNS");
// Payments against this invoice
foreach (var pmt in inv.Payments)
{
var pmtDate = IifDate(pmt.PaymentDate);
var pmtMethod = MapPaymentMethodToAccount(pmt.PaymentMethod);
var memo = $"Payment for {inv.InvoiceNumber}";
sb.AppendLine($"TRNS\tPAYMENT\t{pmtDate}\t{pmtMethod}\t{cust}\t{pmt.Amount}\t{pmt.Reference ?? ""}\t{memo}");
sb.AppendLine($"SPL\tPAYMENT\t{pmtDate}\tAccounts Receivable\t{cust}\t{-pmt.Amount}\t{pmt.Reference ?? ""}\t{memo}");
sb.AppendLine("ENDTRNS");
}
}
return sb.ToString();
}
/// <summary>
/// Builds the IIF file for direct expenses and vendor bills. Direct expenses are emitted as
/// <c>CHECK</c> transactions (payment account → expense account), while vendor bills are
/// emitted as <c>BILL</c> transactions (Accounts Payable → per-line expense accounts). Each
/// bill payment is appended as a <c>BILLPMT</c> transaction after its parent bill, referencing
/// the actual bank account stored on the <c>BillPayment</c> record (or "Checking" as a
/// fallback when no bank account is linked).
/// </summary>
private static string BuildIifExpensesAndBills(
List<PowderCoating.Core.Entities.Expense> expenses,
List<PowderCoating.Core.Entities.Bill> bills)
{
var sb = new StringBuilder();
sb.AppendLine("!TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO");
sb.AppendLine("!SPL\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO");
sb.AppendLine("!ENDTRNS");
foreach (var exp in expenses)
{
var date = IifDate(exp.Date);
var vendor = exp.Vendor?.CompanyName ?? "Unknown Vendor";
var payAcct = exp.PaymentAccount?.Name ?? "Checking";
var expAcct = exp.ExpenseAccount?.Name ?? "General Expenses";
sb.AppendLine($"TRNS\tCHECK\t{date}\t{payAcct}\t{vendor}\t{-exp.Amount}\t{exp.ExpenseNumber}\t{exp.Memo ?? ""}");
sb.AppendLine($"SPL\tCHECK\t{date}\t{expAcct}\t{vendor}\t{exp.Amount}\t{exp.ExpenseNumber}\t{exp.Memo ?? ""}");
sb.AppendLine("ENDTRNS");
}
foreach (var bill in bills)
{
var date = IifDate(bill.BillDate);
var vendor = bill.Vendor?.CompanyName ?? "Unknown Vendor";
sb.AppendLine($"TRNS\tBILL\t{date}\tAccounts Payable\t{vendor}\t{-bill.Total}\t{bill.BillNumber}\t{bill.Memo ?? ""}");
foreach (var line in bill.LineItems.OrderBy(l => l.DisplayOrder))
{
var acct = line.Account?.Name ?? "General Expenses";
sb.AppendLine($"SPL\tBILL\t{date}\t{acct}\t{vendor}\t{line.Amount}\t{bill.BillNumber}\t{line.Description}");
}
sb.AppendLine("ENDTRNS");
foreach (var pmt in bill.Payments)
{
var pmtDate = IifDate(pmt.PaymentDate);
sb.AppendLine($"TRNS\tBILLPMT\t{pmtDate}\t{pmt.BankAccount?.Name ?? "Checking"}\t{vendor}\t{-pmt.Amount}\t{pmt.PaymentNumber}\t{pmt.Memo ?? ""}");
sb.AppendLine($"SPL\tBILLPMT\t{pmtDate}\tAccounts Payable\t{vendor}\t{pmt.Amount}\t{pmt.PaymentNumber}\t{pmt.Memo ?? ""}");
sb.AppendLine("ENDTRNS");
}
}
return sb.ToString();
}
// ── CSV Builders ──────────────────────────────────────────────────────────
/// <summary>
/// Builds a CSV file of all customers in the company with contact info, tax status, credit
/// limit, and current balance. Suitable for import into any CRM or accounting system.
/// </summary>
private static string BuildCsvCustomers(List<PowderCoating.Core.Entities.Customer> customers)
{
var sb = new StringBuilder();
sb.AppendLine(CsvRow("Name", "Company", "First Name", "Last Name", "Email", "Phone",
"Address", "City", "State", "Zip", "Country", "Payment Terms",
"Tax Exempt", "Credit Limit", "Current Balance"));
foreach (var c in customers)
{
sb.AppendLine(CsvRow(
CustomerName(c), c.CompanyName, c.ContactFirstName, c.ContactLastName,
c.Email, c.Phone, c.Address, c.City, c.State, c.ZipCode, c.Country,
c.PaymentTerms, c.IsTaxExempt ? "Yes" : "No",
c.CreditLimit.ToString("F2"), c.CurrentBalance.ToString("F2")));
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV of invoice headers with subtotals, tax, discounts, and balance due. Line
/// items are exported separately in <see cref="BuildCsvInvoiceLineItems"/> to keep the header
/// CSV clean and allow joining by invoice number in the target system.
/// </summary>
private static string BuildCsvInvoices(List<PowderCoating.Core.Entities.Invoice> invoices)
{
var sb = new StringBuilder();
sb.AppendLine(CsvRow("Invoice Number", "Customer", "Invoice Date", "Due Date",
"Terms", "Status", "Sub Total", "Tax %", "Tax Amount",
"Discount", "Total", "Amount Paid", "Balance Due", "Notes", "Customer PO"));
foreach (var inv in invoices)
{
sb.AppendLine(CsvRow(
inv.InvoiceNumber, CustomerName(inv.Customer),
inv.InvoiceDate.ToString("yyyy-MM-dd"),
inv.DueDate?.ToString("yyyy-MM-dd"),
inv.Terms, inv.Status.ToString(),
inv.SubTotal.ToString("F2"), inv.TaxPercent.ToString("F2"),
inv.TaxAmount.ToString("F2"), inv.DiscountAmount.ToString("F2"),
inv.Total.ToString("F2"), inv.AmountPaid.ToString("F2"),
inv.BalanceDue.ToString("F2"), inv.Notes, inv.CustomerPO));
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV of invoice line items with invoice number and customer name repeated on each
/// row so the file can be used standalone without requiring a join to the invoice header CSV.
/// Items are ordered by <c>DisplayOrder</c> to preserve the sequence from the original invoice.
/// </summary>
private static string BuildCsvInvoiceLineItems(List<PowderCoating.Core.Entities.Invoice> invoices)
{
var sb = new StringBuilder();
sb.AppendLine(CsvRow("Invoice Number", "Customer", "Invoice Date", "Description",
"Quantity", "Unit Price", "Total Price", "Notes"));
foreach (var inv in invoices)
{
var cust = CustomerName(inv.Customer);
var date = inv.InvoiceDate.ToString("yyyy-MM-dd");
foreach (var item in inv.InvoiceItems.OrderBy(i => i.DisplayOrder))
{
sb.AppendLine(CsvRow(
inv.InvoiceNumber, cust, date,
item.Description, item.Quantity.ToString("F2"),
item.UnitPrice.ToString("F2"), item.TotalPrice.ToString("F2"),
item.Notes));
}
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV of customer payments received, one row per payment. Derived from the same
/// invoice collection as other invoice CSVs so the data is consistent for the export date
/// range. Payments are ordered by date within each invoice.
/// </summary>
private static string BuildCsvPayments(List<PowderCoating.Core.Entities.Invoice> invoices)
{
var sb = new StringBuilder();
sb.AppendLine(CsvRow("Invoice Number", "Customer", "Payment Date",
"Amount", "Payment Method", "Reference", "Notes"));
foreach (var inv in invoices)
{
var cust = CustomerName(inv.Customer);
foreach (var pmt in inv.Payments.OrderBy(p => p.PaymentDate))
{
sb.AppendLine(CsvRow(
inv.InvoiceNumber, cust,
pmt.PaymentDate.ToString("yyyy-MM-dd"),
pmt.Amount.ToString("F2"),
FormatPaymentMethod(pmt.PaymentMethod),
pmt.Reference, pmt.Notes));
}
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV of direct expenses with vendor, account categorisation, and payment method.
/// These represent cash/card purchases that do not go through the AP workflow.
/// </summary>
private static string BuildCsvExpenses(List<PowderCoating.Core.Entities.Expense> expenses)
{
var sb = new StringBuilder();
sb.AppendLine(CsvRow("Expense Number", "Date", "Vendor", "Expense Account",
"Payment Account", "Payment Method", "Amount", "Memo"));
foreach (var e in expenses)
{
sb.AppendLine(CsvRow(
e.ExpenseNumber, e.Date.ToString("yyyy-MM-dd"),
e.Vendor?.CompanyName ?? "",
e.ExpenseAccount?.Name ?? "",
e.PaymentAccount?.Name ?? "",
FormatPaymentMethod(e.PaymentMethod),
e.Amount.ToString("F2"), e.Memo));
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV of vendor bill headers (AP entries) with status, amounts, and balance due.
/// Line item detail is not included here to keep the file flat; use the IIF export for
/// per-line categorisation data or query the database directly.
/// </summary>
private static string BuildCsvBills(List<PowderCoating.Core.Entities.Bill> bills)
{
var sb = new StringBuilder();
sb.AppendLine(CsvRow("Bill Number", "Vendor Invoice #", "Vendor", "Bill Date",
"Due Date", "Status", "Sub Total", "Tax Amount", "Total",
"Amount Paid", "Balance Due", "Memo"));
foreach (var b in bills)
{
sb.AppendLine(CsvRow(
b.BillNumber, b.VendorInvoiceNumber, b.Vendor?.CompanyName ?? "",
b.BillDate.ToString("yyyy-MM-dd"), b.DueDate?.ToString("yyyy-MM-dd"),
b.Status.ToString(),
b.SubTotal.ToString("F2"), b.TaxAmount.ToString("F2"),
b.Total.ToString("F2"), b.AmountPaid.ToString("F2"),
b.BalanceDue.ToString("F2"), b.Memo));
}
return sb.ToString();
}
/// <summary>
/// Builds a CSV of vendor bill payments, one row per payment. The bill number and vendor name
/// are repeated on each row so the file is self-contained for AP payment reconciliation
/// without requiring a join to the bills CSV.
/// </summary>
private static string BuildCsvBillPayments(List<PowderCoating.Core.Entities.Bill> bills)
{
var sb = new StringBuilder();
sb.AppendLine(CsvRow("Payment Number", "Bill Number", "Vendor", "Payment Date",
"Amount", "Payment Method", "Check Number", "Memo"));
foreach (var b in bills)
{
foreach (var pmt in b.Payments.OrderBy(p => p.PaymentDate))
{
sb.AppendLine(CsvRow(
pmt.PaymentNumber, b.BillNumber, b.Vendor?.CompanyName ?? "",
pmt.PaymentDate.ToString("yyyy-MM-dd"),
pmt.Amount.ToString("F2"),
FormatPaymentMethod(pmt.PaymentMethod),
pmt.CheckNumber, pmt.Memo));
}
}
return sb.ToString();
}
// ── README ────────────────────────────────────────────────────────────────
/// <summary>
/// Generates a human-readable README.txt file included in the export ZIP. For QuickBooks
/// exports it documents the required import order (customers → invoices → expenses/bills)
/// and warns that IIF account names must match the company's QB chart of accounts exactly.
/// For CSV exports it lists all files and their contents.
/// </summary>
private static string BuildReadme(string format, DateTime start, DateTime end)
{
var sb = new StringBuilder();
sb.AppendLine("ACCOUNTING DATA EXPORT");
sb.AppendLine("======================");
sb.AppendLine($"Format: {format}");
sb.AppendLine($"Period: {start:MMMM d, yyyy} {end:MMMM d, yyyy}");
sb.AppendLine($"Generated: {DateTime.Now:MMMM d, yyyy h:mm tt}");
sb.AppendLine();
if (format.Contains("QuickBooks"))
{
sb.AppendLine("IMPORT ORDER (QuickBooks Desktop)");
sb.AppendLine("---------------------------------");
sb.AppendLine("1. File > Utilities > Import > IIF Files");
sb.AppendLine("2. Import in this order:");
sb.AppendLine(" a. 1_customers.iif");
sb.AppendLine(" b. 2_invoices_payments.iif");
sb.AppendLine(" c. 3_expenses_bills.iif");
sb.AppendLine();
sb.AppendLine("NOTE: Review your Chart of Accounts in QuickBooks before importing.");
sb.AppendLine(" Account names in the IIF files must match your QB accounts exactly.");
}
else
{
sb.AppendLine("FILES INCLUDED");
sb.AppendLine("--------------");
sb.AppendLine("customers.csv Customer list with balances");
sb.AppendLine("invoices.csv Invoice headers");
sb.AppendLine("invoice_line_items.csv Invoice line item detail");
sb.AppendLine("payments_received.csv Customer payments");
sb.AppendLine("expenses.csv Direct expenses");
sb.AppendLine("bills.csv Vendor bills (AP)");
sb.AppendLine("bill_payments.csv Payments made to vendors");
}
return sb.ToString();
}
// ── Enum helpers ──────────────────────────────────────────────────────────
/// <summary>
/// Maps a <see cref="PaymentMethod"/> enum value to the standard QuickBooks account name
/// used in IIF transaction records. QuickBooks uses account names (not IDs) in IIF imports,
/// so these strings must match the company's QB chart of accounts exactly. Cash maps to
/// "Petty Cash" and card/digital payments map to "Merchant Account" per common QB conventions.
/// </summary>
private static string MapPaymentMethodToAccount(PowderCoating.Core.Enums.PaymentMethod method) => method switch
{
PowderCoating.Core.Enums.PaymentMethod.Cash => "Petty Cash",
PowderCoating.Core.Enums.PaymentMethod.Check => "Checking",
PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard => "Merchant Account",
PowderCoating.Core.Enums.PaymentMethod.BankTransferACH => "Checking",
PowderCoating.Core.Enums.PaymentMethod.DigitalPayment => "Merchant Account",
_ => "Undeposited Funds"
};
/// <summary>
/// Formats a <see cref="PaymentMethod"/> enum value as a human-readable string for CSV
/// output. Unlike <see cref="MapPaymentMethodToAccount"/>, this returns a descriptive label
/// (e.g. "ACH / Bank Transfer") rather than a QB account name, suitable for display in
/// spreadsheets and non-QB accounting tools.
/// </summary>
private static string FormatPaymentMethod(PowderCoating.Core.Enums.PaymentMethod method) => method switch
{
PowderCoating.Core.Enums.PaymentMethod.Cash => "Cash",
PowderCoating.Core.Enums.PaymentMethod.Check => "Check",
PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard => "Credit/Debit Card",
PowderCoating.Core.Enums.PaymentMethod.BankTransferACH => "ACH / Bank Transfer",
PowderCoating.Core.Enums.PaymentMethod.DigitalPayment => "Digital Payment",
_ => method.ToString()
};
}
@@ -0,0 +1,455 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanViewData)]
public class AccountsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<AccountsController> _logger;
private readonly ISeedDataService _seedDataService;
private readonly ITenantContext _tenantContext;
private readonly ILedgerService _ledgerService;
private readonly IAccountBalanceService _accountBalanceService;
public AccountsController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<AccountsController> logger,
ISeedDataService seedDataService,
ITenantContext tenantContext,
ILedgerService ledgerService,
IAccountBalanceService accountBalanceService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_seedDataService = seedDataService;
_tenantContext = tenantContext;
_ledgerService = ledgerService;
_accountBalanceService = accountBalanceService;
}
/// <summary>
/// Displays the chart of accounts grouped by account type (Asset, Liability, Equity, Revenue,
/// Expense, CostOfGoods) in ascending account-number order within each group. Grouping is done
/// in memory after a single DB fetch to avoid multiple queries. The parent account navigation
/// property is eagerly loaded so the view can display the account hierarchy without lazy
/// loading.
/// </summary>
// GET: /Accounts
public async Task<IActionResult> Index()
{
var accounts = await _unitOfWork.Accounts.GetAllAsync(false, a => a.ParentAccount);
var dtos = _mapper.Map<List<AccountListDto>>(accounts.OrderBy(a => a.AccountNumber).ToList());
// Group by AccountType for display
var grouped = dtos
.GroupBy(a => a.AccountType)
.OrderBy(g => (int)g.Key)
.ToList();
return View(grouped);
}
/// <summary>
/// Returns the blank account creation form. Restricted to CompanyAdmin because adding
/// accounts affects the double-entry accounting structure for the entire company.
/// </summary>
// GET: /Accounts/Create
/// <summary>
/// Renders the account creation form. When <paramref name="inline"/> is true the layout is
/// stripped for modal embedding. <paramref name="preSubType"/> pre-selects the AccountSubType
/// (and derives AccountType) so context-specific modals open with the right type already chosen —
/// e.g. preSubType=4 (Inventory) from asset pickers, preSubType=40 (CostOfGoodsSold) from COGS pickers.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> Create(bool inline = false, AccountSubType? preSubType = null)
{
await PopulateDropdownsAsync();
var dto = new CreateAccountDto { IsActive = true };
if (preSubType.HasValue)
{
dto.AccountSubType = preSubType.Value;
dto.AccountType = preSubType.Value switch
{
AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable
or AccountSubType.Inventory or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset or AccountSubType.OtherAsset => AccountType.Asset,
AccountSubType.AccountsPayable or AccountSubType.CreditCard
or AccountSubType.OtherCurrentLiability or AccountSubType.LongTermLiability => AccountType.Liability,
AccountSubType.OwnersEquity or AccountSubType.RetainedEarnings => AccountType.Equity,
AccountSubType.Sales or AccountSubType.ServiceRevenue or AccountSubType.OtherIncome => AccountType.Revenue,
AccountSubType.CostOfGoodsSold => AccountType.CostOfGoods,
_ => AccountType.Expense
};
}
ViewBag.Inline = inline;
if (inline)
return PartialView(dto);
return View(dto);
}
/// <summary>
/// Persists a new account. Account number uniqueness is enforced within the company's chart
/// of accounts to prevent duplicate ledger entries. The check is performed after model
/// validation so the user sees both validation errors and duplicate-number errors in the same
/// form round-trip. When <paramref name="inline"/> is true (quick-add modal path) returns JSON
/// {success, id, name} instead of a redirect so the caller can populate the originating select.
/// </summary>
// POST: /Accounts/Create
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> Create(CreateAccountDto dto, bool inline = false)
{
if (!ModelState.IsValid)
{
if (inline)
{
var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage);
return Json(new { success = false, errors });
}
await PopulateDropdownsAsync();
return View(dto);
}
try
{
var currentUser = await _userManager.GetUserAsync(User);
// Check for duplicate account number
var existing = await _unitOfWork.Accounts.FindAsync(a => a.AccountNumber == dto.AccountNumber);
if (existing.Any())
{
ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists.");
if (inline)
return Json(new { success = false, errors = new[] { "An account with this number already exists." } });
await PopulateDropdownsAsync();
return View(dto);
}
var account = _mapper.Map<Account>(dto);
account.CompanyId = currentUser!.CompanyId;
account.CreatedBy = currentUser.Email;
await _unitOfWork.Accounts.AddAsync(account);
await _unitOfWork.CompleteAsync();
if (inline)
return Json(new { success = true, id = account.Id, name = $"{account.AccountNumber} {account.Name}" });
TempData["Success"] = $"Account '{account.AccountNumber} {account.Name}' created.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating account");
if (inline)
return Json(new { success = false, errors = new[] { "An error occurred while saving." } });
ModelState.AddModelError(string.Empty, "An error occurred while saving.");
await PopulateDropdownsAsync();
return View(dto);
}
}
/// <summary>
/// Returns the edit form for an account. The account being edited is excluded from the
/// parent-account dropdown (via <paramref name="excludeId"/>) to prevent circular parent
/// references that would break account hierarchy traversal.
/// </summary>
// GET: /Accounts/Edit/5
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> Edit(int? id)
{
if (id == null) return NotFound();
var account = await _unitOfWork.Accounts.GetByIdAsync(id.Value);
if (account == null) return NotFound();
var dto = _mapper.Map<EditAccountDto>(account);
await PopulateDropdownsAsync(excludeId: id.Value);
return View(dto);
}
/// <summary>
/// Saves account edits. The duplicate-number check excludes the account being edited
/// (<c>a.Id != id</c>) so users can save without changing the number. Account type and
/// sub-type changes are allowed on non-system accounts; changing them on an account with
/// existing transactions will affect how those transactions are presented in reports, so this
/// should be used with care.
/// </summary>
// POST: /Accounts/Edit/5
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> Edit(int id, EditAccountDto dto)
{
if (id != dto.Id) return NotFound();
if (!ModelState.IsValid)
{
await PopulateDropdownsAsync(excludeId: id);
return View(dto);
}
try
{
var account = await _unitOfWork.Accounts.GetByIdAsync(id);
if (account == null) return NotFound();
// Check duplicate number (excluding self)
var existing = await _unitOfWork.Accounts.FindAsync(
a => a.AccountNumber == dto.AccountNumber && a.Id != id);
if (existing.Any())
{
ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists.");
await PopulateDropdownsAsync(excludeId: id);
return View(dto);
}
_mapper.Map(dto, account);
account.UpdatedAt = DateTime.UtcNow;
account.UpdatedBy = (await _userManager.GetUserAsync(User))?.Email;
await _unitOfWork.Accounts.UpdateAsync(account);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Account '{account.AccountNumber} {account.Name}' updated.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating account {Id}", id);
ModelState.AddModelError(string.Empty, "An error occurred while saving.");
await PopulateDropdownsAsync(excludeId: id);
return View(dto);
}
}
/// <summary>
/// Displays account detail including parent account and direct sub-accounts, giving a view
/// of where the account sits in the hierarchy.
/// </summary>
// GET: /Accounts/Details/5
public async Task<IActionResult> Details(int? id)
{
if (id == null) return NotFound();
var account = await _unitOfWork.Accounts.GetByIdAsync(id.Value, false, a => a.ParentAccount, a => a.SubAccounts);
if (account == null) return NotFound();
return View(_mapper.Map<AccountDto>(account));
}
/// <summary>
/// Soft-deletes a user-defined account. System accounts (seeded by the platform and marked
/// <c>IsSystem = true</c>) cannot be deleted because removing them would break core accounting
/// rules (e.g. the Accounts Receivable or Accounts Payable accounts used by the invoice and
/// bill modules). No balance-reversal is performed here; the caller is expected to reassign
/// any transactions before deleting an account.
/// </summary>
// POST: /Accounts/Delete/5
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> Delete(int id)
{
var account = await _unitOfWork.Accounts.GetByIdAsync(id);
if (account == null) return NotFound();
if (account.IsSystem)
{
TempData["Error"] = "System accounts cannot be deleted.";
return RedirectToAction(nameof(Index));
}
await _unitOfWork.Accounts.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Account '{account.AccountNumber} {account.Name}' deleted.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Seeds the standard chart of accounts for the current tenant. Delegates to
/// <see cref="ISeedDataService.SeedCompanyLookupsAsync"/> which is idempotent — it checks for
/// existing accounts and reports zero items seeded rather than creating duplicates. This is
/// the only supported path for new companies to bootstrap accounting; seeding is not automatic
/// on company creation so that admins can opt for a manual or imported chart of accounts
/// instead.
/// </summary>
// POST: /Accounts/SeedDefaultAccounts
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> SeedDefaultAccounts()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
try
{
var result = await _seedDataService.SeedCompanyLookupsAsync(companyId.Value);
if (result.Success && result.ItemsSeeded > 0)
TempData["Success"] = $"Default chart of accounts created successfully ({result.ItemsSeeded} accounts added).";
else if (result.Success)
TempData["Error"] = "Accounts already exist — nothing was seeded.";
else
TempData["Error"] = result.Message;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error seeding default chart of accounts for company {CompanyId}", companyId);
TempData["Error"] = "An error occurred while creating the default accounts.";
}
return RedirectToAction(nameof(Index));
}
/// <summary>
/// One-time data repair for companies whose chart of accounts was imported from QuickBooks
/// IIF files. QuickBooks IIF exports store credit-normal account opening balances as negative
/// numbers (e.g. Revenue accounts), but the application's convention is to store all opening
/// balances as positive amounts with the credit/debit nature implied by account type. This
/// action flips negative opening balances on Revenue, Liability, and Equity accounts to their
/// absolute values. After running this, <see cref="RecalculateBalances"/> should be called to
/// propagate the corrected opening balances into <c>CurrentBalance</c>.
/// </summary>
// POST: /Accounts/FixOpeningBalanceSigns
// One-time fix: QB IIF imports store credit-normal accounts with negative opening balances.
// This flips them to positive so the chart of accounts displays correctly.
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> FixOpeningBalanceSigns()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
try
{
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
int fixed_ = 0;
foreach (var acct in accounts)
{
if (acct.OpeningBalance < 0 &&
acct.AccountType is Core.Enums.AccountType.Revenue
or Core.Enums.AccountType.Liability
or Core.Enums.AccountType.Equity)
{
acct.OpeningBalance = Math.Abs(acct.OpeningBalance);
acct.CurrentBalance = Math.Abs(acct.CurrentBalance);
await _unitOfWork.Accounts.UpdateAsync(acct);
fixed_++;
}
}
await _unitOfWork.CompleteAsync();
TempData["Success"] = fixed_ > 0
? $"Fixed {fixed_} account(s) with negative opening balances. Run Recalculate Balances to update CurrentBalance."
: "No accounts needed fixing — all opening balances already have the correct sign.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fixing opening balance signs for company {CompanyId}", companyId);
TempData["Error"] = "An error occurred while fixing opening balances.";
}
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Triggers a full recalculation of <c>CurrentBalance</c> for every account in the company
/// by replaying all transactions through <see cref="IAccountBalanceService.RecalculateAllAsync"/>.
/// This is a corrective tool for situations where balances have drifted due to data migrations,
/// IIF imports, or manual database edits. It is safe to run repeatedly; the resulting balances
/// will always match the sum of all associated transaction amounts from opening balance.
/// </summary>
// POST: /Accounts/RecalculateBalances
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> RecalculateBalances()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
try
{
await _accountBalanceService.RecalculateAllAsync(companyId.Value);
TempData["Success"] = "Account balances recalculated successfully.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recalculating account balances for company {CompanyId}", companyId);
TempData["Error"] = "An error occurred while recalculating balances.";
}
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Displays the account ledger — a date-ranged list of all transactions that touched the
/// given account, with running balances. Defaults to the last 3 months when no date range is
/// supplied. The ledger is computed by <see cref="ILedgerService.GetAccountLedgerAsync"/>
/// which assembles entries from bills, payments, expenses, invoices, and deposits.
/// </summary>
// GET: /Accounts/Ledger/5?from=2026-01-01&to=2026-03-31
public async Task<IActionResult> Ledger(int? id, DateTime? from, DateTime? to)
{
if (id == null) return NotFound();
var fromDate = from ?? DateTime.UtcNow.AddMonths(-3);
var toDate = to ?? DateTime.UtcNow;
var ledger = await _ledgerService.GetAccountLedgerAsync(id.Value, fromDate, toDate);
if (ledger == null) return NotFound();
return View(ledger);
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>
/// Loads Create/Edit form dropdowns: the parent account list (optionally excluding the
/// account being edited to prevent circular references), account type enum values, and
/// account sub-type enum values.
/// </summary>
private async Task PopulateDropdownsAsync(int? excludeId = null)
{
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => excludeId == null || a.Id != excludeId.Value);
ViewBag.ParentAccounts = allAccounts
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem(
$"{a.AccountNumber} {a.Name}",
a.Id.ToString()))
.ToList();
ViewBag.AccountTypes = Enum.GetValues<AccountType>()
.Select(t => new SelectListItem(t.ToString(), ((int)t).ToString()))
.ToList();
ViewBag.AccountSubTypes = Enum.GetValues<AccountSubType>()
.Select(t => new SelectListItem(t.ToString(), ((int)t).ToString()))
.ToList();
}
}
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using PowderCoating.Application.Interfaces;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers;
using System.Security.Claims;
namespace PowderCoating.Web.Controllers;
[Authorize]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public class AiHelpController : Controller
{
private readonly IAiHelpService _aiHelpService;
private readonly IAiUsageLogger _usageLogger;
private readonly ILogger<AiHelpController> _logger;
public AiHelpController(IAiHelpService aiHelpService, IAiUsageLogger usageLogger, ILogger<AiHelpController> logger)
{
_aiHelpService = aiHelpService;
_usageLogger = usageLogger;
_logger = logger;
}
/// <summary>
/// AJAX endpoint that receives a chat message plus conversation history and returns an AI-generated contextual help response. The system prompt is built from HelpKnowledgeBase with the current user's name, company name, and role so the AI can give relevant, role-appropriate guidance. Input length is capped at 1000 characters to limit token cost and prevent prompt injection payloads. Rate-limited by the AI rate-limit policy defined in AppConstants.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Chat([FromBody] AiHelpChatRequest request)
{
if (string.IsNullOrWhiteSpace(request.Message))
return BadRequest(new { error = "Message cannot be empty." });
if (request.Message.Length > 1000)
return BadRequest(new { error = "Message is too long (max 1000 characters)." });
var userName = User.Identity?.Name ?? "User";
var companyName = User.FindFirst("CompanyName")?.Value ?? "your company";
var userRole = User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value
?? User.FindFirst("CompanyRole")?.Value
?? "User";
var systemPrompt = HelpKnowledgeBase.GetSystemPrompt(companyName, userRole, userName);
_logger.LogDebug("AI Help chat from {User} ({Role}) at {Company}", userName, userRole, companyName);
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "";
var response = await _aiHelpService.SendMessageAsync(
request.History ?? [],
request.Message,
systemPrompt);
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.HelpChat, inputLength: request.Message.Length);
return Json(new { response });
}
}
public class AiHelpChatRequest
{
public string Message { get; set; } = string.Empty;
public List<AiHelpMessage> History { get; set; } = [];
}
@@ -0,0 +1,199 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class AiUsageReportController : Controller
{
private readonly ApplicationDbContext _context;
private readonly ILogger<AiUsageReportController> _logger;
public AiUsageReportController(ApplicationDbContext context, ILogger<AiUsageReportController> logger)
{
_context = context;
_logger = logger;
}
/// <summary>
/// Platform-wide AI usage report. Shows per-company call counts, photo upload totals, top
/// feature used, and a usage tier so SuperAdmins can identify abusive or unusually heavy tenants.
/// All queries use IgnoreQueryFilters() where needed to cross tenant boundaries.
/// </summary>
public async Task<IActionResult> Index()
{
try
{
var now = DateTime.UtcNow;
var todayStart = now.Date;
var last7Start = todayStart.AddDays(-7);
var last30Start = todayStart.AddDays(-30);
// Companies (non-deleted only)
var companies = await _context.Companies
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.Select(c => new { c.Id, c.CompanyName, c.SubscriptionPlan, c.IsActive })
.ToListAsync();
// Plan display names from SubscriptionPlanConfig
var planConfigs = await _context.Set<PowderCoating.Core.Entities.SubscriptionPlanConfig>()
.IgnoreQueryFilters()
.Where(p => !p.IsDeleted)
.Select(p => new { p.Plan, p.DisplayName })
.ToListAsync();
var planNames = planConfigs.ToDictionary(p => p.Plan, p => p.DisplayName);
// All-time usage grouped by company — the four count windows are computed in SQL
var usageByCompany = await _context.AiUsageLogs
.GroupBy(l => l.CompanyId)
.Select(g => new
{
CompanyId = g.Key,
Today = g.Count(l => l.CalledAt >= todayStart),
Last7Days = g.Count(l => l.CalledAt >= last7Start),
Last30Days = g.Count(l => l.CalledAt >= last30Start),
AllTime = g.Count()
})
.ToListAsync();
// Top feature per company over the last 30 days
var featureStats = await _context.AiUsageLogs
.Where(l => l.CalledAt >= last30Start)
.GroupBy(l => new { l.CompanyId, l.Feature })
.Select(g => new { g.Key.CompanyId, g.Key.Feature, Count = g.Count() })
.ToListAsync();
var topFeatureByCompany = featureStats
.GroupBy(f => f.CompanyId)
.ToDictionary(
g => g.Key,
g => g.OrderByDescending(f => f.Count).First().Feature);
// Feature breakdown per company (last 30 days)
var featureBreakdownByCompany = featureStats
.GroupBy(f => f.CompanyId)
.ToDictionary(
g => g.Key,
g => g.ToDictionary(f => f.Feature, f => f.Count));
// Total AI photos per company (all time, including deleted photos)
var photoCounts = await _context.QuotePhotos
.IgnoreQueryFilters()
.Where(p => p.IsAiAnalysisPhoto && !p.IsDeleted)
.GroupBy(p => p.CompanyId)
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
// Build report rows
var usageDict = usageByCompany.ToDictionary(u => u.CompanyId);
var rows = companies.Select(c =>
{
usageDict.TryGetValue(c.Id, out var u);
photoCounts.TryGetValue(c.Id, out var photos);
topFeatureByCompany.TryGetValue(c.Id, out var topFeature);
featureBreakdownByCompany.TryGetValue(c.Id, out var breakdown);
planNames.TryGetValue(c.SubscriptionPlan, out var planName);
return new AiUsageReportRow
{
CompanyId = c.Id,
CompanyName = c.CompanyName,
Plan = planName ?? $"Plan {c.SubscriptionPlan}",
IsActive = c.IsActive,
Today = u?.Today ?? 0,
Last7Days = u?.Last7Days ?? 0,
Last30Days = u?.Last30Days ?? 0,
AllTime = u?.AllTime ?? 0,
PhotoCount = photos,
TopFeature = topFeature,
FeatureBreakdown = breakdown ?? []
};
})
.OrderByDescending(r => r.Last30Days)
.ThenByDescending(r => r.AllTime)
.ToList();
// Platform totals for summary cards
var vm = new AiUsageReportViewModel
{
Rows = rows,
TotalCallsLast30Days = rows.Sum(r => r.Last30Days),
TotalCallsToday = rows.Sum(r => r.Today),
CompaniesActiveToday = rows.Count(r => r.Today > 0),
TotalPhotosUploaded = rows.Sum(r => r.PhotoCount),
MostActiveCompany = rows.FirstOrDefault(r => r.Last30Days > 0)?.CompanyName ?? "—"
};
return View(vm);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading AI usage report");
return View(new AiUsageReportViewModel());
}
}
}
// ── View models ──────────────────────────────────────────────────────────────
public class AiUsageReportViewModel
{
public List<AiUsageReportRow> Rows { get; set; } = [];
public int TotalCallsLast30Days { get; set; }
public int TotalCallsToday { get; set; }
public int CompaniesActiveToday { get; set; }
public int TotalPhotosUploaded { get; set; }
public string MostActiveCompany { get; set; } = "—";
}
public class AiUsageReportRow
{
public int CompanyId { get; set; }
public string CompanyName { get; set; } = string.Empty;
public string Plan { get; set; } = string.Empty;
public bool IsActive { get; set; }
public int Today { get; set; }
public int Last7Days { get; set; }
public int Last30Days { get; set; }
public int AllTime { get; set; }
public int PhotoCount { get; set; }
public string? TopFeature { get; set; }
public Dictionary<string, int> FeatureBreakdown { get; set; } = [];
public string UsageTier => Last30Days switch
{
0 => "Inactive",
<= 10 => "Light",
<= 50 => "Regular",
<= 200 => "Heavy",
_ => "Power User"
};
public string TierBadgeClass => UsageTier switch
{
"Inactive" => "bg-secondary",
"Light" => "bg-success",
"Regular" => "bg-primary",
"Heavy" => "bg-warning text-dark",
"Power User" => "bg-danger",
_ => "bg-secondary"
};
public string FeatureDisplayName(string feature) => feature switch
{
"PhotoQuote" => "Photo Quote",
"HelpChat" => "Help Chat",
"ReceiptScan" => "Receipt Scan",
"AccountSuggest" => "Account Suggest",
"ArFollowUp" => "AR Follow-Up",
"FinancialSummary" => "Financial Summary",
"CashFlowForecast" => "Cash Flow",
"AnomalyDetection" => "Anomaly Detection",
_ => feature
};
}
@@ -0,0 +1,157 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class AnnouncementsController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IInAppNotificationService _inApp;
public AnnouncementsController(ApplicationDbContext db, IInAppNotificationService inApp)
{
_db = db;
_inApp = inApp;
}
/// <summary>
/// Lists all platform announcements in reverse-chronological order. SuperAdmin-only (enforced at controller level by the SuperAdminOnly policy).
/// </summary>
public async Task<IActionResult> Index()
{
var announcements = await _db.Announcements
.OrderByDescending(a => a.CreatedAt)
.ToListAsync();
return View(announcements);
}
/// <summary>
/// Shows the announcement creation form with sensible defaults: starts now, dismissible, and active.
/// </summary>
public IActionResult Create()
{
PopulateDropdowns();
return View(new Announcement { StartsAt = DateTime.Now, IsDismissible = true, IsActive = true });
}
/// <summary>
/// Persists a new announcement and immediately dispatches it as in-app notifications to all targeted companies. Dates are converted to UTC before storage; DispatchNotificationsAsync handles the fan-out.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Create(Announcement model)
{
if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); }
model.CreatedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
model.CreatedByUserName = User.Identity?.Name ?? "SuperAdmin";
model.CreatedAt = DateTime.UtcNow;
model.StartsAt = model.StartsAt.ToUniversalTime();
if (model.ExpiresAt.HasValue) model.ExpiresAt = model.ExpiresAt.Value.ToUniversalTime();
_db.Announcements.Add(model);
await _db.SaveChangesAsync();
// Dispatch as in-app notifications to targeted companies
await DispatchNotificationsAsync(model);
TempData["Success"] = "Announcement created and sent as notifications.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Shows the edit form for an existing announcement. Note: editing does NOT re-dispatch notifications; it only updates the stored announcement record.
/// </summary>
public async Task<IActionResult> Edit(int id)
{
var announcement = await _db.Announcements.FindAsync(id);
if (announcement == null) return NotFound();
PopulateDropdowns();
return View(announcement);
}
/// <summary>
/// Saves changes to an existing announcement. TargetPlan and TargetCompanyId are cleared when the Target field changes, preventing stale filter values from persisting on the record.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, Announcement model)
{
if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); }
var existing = await _db.Announcements.FindAsync(id);
if (existing == null) return NotFound();
existing.Title = model.Title;
existing.Message = model.Message;
existing.Type = model.Type;
existing.Target = model.Target;
existing.TargetPlan = model.Target == "Plan" ? model.TargetPlan : null;
existing.TargetCompanyId = model.Target == "Company" ? model.TargetCompanyId : null;
existing.StartsAt = model.StartsAt.ToUniversalTime();
existing.ExpiresAt = model.ExpiresAt.HasValue ? model.ExpiresAt.Value.ToUniversalTime() : null;
existing.IsDismissible = model.IsDismissible;
existing.IsActive = model.IsActive;
existing.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
TempData["Success"] = "Announcement updated.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Permanently deletes an announcement record. Hard delete is intentional here — announcements are platform content, not business data, and do not require audit-trail soft deletion.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var announcement = await _db.Announcements.FindAsync(id);
if (announcement == null) return NotFound();
_db.Announcements.Remove(announcement);
await _db.SaveChangesAsync();
TempData["Success"] = "Announcement deleted.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Fans out the announcement as in-app notifications to each matching company. IgnoreQueryFilters is required to reach all active companies regardless of tenant context (this runs as SuperAdmin). Filtering by Target/Plan/Company happens before the foreach so only relevant tenants receive the notification.
/// </summary>
private async Task DispatchNotificationsAsync(Announcement model)
{
IQueryable<PowderCoating.Core.Entities.Company> companyQuery = _db.Companies
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted && c.IsActive);
if (model.Target == "Plan" && model.TargetPlan.HasValue)
companyQuery = companyQuery.Where(c => c.SubscriptionPlan == model.TargetPlan.Value);
else if (model.Target == "Company" && model.TargetCompanyId.HasValue)
companyQuery = companyQuery.Where(c => c.Id == model.TargetCompanyId.Value);
var companyIds = await companyQuery.Select(c => c.Id).ToListAsync();
foreach (var companyId in companyIds)
{
await _inApp.CreateAsync(
companyId,
model.Title,
model.Message,
"Announcement");
}
}
/// <summary>
/// Loads company and plan lists into ViewBag for the Create/Edit form dropdowns. Uses AsNoTracking and IgnoreQueryFilters to bypass soft-delete and tenant filters so all companies are available for targeting.
/// </summary>
private void PopulateDropdowns()
{
ViewBag.Companies = _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted).OrderBy(c => c.CompanyName)
.Select(c => new { c.Id, c.CompanyName }).ToList();
ViewBag.PlanConfigs = _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
.Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToList();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,229 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only viewer for the application audit log, which records who changed
/// what entity and when. Audit log records are never soft-deleted or modified by
/// the application — they are append-only by design to maintain an unambiguous
/// trail of changes across all tenants.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class AuditLogController : Controller
{
private readonly ApplicationDbContext _db;
private readonly ILogger<AuditLogController> _logger;
private readonly IPlatformSettingsService _platformSettings;
public AuditLogController(ApplicationDbContext db, ILogger<AuditLogController> logger,
IPlatformSettingsService platformSettings)
{
_db = db;
_logger = logger;
_platformSettings = platformSettings;
}
/// <summary>
/// Renders a paginated, filterable audit log across all companies. Supports
/// free-text search on username, entity description, and entity Id; and
/// structured filters for entity type, action verb, company, and date range.
/// <para>
/// Date filters are converted to UTC before querying because audit log timestamps
/// are stored in UTC. The <c>to</c> date filter adds one day so that entries
/// timestamped at any point during the selected end-date are included.
/// Entity-type and action dropdowns are populated from distinct values in the
/// table (not from a static enum) so the UI always reflects exactly what types
/// and actions are present in the log.
/// </para>
/// </summary>
public async Task<IActionResult> Index(
string? search,
string? entityType,
[FromQuery] string? action,
int? companyId,
DateTime? from,
DateTime? to,
int page = 1,
int pageSize = 50)
{
pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50;
page = Math.Max(1, page);
var query = _db.AuditLogs.AsNoTracking();
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(a =>
a.UserName.Contains(search) ||
(a.EntityDescription != null && a.EntityDescription.Contains(search)) ||
(a.EntityId != null && a.EntityId.Contains(search)));
if (!string.IsNullOrWhiteSpace(entityType))
query = query.Where(a => a.EntityType == entityType);
if (!string.IsNullOrWhiteSpace(action))
query = query.Where(a => a.Action == action);
if (companyId.HasValue)
query = query.Where(a => a.CompanyId == companyId);
if (from.HasValue)
query = query.Where(a => a.Timestamp >= from.Value.ToUniversalTime());
if (to.HasValue)
query = query.Where(a => a.Timestamp < to.Value.ToUniversalTime().AddDays(1));
var totalCount = await query.CountAsync();
var logs = await query
.OrderByDescending(a => a.Timestamp)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
// Populate filter dropdowns from distinct values
ViewBag.EntityTypes = await _db.AuditLogs.AsNoTracking()
.Select(a => a.EntityType).Distinct().OrderBy(x => x).ToListAsync();
ViewBag.Companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted).OrderBy(c => c.CompanyName)
.Select(c => new { c.Id, c.CompanyName }).ToListAsync();
ViewBag.Search = search;
ViewBag.EntityType = entityType;
ViewBag.Action = action;
ViewBag.CompanyId = companyId;
ViewBag.From = from?.ToString("yyyy-MM-dd");
ViewBag.To = to?.ToString("yyyy-MM-dd");
ViewBag.Page = page;
ViewBag.PageSize = pageSize;
ViewBag.TotalCount = totalCount;
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
return View(logs);
}
/// <summary>
/// Returns the full detail view for a single audit log entry, including the
/// before/after JSON snapshot stored in the record. Uses <c>long</c> for the
/// Id parameter because audit log primary keys are 64-bit integers to
/// accommodate high-volume writes without risk of overflow.
/// </summary>
public async Task<IActionResult> Details(long id)
{
var log = await _db.AuditLogs.AsNoTracking().FirstOrDefaultAsync(a => a.Id == id);
if (log == null) return NotFound();
return View(log);
}
/// <summary>
/// Diagnostic action — tests a live INSERT into AuditLogs and reports table state.
/// Useful for diagnosing why prod shows 0 entries: isolates whether the table is
/// writable, what the retention setting is, and whether any rows exist at all.
/// Returns JSON so it can be called from the browser address bar without a view.
/// </summary>
[HttpGet]
public async Task<IActionResult> Diagnostics()
{
var results = new Dictionary<string, object?>();
// 1. Row count
try
{
results["rowCount"] = await _db.AuditLogs.AsNoTracking().CountAsync();
}
catch (Exception ex)
{
results["rowCountError"] = ex.Message;
}
// 2. Oldest and newest timestamps
try
{
var oldest = await _db.AuditLogs.AsNoTracking().MinAsync(a => (DateTime?)a.Timestamp);
var newest = await _db.AuditLogs.AsNoTracking().MaxAsync(a => (DateTime?)a.Timestamp);
results["oldestEntry"] = oldest;
results["newestEntry"] = newest;
}
catch (Exception ex)
{
results["timestampError"] = ex.Message;
}
// 3. Retention setting
try
{
var raw = await _platformSettings.GetAsync(PlatformSettingKeys.AuditLogRetentionDays);
results["retentionRaw"] = raw;
results["retentionDays"] = int.TryParse(raw, out var d) ? d : 365;
results["retentionCutoff"] = DateTime.UtcNow.AddDays(-(int.TryParse(raw, out var d2) ? d2 : 365));
}
catch (Exception ex)
{
results["retentionError"] = ex.Message;
}
// 4. Test direct INSERT via EF (same path used by WriteLoginAuditAsync)
string? insertError = null;
long? insertedId = null;
try
{
var testEntry = new AuditLog
{
UserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value,
UserName = User.Identity?.Name ?? "Diagnostics",
Action = "DiagnosticsTest",
EntityType = "AuditLog",
EntityId = "diag",
EntityDescription = "Diagnostic test write",
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
Timestamp = DateTime.UtcNow
};
_db.AuditLogs.Add(testEntry);
await _db.SaveChangesAsync();
insertedId = testEntry.Id;
results["testInsertId"] = insertedId;
results["testInsertSuccess"] = true;
// Clean it up immediately
_db.AuditLogs.Remove(testEntry);
await _db.SaveChangesAsync();
results["testInsertCleanedUp"] = true;
}
catch (Exception ex)
{
insertError = ex.Message;
results["testInsertSuccess"] = false;
results["testInsertError"] = ex.Message;
results["testInsertInner"] = ex.InnerException?.Message;
}
// 5. Connection string info (safe — shows server/db only, no credentials)
try
{
var conn = _db.Database.GetConnectionString() ?? "";
// Extract Server= and Database= segments only (no passwords)
var safeConn = string.Join("; ", conn.Split(';')
.Where(p => p.TrimStart().StartsWith("Server", StringComparison.OrdinalIgnoreCase)
|| p.TrimStart().StartsWith("Database", StringComparison.OrdinalIgnoreCase)
|| p.TrimStart().StartsWith("Data Source", StringComparison.OrdinalIgnoreCase)
|| p.TrimStart().StartsWith("Initial Catalog", StringComparison.OrdinalIgnoreCase)));
results["connectionInfo"] = safeConn;
}
catch (Exception ex)
{
results["connectionError"] = ex.Message;
}
results["serverUtcNow"] = DateTime.UtcNow;
results["environment"] = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "unknown";
_logger.LogInformation("Audit log diagnostics run by {User}: {@Results}", User.Identity?.Name, results);
return Json(results, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
}
}
@@ -0,0 +1,139 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin management of banned IP addresses.
/// Entries here block login before Identity even checks credentials.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class BannedIpsController : Controller
{
private readonly ApplicationDbContext _db;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<BannedIpsController> _logger;
public BannedIpsController(
ApplicationDbContext db,
UserManager<ApplicationUser> userManager,
ILogger<BannedIpsController> logger)
{
_db = db;
_userManager = userManager;
_logger = logger;
}
/// <summary>Lists all banned IPs, showing active and expired separately.</summary>
// GET: BannedIps
public async Task<IActionResult> Index()
{
var bans = await _db.BannedIps
.OrderByDescending(b => b.BannedAt)
.ToListAsync();
return View(bans);
}
/// <summary>
/// Adds a new IP ban. Rejects obviously invalid formats but doesn't require
/// a perfect regex — admins are trusted to enter valid IPs.
/// </summary>
// POST: BannedIps/Add
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Add(string ipAddress, string? reason, DateTime? expiresAt)
{
if (string.IsNullOrWhiteSpace(ipAddress))
{
TempData["Error"] = "IP address is required.";
return RedirectToAction(nameof(Index));
}
ipAddress = ipAddress.Trim();
// Basic sanity check — must look like an IPv4 or IPv6 address
if (!System.Net.IPAddress.TryParse(ipAddress, out _))
{
TempData["Error"] = $"'{ipAddress}' is not a valid IP address.";
return RedirectToAction(nameof(Index));
}
// Don't duplicate an active ban for the same IP
var existing = await _db.BannedIps.FirstOrDefaultAsync(b => b.IpAddress == ipAddress && b.IsActive);
if (existing != null)
{
TempData["Error"] = $"{ipAddress} already has an active ban (added {existing.BannedAt:MMM dd, yyyy}).";
return RedirectToAction(nameof(Index));
}
var currentUser = await _userManager.GetUserAsync(User);
_db.BannedIps.Add(new BannedIp
{
IpAddress = ipAddress,
Reason = reason?.Trim(),
BannedByUserId = currentUser?.Id,
BannedAt = DateTime.UtcNow,
ExpiresAt = expiresAt,
IsActive = true
});
await _db.SaveChangesAsync();
_logger.LogWarning("IP {IP} banned by {Admin}. Reason: {Reason}", ipAddress, User.Identity?.Name, reason);
TempData["Success"] = $"{ipAddress} has been banned.";
return RedirectToAction(nameof(Index));
}
/// <summary>Lifts a ban immediately by marking IsActive = false.</summary>
// POST: BannedIps/Lift/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Lift(int id)
{
var ban = await _db.BannedIps.FindAsync(id);
if (ban == null)
{
TempData["Error"] = "Ban not found.";
return RedirectToAction(nameof(Index));
}
ban.IsActive = false;
await _db.SaveChangesAsync();
_logger.LogInformation("IP ban lifted for {IP} by {Admin}", ban.IpAddress, User.Identity?.Name);
TempData["Success"] = $"Ban on {ban.IpAddress} has been lifted.";
return RedirectToAction(nameof(Index));
}
/// <summary>Permanently deletes a ban record.</summary>
// POST: BannedIps/Delete/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var ban = await _db.BannedIps.FindAsync(id);
if (ban != null)
{
_db.BannedIps.Remove(ban);
await _db.SaveChangesAsync();
_logger.LogInformation("IP ban record deleted for {IP} by {Admin}", ban.IpAddress, User.Identity?.Name);
TempData["Success"] = $"Ban record for {ban.IpAddress} deleted.";
}
return RedirectToAction(nameof(Index));
}
/// <summary>Returns the requesting client's IP so the admin can pre-fill it quickly.</summary>
// GET: BannedIps/MyIp
public IActionResult MyIp()
{
return Json(new { ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown" });
}
}
@@ -0,0 +1,278 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Subscription;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class BillingController : Controller
{
private readonly ISubscriptionService _subscriptionService;
private readonly IStripeService _stripeService;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<BillingController> _logger;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public BillingController(
ISubscriptionService subscriptionService,
IStripeService stripeService,
IUnitOfWork unitOfWork,
ILogger<BillingController> logger,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
{
_subscriptionService = subscriptionService;
_stripeService = stripeService;
_unitOfWork = unitOfWork;
_logger = logger;
_userManager = userManager;
_signInManager = signInManager;
}
/// <summary>
/// Displays the billing dashboard with current subscription status, per-resource usage vs limits, and available upgrade plans. Deactivated plans are omitted from the upgrade list except for the company's own active plan so existing subscribers always see their plan details.
/// </summary>
[HttpGet]
public async Task<IActionResult> Index()
{
var companyId = GetCompanyId();
if (companyId == 0) return RedirectToAction("Index", "Home");
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null) return NotFound();
var (userUsed, userMax) = await _subscriptionService.GetUserCountAsync(companyId);
var (jobUsed, jobMax) = await _subscriptionService.GetJobCountAsync(companyId);
var (customerUsed, customerMax) = await _subscriptionService.GetCustomerCountAsync(companyId);
var (quoteUsed, quoteMax) = await _subscriptionService.GetQuoteCountAsync(companyId);
var (catalogUsed, catalogMax) = await _subscriptionService.GetCatalogItemCountAsync(companyId);
var status = await _subscriptionService.GetStatusAsync(companyId);
var daysUntil = _subscriptionService.DaysUntilExpiry(company);
// Load active plans for upgrade options, but always include the company's current plan
// so existing subscribers can see their plan details even if it has been deactivated.
var currentPlan = company.SubscriptionPlan;
var planConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive || c.Plan == currentPlan, ignoreQueryFilters: true))
.OrderBy(c => c.SortOrder)
.ToList();
var statusDto = new SubscriptionStatusDto(
Status: status,
Plan: company.SubscriptionPlan,
EndDate: company.SubscriptionEndDate,
DaysRemaining: daysUntil,
IsGracePeriod: status == SubscriptionStatus.GracePeriod,
IsExpired: status == SubscriptionStatus.Expired
);
var limitsDto = new PlanLimitsDto(
MaxUsers: userMax,
MaxActiveJobs: jobMax,
MaxCustomers: customerMax,
MaxQuotes: quoteMax,
MaxCatalogItems: catalogMax,
CurrentUsers: userUsed,
CurrentJobs: jobUsed,
CurrentCustomers: customerUsed,
CurrentQuotes: quoteUsed,
CurrentCatalogItems: catalogUsed,
Plan: company.SubscriptionPlan
);
ViewBag.StatusDto = statusDto;
ViewBag.LimitsDto = limitsDto;
ViewBag.PlanConfigs = planConfigs;
ViewBag.Company = company;
return View();
}
/// <summary>
/// Creates a Stripe Checkout session and redirects the company admin to the Stripe-hosted payment page. Guards against upgrading to a deactivated plan via a crafted POST; Stripe configuration errors are surfaced verbatim to the admin (not the public) so they know exactly what to fix.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Checkout(int plan, bool isAnnual = false)
{
var companyId = GetCompanyId();
if (companyId == 0) return RedirectToAction("Index", "Home");
// Prevent upgrading TO a deactivated plan (e.g. via a crafted POST)
var targetConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(
c => c.Plan == plan, ignoreQueryFilters: true);
if (targetConfig == null || !targetConfig.IsActive)
{
TempData["Error"] = "That subscription plan is no longer available. Please choose a different plan.";
return RedirectToAction(nameof(Index));
}
var successUrl = Url.Action("Success", "Billing", null, Request.Scheme)!;
var cancelUrl = Url.Action("Index", "Billing", null, Request.Scheme)!;
try
{
var checkoutUrl = await _stripeService.CreateCheckoutSessionAsync(
companyId, plan, isAnnual, successUrl, cancelUrl);
return Redirect(checkoutUrl);
}
catch (InvalidOperationException ex)
{
// Configuration problems — give the admin the exact message so they know what to fix
_logger.LogError(ex, "Stripe configuration error for company {CompanyId}", companyId);
TempData["Error"] = ex.Message;
return RedirectToAction(nameof(Index));
}
catch (Stripe.StripeException ex)
{
_logger.LogError(ex, "Stripe API error for company {CompanyId}: {StripeError}", companyId, ex.Message);
TempData["Error"] = "A payment processor error occurred. Please try again or contact support.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Stripe checkout session for company {CompanyId}", companyId);
TempData["Error"] = "Unable to start checkout. Please try again or contact support.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Landing page after a successful Stripe Checkout. Fulfills the checkout server-side (updates subscription in DB) and refreshes the auth cookie so the new subscription plan claim is active immediately. Fulfillment errors are logged but do not block the success page since the Stripe webhook will retry.
/// </summary>
[HttpGet]
public async Task<IActionResult> Success(string? session_id)
{
if (!string.IsNullOrEmpty(session_id))
{
try
{
await _stripeService.FulfillCheckoutAsync(session_id);
await RefreshClaimsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fulfill checkout for session {SessionId}", session_id);
}
}
TempData["Success"] = "Your subscription has been updated successfully!";
return View();
}
/// <summary>
/// Return URL when the user cancels out of Stripe Checkout. Simply redirects back to the billing dashboard without modifying any subscription data.
/// </summary>
[HttpGet]
public IActionResult Cancel()
{
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Full-page interstitial shown when a company's subscription has expired. AllowAnonymous because the middleware redirects here before the user can authenticate past the subscription guard.
/// </summary>
[HttpGet]
[AllowAnonymous]
public IActionResult Expired()
{
return View();
}
/// <summary>
/// Full-page interstitial shown when a company account has been deactivated by a SuperAdmin. AllowAnonymous for the same reason as Expired — the auth pipeline redirects here before normal login completes.
/// </summary>
[HttpGet]
[AllowAnonymous]
public IActionResult Inactive()
{
return View();
}
/// <summary>
/// Manually pulls the current subscription state from Stripe and updates the database. Useful when a webhook was missed or delayed; also refreshes the auth cookie so the corrected plan claim takes effect immediately.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SyncWithStripe()
{
var companyId = GetCompanyId();
if (companyId == 0) return RedirectToAction("Index", "Home");
try
{
await _stripeService.SyncSubscriptionAsync(companyId);
await RefreshClaimsAsync();
TempData["Success"] = "Subscription synced successfully from Stripe.";
}
catch (InvalidOperationException ex)
{
TempData["Error"] = ex.Message;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to sync Stripe subscription for company {CompanyId}", companyId);
TempData["Error"] = "Unable to sync with Stripe. Please try again.";
}
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Creates a Stripe Customer Portal session and redirects the admin to the Stripe-hosted portal for self-service invoice history, payment method updates, and cancellation. Requires that a StripeCustomerId exists on the company record (set when the first subscription is created).
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ManageBilling()
{
var companyId = GetCompanyId();
if (companyId == 0) return RedirectToAction("Index", "Home");
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null || string.IsNullOrEmpty(company.StripeCustomerId))
{
TempData["Error"] = "No billing account found. Please set up a subscription first.";
return RedirectToAction(nameof(Index));
}
var returnUrl = Url.Action("Index", "Billing", null, Request.Scheme)!;
try
{
var portalUrl = await _stripeService.CreateCustomerPortalSessionAsync(
company.StripeCustomerId, returnUrl);
return Redirect(portalUrl);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Stripe portal session for company {CompanyId}", companyId);
TempData["Error"] = "Unable to open billing portal. Please try again.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Reads the CompanyId claim from the current user's auth cookie. Returns 0 when absent (e.g., SuperAdmin has no company) so callers can redirect away gracefully.
/// </summary>
private int GetCompanyId()
{
var claim = User.FindFirst("CompanyId")?.Value;
return int.TryParse(claim, out var id) ? id : 0;
}
/// <summary>
/// Re-issues the auth cookie so claims such as SubscriptionPlan reflect the latest DB values. Called after any action that changes the subscription so the updated limits are enforced immediately without requiring a logout/login.
/// </summary>
private async Task RefreshClaimsAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user != null)
await _signInManager.RefreshSignInAsync(user);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,449 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.DTOs.BugReport;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize]
public class BugReportController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _context;
private readonly IEmailService _emailService;
private readonly IAdminNotificationService _adminNotification;
private readonly IAzureBlobStorageService _blobService;
private readonly StorageSettings _storageSettings;
private readonly ILogger<BugReportController> _logger;
private static readonly string[] AllowedMediaTypes = [
".jpg", ".jpeg", ".png", ".gif", ".webp",
".mp4", ".mov", ".avi", ".mkv", ".webm"
];
private const long MaxMediaSizeBytes = 100 * 1024 * 1024; // 100 MB
public BugReportController(
IUnitOfWork unitOfWork,
IMapper mapper,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
ApplicationDbContext context,
IEmailService emailService,
IAdminNotificationService adminNotification,
IAzureBlobStorageService blobService,
IOptions<StorageSettings> storageSettings,
ILogger<BugReportController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_tenantContext = tenantContext;
_userManager = userManager;
_context = context;
_emailService = emailService;
_adminNotification = adminNotification;
_blobService = blobService;
_storageSettings = storageSettings.Value;
_logger = logger;
}
/// <summary>
/// Renders the bug-report submission form for the currently authenticated user.
/// Returns an empty <see cref="CreateBugReportDto"/> so the view has a bound model
/// with sensible defaults (e.g. Priority pre-set to Normal).
/// </summary>
// GET: /BugReport/Submit
[HttpGet]
public IActionResult Submit()
{
return View(new CreateBugReportDto());
}
/// <summary>
/// Accepts a bug report form submission, persists the report, uploads any media
/// attachments to Azure Blob Storage, and notifies platform admins.
/// <para>
/// Design decisions:
/// <list type="bullet">
/// <item>The request-size limit is set to 110 MB (slightly above the 100 MB per-file
/// cap) to allow multi-file payloads while still enforcing a hard ceiling.</item>
/// <item>The bug report record is saved to the database <em>before</em> attachment
/// upload so the auto-generated <c>BugReport.Id</c> is available as the blob
/// folder prefix: <c>{companyId}/{bugReportId}/{guid}{ext}</c>.</item>
/// <item>Individual upload failures are logged as warnings but do not abort the
/// overall submission — a partial upload is still a valid report.</item>
/// <item>Attachments are tracked in <c>BugReportAttachment</c> rows via the raw
/// <c>ApplicationDbContext</c> (not IUnitOfWork) because they need a
/// separate <c>SaveChangesAsync()</c> call after all blobs are uploaded.</item>
/// </list>
/// </para>
/// </summary>
// POST: /BugReport/Submit
[HttpPost]
[ValidateAntiForgeryToken]
[RequestSizeLimit(110 * 1024 * 1024)]
public async Task<IActionResult> Submit(CreateBugReportDto dto, List<IFormFile>? attachments)
{
if (!ModelState.IsValid)
return View(dto);
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company. Please contact support.";
return RedirectToAction("Index", "Home");
}
var user = await _userManager.GetUserAsync(User);
var userName = user != null ? user.FullName : User.Identity?.Name ?? "Unknown";
// Resolve company name
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
var companyName = company?.CompanyName ?? $"Company #{companyId}";
var bugReport = _mapper.Map<BugReport>(dto);
bugReport.CompanyId = companyId.Value;
bugReport.CompanyName = companyName;
bugReport.SubmittedByUserId = user?.Id ?? string.Empty;
bugReport.SubmittedByUserName = userName;
bugReport.Status = BugReportStatus.New;
await _unitOfWork.BugReports.AddAsync(bugReport);
await _unitOfWork.CompleteAsync();
// Upload attachments
var uploadedCount = 0;
if (attachments != null && attachments.Count > 0)
{
foreach (var file in attachments)
{
if (file == null || file.Length == 0) continue;
if (file.Length > MaxMediaSizeBytes) continue;
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedMediaTypes.Contains(ext)) continue;
var blobPath = $"{companyId}/{bugReport.Id}/{Guid.NewGuid():N}{ext}";
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(
_storageSettings.Containers.BugReportMedia, blobPath, stream, file.ContentType);
if (result.Success)
{
var attachment = new BugReportAttachment
{
BugReportId = bugReport.Id,
CompanyId = companyId.Value,
BlobPath = blobPath,
FileName = file.FileName,
ContentType = file.ContentType,
FileSizeBytes = file.Length
};
_context.BugReportAttachments.Add(attachment);
uploadedCount++;
}
else
{
_logger.LogWarning("Failed to upload bug report attachment {FileName}: {Error}",
file.FileName, result.ErrorMessage);
}
}
if (uploadedCount > 0)
await _context.SaveChangesAsync();
}
_logger.LogInformation("Bug report #{Id} submitted by {UserName} ({Company}): {Title} with {AttachmentCount} attachment(s)",
bugReport.Id, userName, companyName, dto.Title, uploadedCount);
await _adminNotification.NotifyBugReportSubmittedAsync(
bugReport.Id, dto.Title, dto.Description,
dto.Priority.ToString(), userName, companyName);
TempData["SuccessMessage"] = "Your bug report has been submitted. Thank you for helping us improve!";
return RedirectToAction(nameof(Submit));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error submitting bug report");
TempData["ErrorMessage"] = "An error occurred while submitting your report. Please try again.";
return View(dto);
}
}
/// <summary>
/// Displays a paginated, filterable list of all bug reports across every tenant
/// (SuperAdmin only).
/// <para>
/// Uses <c>IgnoreQueryFilters()</c> to bypass the global soft-delete and
/// multi-tenancy filters so SuperAdmins see reports from all companies.
/// The manual <c>!r.IsDeleted</c> predicate is intentionally re-added so that
/// physically deleted records (which should be rare) are still excluded.
/// Sorting is resolved with a switch expression covering the five UI columns;
/// any unrecognised column falls back to descending <c>CreatedAt</c>.
/// </para>
/// </summary>
// GET: /BugReport/Index — SuperAdmin only
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> Index(
string? searchTerm,
string? statusFilter,
string? priorityFilter,
string sortColumn = "CreatedAt",
string sortDirection = "desc",
int pageNumber = 1,
int pageSize = 25)
{
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
var query = _context.BugReports
.AsNoTracking()
.IgnoreQueryFilters()
.Where(r => !r.IsDeleted)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
query = query.Where(r =>
r.Title.ToLower().Contains(search) ||
r.Description.ToLower().Contains(search) ||
r.SubmittedByUserName.ToLower().Contains(search));
}
if (!string.IsNullOrWhiteSpace(statusFilter) &&
Enum.TryParse<BugReportStatus>(statusFilter, out var status))
query = query.Where(r => r.Status == status);
if (!string.IsNullOrWhiteSpace(priorityFilter) &&
Enum.TryParse<BugReportPriority>(priorityFilter, out var priority))
query = query.Where(r => r.Priority == priority);
query = (sortColumn, sortDirection == "asc") switch
{
("Title", true) => query.OrderBy(r => r.Title),
("Title", false) => query.OrderByDescending(r => r.Title),
("Status", true) => query.OrderBy(r => r.Status),
("Status", false) => query.OrderByDescending(r => r.Status),
("Priority", true) => query.OrderBy(r => r.Priority),
("Priority", false) => query.OrderByDescending(r => r.Priority),
("Submitted", true) => query.OrderBy(r => r.SubmittedByUserName),
("Submitted", false) => query.OrderByDescending(r => r.SubmittedByUserName),
(_, true) => query.OrderBy(r => r.CreatedAt),
_ => query.OrderByDescending(r => r.CreatedAt)
};
var totalCount = await query.CountAsync();
var items = await query
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var dtos = _mapper.Map<List<BugReportDto>>(items);
ViewBag.SearchTerm = searchTerm;
ViewBag.StatusFilter = statusFilter;
ViewBag.PriorityFilter = priorityFilter;
ViewBag.SortColumn = sortColumn;
ViewBag.SortDirection = sortDirection;
ViewBag.TotalCount = totalCount;
ViewBag.PageNumber = pageNumber;
ViewBag.PageSize = pageSize;
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
return View(dtos);
}
/// <summary>
/// Loads a bug report and its attachments for editing by a SuperAdmin.
/// <para>
/// Both the report and its attachment rows are fetched with
/// <c>IgnoreQueryFilters()</c> because SuperAdmins need visibility into
/// cross-tenant records. The explicit <c>IsDeleted</c> guard prevents
/// editing a report that has been soft-deleted.
/// Attachments are loaded separately (not via EF navigation) so they can be
/// displayed in the Edit view without requiring a full Include chain.
/// </para>
/// </summary>
// GET: /BugReport/Edit/5 — SuperAdmin only
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
[HttpGet]
public async Task<IActionResult> Edit(int id)
{
var bugReport = await _unitOfWork.BugReports.GetByIdAsync(id, ignoreQueryFilters: true);
if (bugReport == null || bugReport.IsDeleted)
return NotFound();
var dto = _mapper.Map<EditBugReportDto>(bugReport);
var attachments = await _context.BugReportAttachments
.AsNoTracking()
.IgnoreQueryFilters()
.Where(a => a.BugReportId == id && !a.IsDeleted)
.OrderBy(a => a.CreatedAt)
.ToListAsync();
dto.Attachments = _mapper.Map<List<BugReportAttachmentDto>>(attachments);
return View(dto);
}
/// <summary>
/// Streams a bug-report attachment from Azure Blob Storage directly to the browser.
/// Returns a <c>FileResult</c> with the original filename and content-type so the
/// browser can render images inline or prompt a download for other media types.
/// <para>
/// Access is restricted to SuperAdmin because attachment blobs are stored outside
/// the tenant-scoped container and could contain sensitive reproduction screenshots.
/// The blob path is resolved from the <c>BugReportAttachment</c> record (not from a
/// user-supplied path) to prevent path-traversal attacks.
/// </para>
/// </summary>
// GET: /BugReport/Attachment/5 — SuperAdmin only — streams blob to browser
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
[HttpGet]
public async Task<IActionResult> Attachment(int id)
{
var attachment = await _context.BugReportAttachments
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.Id == id && !a.IsDeleted);
if (attachment == null)
return NotFound();
var download = await _blobService.DownloadAsync(
_storageSettings.Containers.BugReportMedia, attachment.BlobPath);
if (!download.Success)
return NotFound();
return File(download.Content, download.ContentType, attachment.FileName);
}
/// <summary>
/// Persists status/priority/resolution edits to a bug report and, when the report
/// transitions to <see cref="BugReportStatus.Completed"/> for the first time,
/// emails the original submitter with the resolution notes.
/// <para>
/// Key business rules:
/// <list type="bullet">
/// <item><c>ResolvedAt</c> / <c>ResolvedBy</c> are stamped only on the first
/// transition to <c>Completed</c> or <c>Cancelled</c> — subsequent saves
/// do not overwrite those audit fields.</item>
/// <item>The resolution email is gated on three conditions: the status just
/// changed to <c>Completed</c>, resolution notes are non-empty, and the
/// submitter's user ID is still resolvable in Identity. Email failures
/// are logged as warnings but do not roll back the save.</item>
/// <item>The route-id / dto.Id equality check prevents CSRF-style ID-swap
/// attacks where a forged form targets a different record.</item>
/// </list>
/// </para>
/// </summary>
// POST: /BugReport/Edit/5 — SuperAdmin only
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, EditBugReportDto dto)
{
if (id != dto.Id)
return BadRequest();
if (!ModelState.IsValid)
return View(dto);
try
{
var bugReport = await _unitOfWork.BugReports.GetByIdAsync(id, ignoreQueryFilters: true);
if (bugReport == null || bugReport.IsDeleted)
return NotFound();
var previousStatus = bugReport.Status;
bugReport.Title = dto.Title;
bugReport.Description = dto.Description;
bugReport.Priority = dto.Priority;
bugReport.Status = dto.Status;
bugReport.ResolutionNotes = dto.ResolutionNotes;
bugReport.UpdatedAt = DateTime.UtcNow;
bugReport.UpdatedBy = User.Identity?.Name;
var justCompleted = dto.Status == BugReportStatus.Completed
&& previousStatus != BugReportStatus.Completed;
if (justCompleted || (dto.Status == BugReportStatus.Cancelled
&& previousStatus is not BugReportStatus.Completed and not BugReportStatus.Cancelled))
{
bugReport.ResolvedAt = DateTime.UtcNow;
bugReport.ResolvedBy = User.Identity?.Name;
}
await _unitOfWork.BugReports.UpdateAsync(bugReport);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Bug report {Id} updated by {UserName}", id, User.Identity?.Name);
// Email submitter when their report is completed and resolution notes are provided
if (justCompleted && !string.IsNullOrWhiteSpace(dto.ResolutionNotes)
&& !string.IsNullOrWhiteSpace(bugReport.SubmittedByUserId))
{
var submitter = await _userManager.FindByIdAsync(bugReport.SubmittedByUserId);
if (submitter != null && !string.IsNullOrWhiteSpace(submitter.Email))
{
var resolvedBy = User.Identity?.Name ?? "Support";
var htmlBody = $"""
<h2>Your Bug Report Has Been Resolved</h2>
<p>Hi {System.Net.WebUtility.HtmlEncode(bugReport.SubmittedByUserName)},</p>
<p>We wanted to let you know that your bug report has been marked as completed.</p>
<table cellpadding="6" style="border-collapse:collapse;">
<tr><td><strong>Report:</strong></td><td>{System.Net.WebUtility.HtmlEncode(bugReport.Title)}</td></tr>
<tr><td><strong>Resolved By:</strong></td><td>{System.Net.WebUtility.HtmlEncode(resolvedBy)}</td></tr>
<tr><td><strong>Resolved At:</strong></td><td>{bugReport.ResolvedAt:MM/dd/yyyy h:mm tt} UTC</td></tr>
</table>
<h3>Resolution Notes</h3>
<p style="white-space:pre-wrap;">{System.Net.WebUtility.HtmlEncode(dto.ResolutionNotes)}</p>
<p>Thank you for helping us improve the platform!</p>
""";
var plainBody = $"Hi {bugReport.SubmittedByUserName},\n\nYour bug report \"{bugReport.Title}\" has been marked as completed.\n\nResolution Notes:\n{dto.ResolutionNotes}\n\nResolved by: {resolvedBy}\n\nThank you for helping us improve the platform!";
var emailResult = await _emailService.SendEmailAsync(
toEmail: submitter.Email,
toName: bugReport.SubmittedByUserName,
subject: $"[Resolved] {bugReport.Title}",
plainTextBody: plainBody,
htmlBody: htmlBody);
if (emailResult.Success)
_logger.LogInformation("Resolution email sent to {Email} for bug report {Id}", submitter.Email, id);
else
_logger.LogWarning("Failed to send resolution email for bug report {Id}: {Error}", id, emailResult.ErrorMessage);
}
}
TempData["SuccessMessage"] = "Bug report updated successfully.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating bug report {Id}", id);
TempData["ErrorMessage"] = "An error occurred while saving the changes.";
return View(dto);
}
}
}
@@ -0,0 +1,738 @@
using AutoMapper;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Application.DTOs.Catalog;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace PowderCoating.Web.Controllers
{
/// <summary>
/// Manages the hierarchical catalog category tree used to organise catalog items.
/// Categories form an unlimited-depth tree via the nullable <c>ParentCategoryId</c> FK;
/// root categories have <c>ParentCategoryId == null</c>.
/// The controller enforces:
/// - Case-insensitive uniqueness of names within the same parent scope.
/// - Circular-reference prevention when re-parenting a category.
/// - Non-empty precondition before soft-deleting (items and sub-categories must be cleared first).
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageProducts)]
public class CatalogCategoriesController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<CatalogCategoriesController> _logger;
public CatalogCategoriesController(
IUnitOfWork unitOfWork,
IMapper mapper,
ILogger<CatalogCategoriesController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
}
/// <summary>
/// Displays the full category list, structured as a tree for hierarchical presentation.
/// Eager-loads <c>ParentCategory</c>, <c>SubCategories</c>, and <c>Items</c> in a single
/// round-trip so the view can render parent breadcrumbs, child counts, and item counts
/// without issuing per-row N+1 queries.
/// Root categories (those with no parent) are passed separately via <c>ViewBag.RootCategories</c>
/// so the view can render them as tree roots without re-filtering in Razor.
/// </summary>
// GET: CatalogCategories
public async Task<IActionResult> Index()
{
try
{
var categories = await _unitOfWork.CatalogCategories
.GetAllAsync(false,
c => c.ParentCategory,
c => c.SubCategories,
c => c.Items);
var categoryDtos = _mapper.Map<List<CategoryDto>>(categories);
// Build tree structure for display
var rootCategories = categoryDtos
.Where(c => c.ParentCategoryId == null)
.OrderBy(c => c.DisplayOrder)
.ThenBy(c => c.Name)
.ToList();
ViewBag.RootCategories = rootCategories;
ViewBag.AllCategories = categoryDtos;
return View(categoryDtos);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading catalog categories");
TempData["Error"] = "An error occurred while loading catalog categories.";
return View(new List<CategoryDto>());
}
}
/// <summary>
/// Shows details for a single category, including its parent breadcrumb and the list of
/// non-deleted catalog items directly assigned to it.
/// Items are filtered and sorted in memory (after the EF load) because the Items collection
/// is already materialized via eager loading; a second DB query would be redundant.
/// </summary>
/// <param name="id">Primary key of the category to display.</param>
// GET: CatalogCategories/Details/5
public async Task<IActionResult> Details(int id)
{
try
{
var category = await _unitOfWork.CatalogCategories.GetByIdAsync(
id,
false,
c => c.ParentCategory,
c => c.SubCategories,
c => c.Items);
if (category == null)
{
TempData["Error"] = "Category not found.";
return RedirectToAction(nameof(Index));
}
var categoryDto = _mapper.Map<CategoryDto>(category);
// Get items in this category
var items = category.Items
.Where(i => !i.IsDeleted)
.OrderBy(i => i.DisplayOrder)
.ThenBy(i => i.Name)
.ToList();
ViewBag.Items = items;
return View(categoryDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading category {CategoryId}", id);
TempData["Error"] = "An error occurred while loading the category.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Renders the category creation form, pre-populating the parent category dropdown via
/// <see cref="PopulateParentCategoryDropdown"/> with no exclusions (no category to exclude yet).
/// </summary>
// GET: CatalogCategories/Create
public async Task<IActionResult> Create()
{
try
{
await PopulateParentCategoryDropdown();
return View(new CreateCategoryDto());
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading create category page");
TempData["Error"] = "An error occurred while loading the page.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Handles category creation. Before persisting, enforces a case-insensitive uniqueness
/// constraint on the category name within the same parent scope (root or a specific parent).
/// This check is done in application code rather than a DB unique index because the constraint
/// is scoped to <c>(Name, ParentCategoryId)</c> with nullable <c>ParentCategoryId</c>, which
/// SQL Server unique indexes do not enforce consistently for NULLs.
/// </summary>
/// <param name="dto">Form-bound DTO with the new category's name, description, parent, and display order.</param>
// POST: CatalogCategories/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateCategoryDto dto)
{
try
{
if (ModelState.IsValid)
{
// Check for duplicate category name under the same parent (case-insensitive)
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var existingCategory = allCategories.FirstOrDefault(c =>
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
c.ParentCategoryId == dto.ParentCategoryId);
if (existingCategory != null)
{
var parentInfo = dto.ParentCategoryId.HasValue
? $"under the same parent category"
: "at the root level";
ModelState.AddModelError("Name", $"A category named '{existingCategory.Name}' already exists {parentInfo}.");
await PopulateParentCategoryDropdown();
return View(dto);
}
var category = _mapper.Map<CatalogCategory>(dto);
await _unitOfWork.CatalogCategories.AddAsync(category);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Category '{category.Name}' created successfully.";
return RedirectToAction(nameof(Index));
}
await PopulateParentCategoryDropdown();
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating category");
ModelState.AddModelError("", "An error occurred while creating the category.");
await PopulateParentCategoryDropdown();
return View(dto);
}
}
/// <summary>
/// Renders the edit form for an existing category.
/// Passes the category's own ID to <see cref="PopulateParentCategoryDropdown"/> so that
/// the category itself and all its descendants are excluded from the parent dropdown,
/// preventing the user from accidentally selecting a circular parent relationship.
/// </summary>
/// <param name="id">Primary key of the category to edit.</param>
// GET: CatalogCategories/Edit/5
public async Task<IActionResult> Edit(int id)
{
try
{
var category = await _unitOfWork.CatalogCategories.GetByIdAsync(id);
if (category == null)
{
TempData["Error"] = "Category not found.";
return RedirectToAction(nameof(Index));
}
var dto = _mapper.Map<UpdateCategoryDto>(category);
await PopulateParentCategoryDropdown(id);
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading category {CategoryId} for editing", id);
TempData["Error"] = "An error occurred while loading the category.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Handles category update. Two safety checks are performed before saving:
/// <list type="number">
/// <item>Duplicate name check — only re-evaluated when the name or parent actually changed,
/// to avoid unnecessary DB round-trips on cosmetic edits.</item>
/// <item>Circular reference check — verifies that the proposed new parent is not the category
/// itself or any of its descendants, which would create an infinite loop in tree traversal.</item>
/// </list>
/// The route ID and DTO ID are cross-validated at entry to prevent tampered requests from
/// silently editing the wrong record.
/// </summary>
/// <param name="id">Route ID of the category being edited.</param>
/// <param name="dto">Form-bound DTO with updated fields.</param>
// POST: CatalogCategories/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateCategoryDto dto)
{
if (id != dto.Id)
{
TempData["Error"] = "Invalid category ID.";
return RedirectToAction(nameof(Index));
}
try
{
if (ModelState.IsValid)
{
var category = await _unitOfWork.CatalogCategories.GetByIdAsync(id);
if (category == null)
{
TempData["Error"] = "Category not found.";
return RedirectToAction(nameof(Index));
}
// Check for duplicate category name under the same parent (only if name or parent changed)
var nameChanged = !category.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase);
var parentChanged = category.ParentCategoryId != dto.ParentCategoryId;
if (nameChanged || parentChanged)
{
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var existingCategory = allCategories.FirstOrDefault(c =>
c.Id != id &&
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
c.ParentCategoryId == dto.ParentCategoryId);
if (existingCategory != null)
{
var parentInfo = dto.ParentCategoryId.HasValue
? $"under the same parent category"
: "at the root level";
ModelState.AddModelError("Name", $"A category named '{existingCategory.Name}' already exists {parentInfo}.");
await PopulateParentCategoryDropdown(id);
return View(dto);
}
}
// Check for circular reference
if (dto.ParentCategoryId.HasValue)
{
if (await WouldCreateCircularReference(id, dto.ParentCategoryId.Value))
{
ModelState.AddModelError("ParentCategoryId",
"Cannot set this category as parent - it would create a circular reference.");
await PopulateParentCategoryDropdown(id);
return View(dto);
}
}
_mapper.Map(dto, category);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Category '{category.Name}' updated successfully.";
return RedirectToAction(nameof(Details), new { id = category.Id });
}
await PopulateParentCategoryDropdown(id);
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating category {CategoryId}", id);
ModelState.AddModelError("", "An error occurred while updating the category.");
await PopulateParentCategoryDropdown(id);
return View(dto);
}
}
/// <summary>
/// Renders the delete confirmation page for a category.
/// Eagerly loads sub-categories and items to determine whether the category is safe to
/// delete. The <c>ViewBag.CanDelete</c> flag is passed to the view to conditionally
/// render a disabled "Delete" button when the category is not empty, providing a clear
/// UI hint rather than a post-submission error.
/// </summary>
/// <param name="id">Primary key of the category to delete.</param>
// GET: CatalogCategories/Delete/5
public async Task<IActionResult> Delete(int id)
{
try
{
var category = await _unitOfWork.CatalogCategories.GetByIdAsync(
id,
false,
c => c.ParentCategory,
c => c.SubCategories,
c => c.Items);
if (category == null)
{
TempData["Error"] = "Category not found.";
return RedirectToAction(nameof(Index));
}
var dto = _mapper.Map<CategoryDto>(category);
// Check if category has items or subcategories
var hasItems = category.Items.Any(i => !i.IsDeleted);
var hasSubCategories = category.SubCategories.Any(c => !c.IsDeleted);
ViewBag.HasItems = hasItems;
ViewBag.HasSubCategories = hasSubCategories;
ViewBag.CanDelete = !hasItems && !hasSubCategories;
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading category {CategoryId} for deletion", id);
TempData["Error"] = "An error occurred while loading the category.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Confirms and executes the soft-delete of a category.
/// Re-checks emptiness on the POST to defend against TOCTOU (time-of-check/time-of-use)
/// race conditions: an item or sub-category could have been added between the GET and POST.
/// A soft-delete (<see cref="IRepository{T}.SoftDeleteAsync"/>) is used rather than a physical
/// delete so the record can be recovered if needed and audit history is preserved.
/// </summary>
/// <param name="id">Primary key of the category to soft-delete.</param>
// POST: CatalogCategories/Delete/5
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
try
{
var category = await _unitOfWork.CatalogCategories.GetByIdAsync(
id,
false,
c => c.SubCategories,
c => c.Items);
if (category == null)
{
TempData["Error"] = "Category not found.";
return RedirectToAction(nameof(Index));
}
// Verify category is empty
var hasItems = category.Items.Any(i => !i.IsDeleted);
var hasSubCategories = category.SubCategories.Any(c => !c.IsDeleted);
if (hasItems || hasSubCategories)
{
TempData["Error"] = "Cannot delete category that contains items or subcategories. " +
"Please move or delete them first.";
return RedirectToAction(nameof(Delete), new { id });
}
var categoryName = category.Name;
await _unitOfWork.CatalogCategories.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Category '{categoryName}' deleted successfully.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting category {CategoryId}", id);
TempData["Error"] = "An error occurred while deleting the category.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// AJAX endpoint that creates a category inline from the catalog item create/edit form,
/// allowing users to add a new category without navigating away.
/// Returns a JSON result with the new category's ID and name so the caller can append it
/// to the category dropdown and select it immediately.
/// Applies the same case-insensitive uniqueness guard as the full Create action.
/// Does not validate CSRF via <c>[ValidateAntiForgeryToken]</c> because the request is
/// a JSON POST from JavaScript (a standard AJAX anti-CSRF token approach is expected in the
/// JS client via the <c>RequestVerificationToken</c> header).
/// </summary>
/// <param name="request">JSON body with name, optional description, and optional parent ID.</param>
// AJAX: Quick create category (for use in catalog item forms)
[HttpPost]
public async Task<IActionResult> QuickCreate([FromBody] QuickCreateCategoryRequest request)
{
try
{
if (string.IsNullOrWhiteSpace(request.Name))
{
return Json(new { success = false, message = "Category name is required." });
}
var trimmedName = request.Name.Trim();
// Check for duplicate category name under the same parent (case-insensitive)
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var existingCategory = allCategories.FirstOrDefault(c =>
c.Name.Equals(trimmedName, StringComparison.OrdinalIgnoreCase) &&
c.ParentCategoryId == request.ParentCategoryId);
if (existingCategory != null)
{
var parentInfo = request.ParentCategoryId.HasValue
? "under the same parent category"
: "at the root level";
return Json(new
{
success = false,
message = $"A category named '{existingCategory.Name}' already exists {parentInfo}."
});
}
var category = new CatalogCategory
{
Name = trimmedName,
Description = request.Description?.Trim(),
ParentCategoryId = request.ParentCategoryId,
DisplayOrder = 0,
IsActive = true
};
await _unitOfWork.CatalogCategories.AddAsync(category);
await _unitOfWork.CompleteAsync();
return Json(new
{
success = true,
id = category.Id,
name = category.Name,
message = $"Category '{category.Name}' created successfully."
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating category via quick-create");
return Json(new { success = false, message = "An error occurred while creating the category." });
}
}
/// <summary>
/// Returns the full category tree as a JSON array of <c>CategoryTreeDto</c> objects,
/// rooted at the top-level (parentless) categories.
/// Used by JavaScript widgets that need to render an interactive tree without a full page load.
/// Eagerly loads <c>SubCategories</c> and <c>Items</c> so the mapper can populate child counts.
/// </summary>
// AJAX: Get category tree
[HttpGet]
public async Task<IActionResult> GetCategoryTree()
{
try
{
var categories = await _unitOfWork.CatalogCategories
.GetAllAsync(false, c => c.SubCategories, c => c.Items);
// Build tree from root categories
var rootCategories = categories
.Where(c => c.ParentCategoryId == null)
.OrderBy(c => c.DisplayOrder)
.ThenBy(c => c.Name)
.ToList();
var tree = _mapper.Map<List<CategoryTreeDto>>(rootCategories);
return Json(tree);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting category tree");
return Json(new { error = "An error occurred while loading the category tree." });
}
}
/// <summary>
/// Returns all active categories as a flat JSON array in parent-before-child (depth-first)
/// order, with each entry including an indented display name for use in a &lt;select&gt; dropdown.
/// This endpoint is consumed by JavaScript dropdowns on catalog item create/edit pages,
/// allowing dynamic refresh without a full page reload.
/// The visual indentation uses non-breaking spaces and em dashes (built by
/// <see cref="GetCategoryDisplayName"/>) so the hierarchy is visible even in a flat dropdown.
/// </summary>
// AJAX: Get categories for dropdown (hierarchical list with formatted names)
[HttpGet]
public async Task<IActionResult> GetCategoriesForDropdown()
{
try
{
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
// Build hierarchical list (parents before children)
var hierarchicalList = new List<CatalogCategory>();
BuildHierarchicalCategoryList(categories, null, hierarchicalList, new HashSet<int>());
var categoryOptions = hierarchicalList
.Select(c => new
{
id = c.Id,
name = c.Name,
displayText = GetCategoryDisplayName(c, categories)
})
.ToList();
return Json(new { success = true, categories = categoryOptions });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting categories for dropdown");
return Json(new { success = false, error = "An error occurred while loading categories." });
}
}
/// <summary>
/// Populates <c>ViewBag.ParentCategories</c> with a hierarchically ordered
/// <see cref="SelectListItem"/> list suitable for a Razor dropdown.
/// When <paramref name="excludeCategoryId"/> is provided, that category and all of its
/// descendants are excluded from the list. This prevents the user from accidentally
/// choosing a descendant as the new parent, which would create a circular reference
/// and break tree traversal logic throughout the application.
/// A "(None - Root Category)" option is prepended so users can promote a category to root level.
/// </summary>
/// <param name="excludeCategoryId">
/// Optional ID of the category being edited; its full subtree is excluded from the dropdown.
/// </param>
private async Task PopulateParentCategoryDropdown(int? excludeCategoryId = null)
{
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
// Exclude the current category and its descendants to prevent circular references
var excludedIds = new HashSet<int>();
if (excludeCategoryId.HasValue)
{
excludedIds.Add(excludeCategoryId.Value);
AddDescendantIds(excludeCategoryId.Value, excludedIds, categories);
}
// Build hierarchical list (parents before children)
var hierarchicalList = new List<CatalogCategory>();
BuildHierarchicalCategoryList(categories, null, hierarchicalList, excludedIds);
var categoryList = hierarchicalList
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = GetCategoryDisplayName(c, categories)
})
.ToList();
// Add "(None)" option for root categories
categoryList.Insert(0, new SelectListItem { Value = "", Text = "(None - Root Category)" });
ViewBag.ParentCategories = categoryList;
}
/// <summary>
/// Recursively builds a flat list of categories in depth-first, parent-before-child order,
/// skipping any category whose ID is in <paramref name="excludedIds"/>.
/// The resulting list preserves the visual hierarchy when rendered as a flat dropdown
/// (combined with indentation from <see cref="GetCategoryDisplayName"/>).
/// </summary>
/// <param name="allCategories">Complete set of all non-deleted categories for the tenant.</param>
/// <param name="parentId">The parent ID whose direct children to enumerate at this recursion level; null for root.</param>
/// <param name="result">Accumulator list; appended to in-place.</param>
/// <param name="excludedIds">Set of category IDs to skip (the edited category and its subtree).</param>
private void BuildHierarchicalCategoryList(List<CatalogCategory> allCategories, int? parentId, List<CatalogCategory> result, HashSet<int> excludedIds)
{
var children = allCategories
.Where(c => c.ParentCategoryId == parentId && !excludedIds.Contains(c.Id))
.OrderBy(c => c.Name)
.ToList();
foreach (var category in children)
{
result.Add(category);
// Recursively add children
BuildHierarchicalCategoryList(allCategories, category.Id, result, excludedIds);
}
}
/// <summary>
/// Returns a visually indented display name for a category based on its depth in the hierarchy.
/// Root categories get no prefix; each level of nesting prepends two non-breaking spaces
/// plus an em dash ("&amp;nbsp;&amp;nbsp;— ") so the hierarchy is visible in a flat HTML dropdown.
/// Non-breaking spaces are used instead of regular spaces because HTML collapses regular spaces.
/// </summary>
/// <param name="category">The category whose display name to build.</param>
/// <param name="allCategories">All categories, used to walk up the ancestor chain.</param>
private string GetCategoryDisplayName(CatalogCategory category, List<CatalogCategory> allCategories)
{
var depth = GetCategoryDepth(category, allCategories);
// Use em dash and non-breaking spaces for better visibility
var prefix = depth > 0 ? new string('\u00A0', depth * 2) + "\u2014 " : "";
return $"{prefix}{category.Name}";
}
/// <summary>
/// Computes how many levels deep a category is in the hierarchy by walking up through
/// its ancestors until a root (no parent) is reached.
/// Returns 0 for root categories, 1 for direct children of root, etc.
/// The loop is guarded against orphaned records (missing parent entity) by breaking on null.
/// </summary>
/// <param name="category">The category whose depth to compute.</param>
/// <param name="allCategories">All categories, used to look up parent entities by ID.</param>
private int GetCategoryDepth(CatalogCategory category, List<CatalogCategory> allCategories)
{
int depth = 0;
var current = category;
while (current.ParentCategoryId.HasValue)
{
depth++;
current = allCategories.FirstOrDefault(c => c.Id == current.ParentCategoryId.Value);
if (current == null) break;
}
return depth;
}
/// <summary>
/// Recursively collects the IDs of all descendants of <paramref name="categoryId"/>
/// into <paramref name="excludedIds"/>.
/// Used by <see cref="PopulateParentCategoryDropdown"/> to build the exclusion set that
/// prevents circular-reference selections in the parent category dropdown.
/// </summary>
/// <param name="categoryId">Root of the subtree to collect.</param>
/// <param name="excludedIds">Set to add descendant IDs into; modified in-place.</param>
/// <param name="allCategories">All categories, used to find direct children at each level.</param>
private void AddDescendantIds(int categoryId, HashSet<int> excludedIds, List<CatalogCategory> allCategories)
{
var children = allCategories.Where(c => c.ParentCategoryId == categoryId).ToList();
foreach (var child in children)
{
excludedIds.Add(child.Id);
AddDescendantIds(child.Id, excludedIds, allCategories);
}
}
/// <summary>
/// Determines whether assigning <paramref name="newParentId"/> as the parent of
/// <paramref name="categoryId"/> would create a circular reference in the category tree.
/// A circular reference occurs when the proposed parent is the category itself, or when
/// walking up the proposed parent's ancestor chain eventually reaches the category being moved.
/// This check is necessary because the tree is stored as a flat table with a self-referential FK,
/// and the database does not enforce acyclicity at the schema level.
/// </summary>
/// <param name="categoryId">ID of the category being re-parented.</param>
/// <param name="newParentId">Proposed new parent ID.</param>
/// <returns><c>true</c> if the assignment would create a cycle; <c>false</c> if it is safe.</returns>
private async Task<bool> WouldCreateCircularReference(int categoryId, int newParentId)
{
if (categoryId == newParentId)
return true;
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
var current = categories.FirstOrDefault(c => c.Id == newParentId);
while (current != null)
{
if (current.Id == categoryId)
return true;
if (!current.ParentCategoryId.HasValue)
break;
current = categories.FirstOrDefault(c => c.Id == current.ParentCategoryId.Value);
}
return false;
}
}
/// <summary>
/// Request body model for the <see cref="CatalogCategoriesController.QuickCreate"/> AJAX endpoint.
/// Bound from the JSON body of the POST request.
/// </summary>
public class QuickCreateCategoryRequest
{
/// <summary>The display name for the new category. Must not be null or whitespace.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Optional description for the new category.</summary>
public string? Description { get; set; }
/// <summary>
/// Optional parent category ID. When null the new category is created at the root level.
/// </summary>
public int? ParentCategoryId { get; set; }
}
}
@@ -0,0 +1,849 @@
using AutoMapper;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Identity;
using PowderCoating.Application.DTOs.Catalog;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace PowderCoating.Web.Controllers
{
/// <summary>
/// Manages the pre-priced service catalog: categories, items, revenue/COGS account mapping, and PDF export.
/// Items in the catalog serve as reusable line items on quotes and jobs; the <c>IsMerchandise</c> flag
/// additionally makes them available as retail goods on invoices. Revenue and COGS accounts are only
/// populated when the company's subscription includes accounting features
/// (<c>AllowAccounting</c> middleware flag), so the dropdowns degrade gracefully to empty lists.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageProducts)]
public class CatalogItemsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<CatalogItemsController> _logger;
private readonly IPdfService _pdfService;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ITenantContext _tenantContext;
private readonly IMeasurementConversionService _measurementService;
private readonly ISubscriptionService _subscriptionService;
public CatalogItemsController(
IUnitOfWork unitOfWork,
IMapper mapper,
ILogger<CatalogItemsController> logger,
IPdfService pdfService,
UserManager<ApplicationUser> userManager,
ITenantContext tenantContext,
IMeasurementConversionService measurementService,
ISubscriptionService subscriptionService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
_pdfService = pdfService;
_userManager = userManager;
_tenantContext = tenantContext;
_measurementService = measurementService;
_subscriptionService = subscriptionService;
}
/// <summary>
/// Displays the catalog item list grouped in a nested category hierarchy. Loads all categories with
/// their items in one query, then filters and re-builds the tree in memory so that empty parent
/// categories are suppressed when a search or category filter is active — giving users a tidy
/// filtered view without confusing empty headings. Stats (count, average price) are computed
/// from the unfiltered set so dashboard cards always reflect the full catalog, not just the
/// current page.
/// </summary>
public async Task<IActionResult> Index(int? categoryId, string? searchTerm)
{
try
{
// Get all categories with their items
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync(false, c => c.Items)).ToList();
var allItems = allCategories.SelectMany(c => c.Items).ToList();
// Apply search filter
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
allItems = allItems.Where(i =>
i.Name.ToLower().Contains(search) ||
(i.SKU != null && i.SKU.ToLower().Contains(search)) ||
(i.Description != null && i.Description.ToLower().Contains(search))).ToList();
}
// Apply category filter
if (categoryId.HasValue)
{
allItems = allItems.Where(i => i.CategoryId == categoryId.Value).ToList();
}
// Build hierarchical category structure with filtered items
var rootCategories = BuildCategoryHierarchy(allCategories, null, allItems, searchTerm, categoryId);
// Get categories for filter dropdown
var hierarchicalList = new List<CatalogCategory>();
BuildHierarchicalCategoryList(allCategories, null, hierarchicalList);
var categoryList = hierarchicalList
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = GetCategoryDisplayName(c, allCategories)
})
.ToList();
// View data
ViewBag.Categories = categoryList;
ViewBag.CurrentCategoryId = categoryId;
ViewBag.SearchTerm = searchTerm;
ViewBag.HasFilters = !string.IsNullOrWhiteSpace(searchTerm) || categoryId.HasValue;
// Stats for cards — enumerate once to avoid repeated SelectMany passes
var allItemsForStats = allCategories.SelectMany(c => c.Items).ToList();
ViewBag.TotalItemsCount = allItemsForStats.Count;
ViewBag.ActiveItemsCount = allItemsForStats.Count(i => i.IsActive);
ViewBag.AveragePrice = allItemsForStats.Count > 0 ? allItemsForStats.Average(i => i.DefaultPrice) : 0;
ViewBag.CategoryCount = allCategories.Count;
ViewBag.FilteredItemsCount = allItems.Count;
return View(rootCategories);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading catalog items");
TempData["Error"] = $"An error occurred while loading catalog items: {ex.Message}";
// Set default ViewBag values to prevent view errors
ViewBag.Categories = new List<SelectListItem>();
ViewBag.CurrentCategoryId = categoryId;
ViewBag.SearchTerm = searchTerm;
ViewBag.HasFilters = false;
ViewBag.TotalItemsCount = 0;
ViewBag.ActiveItemsCount = 0;
ViewBag.AveragePrice = 0;
ViewBag.CategoryCount = 0;
ViewBag.FilteredItemsCount = 0;
return View(new List<CategoryWithItems>());
}
}
/// <summary>
/// Shows full detail for a single catalog item including its category, revenue account, and COGS
/// account. Both accounts are eagerly loaded here so the view can display human-readable account
/// names without a second round-trip.
/// </summary>
public async Task<IActionResult> Details(int id)
{
try
{
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id, false, i => i.Category, i => i.RevenueAccount, i => i.CogsAccount);
if (item == null)
{
TempData["Error"] = "Catalog item not found.";
return RedirectToAction(nameof(Index));
}
var itemDto = _mapper.Map<CatalogItemDto>(item);
return View(itemDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading catalog item {ItemId}", id);
TempData["Error"] = "An error occurred while loading the catalog item.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Renders the Create form, pre-selecting the given category when navigating from a category page.
/// Enforces the subscription plan catalog-item limit before rendering — redirecting with an
/// explanatory message rather than showing a form the user cannot successfully submit. Area-unit
/// labels (sqft vs m²) are resolved from the company's metric preference so field hints match
/// the user's locale.
/// </summary>
public async Task<IActionResult> Create(int? categoryId)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
if (!await _subscriptionService.CanAddCatalogItemAsync(companyId))
{
var (used, max) = await _subscriptionService.GetCatalogItemCountAsync(companyId);
TempData["Error"] = $"You have reached your plan limit of {max} catalog items. " +
"Please upgrade your plan to add more items.";
return RedirectToAction(nameof(Index));
}
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels based on company preference
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
var model = new CreateCatalogItemDto
{
CategoryId = categoryId ?? 0,
DisplayOrder = 0
};
return View(model);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading create catalog item page");
TempData["Error"] = "An error occurred while loading the page.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Persists a new catalog item after re-checking the subscription limit on POST. The limit is
/// checked twice (GET and POST) to close the race window where a user could open the form under
/// the limit, then another admin creates items pushing the company over before this POST lands.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateCatalogItemDto dto)
{
try
{
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
if (ModelState.IsValid)
{
// Subscription catalog item limit check
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
if (!await _subscriptionService.CanAddCatalogItemAsync(companyId))
{
var (used, max) = await _subscriptionService.GetCatalogItemCountAsync(companyId);
ModelState.AddModelError(string.Empty,
$"You have reached your plan limit of {max} catalog items. " +
"Please upgrade your plan to add more items.");
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
return View(dto);
}
var item = _mapper.Map<CatalogItem>(dto);
await _unitOfWork.CatalogItems.AddAsync(item);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Catalog item '{item.Name}' created successfully.";
return RedirectToAction(nameof(Index));
}
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating catalog item");
ModelState.AddModelError("", "An error occurred while creating the catalog item.");
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels for view repopulation
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
return View(dto);
}
}
/// <summary>
/// Loads the Edit form for an existing catalog item. Verbose debug-level logging is intentional
/// here because catalog item edits occasionally failed silently in early development; the
/// granular log messages help triage mapping or dropdown-population issues without needing a
/// debugger attached to production.
/// </summary>
public async Task<IActionResult> Edit(int id)
{
try
{
_logger.LogInformation("Loading catalog item {ItemId} for editing", id);
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id);
if (item == null)
{
_logger.LogWarning("Catalog item {ItemId} not found", id);
TempData["Error"] = "Catalog item not found.";
return RedirectToAction(nameof(Index));
}
_logger.LogDebug("Mapping item {ItemId} to DTO", id);
var dto = _mapper.Map<UpdateCatalogItemDto>(item);
_logger.LogDebug("Populating category dropdown for item {ItemId}", id);
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels based on company preference
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
_logger.LogInformation("Successfully loaded item {ItemId} for editing", id);
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading catalog item {ItemId} for editing. Message: {Message}, StackTrace: {StackTrace}",
id, ex.Message, ex.StackTrace);
TempData["Error"] = $"An error occurred while loading the catalog item: {ex.Message}";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Applies edits to an existing catalog item. Uses AutoMapper's map-onto-existing-entity overload
/// (<c>_mapper.Map(dto, item)</c>) so that EF Core's change tracker detects only the modified
/// columns, rather than replacing the full entity and potentially overwriting audit fields or
/// columns not present in the DTO. Redirects to Details on success so the user can immediately
/// verify the saved state.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateCatalogItemDto dto)
{
if (id != dto.Id)
{
TempData["Error"] = "Invalid catalog item ID.";
return RedirectToAction(nameof(Index));
}
try
{
if (ModelState.IsValid)
{
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id);
if (item == null)
{
TempData["Error"] = "Catalog item not found.";
return RedirectToAction(nameof(Index));
}
_mapper.Map(dto, item);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Catalog item '{item.Name}' updated successfully.";
return RedirectToAction(nameof(Details), new { id = item.Id });
}
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels for view repopulation
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating catalog item {ItemId}", id);
ModelState.AddModelError("", "An error occurred while updating the catalog item.");
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels for view repopulation
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
return View(dto);
}
}
/// <summary>
/// Shows the Delete confirmation page with the item's category name so the user can confirm they
/// are deleting the correct item. Category is eagerly loaded here rather than relying on a prior
/// Details load so the confirmation is authoritative.
/// </summary>
public async Task<IActionResult> Delete(int id)
{
try
{
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id, false, i => i.Category);
if (item == null)
{
TempData["Error"] = "Catalog item not found.";
return RedirectToAction(nameof(Index));
}
var dto = _mapper.Map<CatalogItemDto>(item);
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading catalog item {ItemId} for deletion", id);
TempData["Error"] = "An error occurred while loading the catalog item.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Soft-deletes a catalog item, preserving it in the database so that existing quote and job line
/// items that reference it do not lose their descriptions or pricing history. Physical deletion
/// is intentionally avoided because historical records often depend on the catalog item text.
/// </summary>
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
try
{
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id);
if (item == null)
{
TempData["Error"] = "Catalog item not found.";
return RedirectToAction(nameof(Index));
}
var itemName = item.Name;
await _unitOfWork.CatalogItems.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Catalog item '{itemName}' deleted successfully.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting catalog item {ItemId}", id);
TempData["Error"] = "An error occurred while deleting the catalog item.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// AJAX endpoint that returns active items belonging to a single category, ordered by
/// <c>DisplayOrder</c> then name. Called by the quote/job item wizard when the user picks a
/// category from the dropdown; returns only the fields the wizard needs so the JSON payload is
/// small. Inactive items are excluded so discontinued services don't appear on new work orders.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetItemsByCategory(int categoryId)
{
try
{
var items = await _unitOfWork.CatalogItems.FindAsync(i => i.CategoryId == categoryId && i.IsActive);
var itemDtos = items
.OrderBy(i => i.DisplayOrder)
.ThenBy(i => i.Name)
.Select(i => new
{
i.Id,
i.Name,
i.Description,
i.DefaultPrice,
i.DefaultRequiresSandblasting,
i.DefaultRequiresMasking,
i.DefaultEstimatedMinutes,
i.ApproximateArea
})
.ToList();
return Json(itemDtos);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting items for category {CategoryId}", categoryId);
return Json(new { error = "An error occurred while loading catalog items." });
}
}
/// <summary>
/// AJAX endpoint that returns all active merchandise items grouped by category for the invoice
/// merchandise picker. Only items with <c>IsMerchandise = true</c> are returned; this flag
/// distinguishes retail goods (e.g. touch-up spray, accessories) from pure service catalog
/// entries that should not appear as physical line items on an invoice. Includes
/// <c>RevenueAccountId</c> so the invoice create page can pre-populate the GL mapping without
/// a separate lookup.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetMerchandiseItems()
{
try
{
var items = await _unitOfWork.CatalogItems.FindAsync(
i => i.IsMerchandise && i.IsActive, false, i => i.Category);
var result = items
.OrderBy(i => i.Category.Name)
.ThenBy(i => i.DisplayOrder)
.ThenBy(i => i.Name)
.Select(i => new
{
i.Id,
i.Name,
i.SKU,
CategoryName = i.Category.Name,
i.DefaultPrice,
i.RevenueAccountId
})
.ToList();
return Json(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching merchandise items");
return Json(new { error = "An error occurred while loading merchandise items." });
}
}
/// <summary>
/// AJAX full-text search across item name and SKU, returning at most 20 results ordered
/// alphabetically. The 20-result cap keeps the autocomplete dropdown usable; if a user needs a
/// more precise match they are expected to narrow their search term. Returns an empty list rather
/// than an error when the search term is blank, so the calling JavaScript doesn't need to guard
/// against a null payload.
/// </summary>
[HttpGet]
public async Task<IActionResult> SearchItems(string searchTerm)
{
try
{
if (string.IsNullOrWhiteSpace(searchTerm))
{
return Json(new List<object>());
}
var allItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category);
var search = searchTerm.ToLower();
var items = allItems
.Where(i => i.IsActive &&
(i.Name.ToLower().Contains(search) ||
(i.SKU != null && i.SKU.ToLower().Contains(search))))
.OrderBy(i => i.Name)
.Take(20) // Limit results
.Select(i => new
{
i.Id,
i.Name,
i.Description,
CategoryName = i.Category.Name,
i.DefaultPrice,
i.DefaultRequiresSandblasting,
i.DefaultRequiresMasking,
i.DefaultEstimatedMinutes,
i.ApproximateArea
})
.ToList();
return Json(items);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching catalog items with term '{SearchTerm}'", searchTerm);
return Json(new { error = "An error occurred while searching catalog items." });
}
}
/// <summary>
/// AJAX endpoint that fetches the full detail set for a single catalog item so the quote wizard
/// can pre-populate sandblasting, masking, estimated minutes, and approximate area defaults.
/// Separated from <see cref="Details"/> because the quote wizard consumes JSON, not a view, and
/// needs a slightly different field set (camelCase, null-coalesced primitives).
/// </summary>
[HttpGet]
public async Task<IActionResult> GetItemForQuote(int id)
{
try
{
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id, false, i => i.Category);
if (item == null)
{
return Json(new { error = "Catalog item not found." });
}
var itemData = new
{
id = item.Id,
name = item.Name,
description = item.Description ?? "",
price = item.DefaultPrice,
requiresSandblasting = item.DefaultRequiresSandblasting,
requiresMasking = item.DefaultRequiresMasking,
estimatedMinutes = item.DefaultEstimatedMinutes ?? 0,
approximateArea = item.ApproximateArea ?? 0,
categoryName = item.Category.Name
};
return Json(itemData);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting catalog item {ItemId} for quote", id);
return Json(new { error = "An error occurred while loading the catalog item." });
}
}
/// <summary>
/// Populates <c>ViewBag.RevenueAccounts</c> and <c>ViewBag.CogsAccounts</c> for the Create/Edit
/// forms. Returns empty lists when the company's subscription does not include accounting
/// (<c>AllowAccounting</c> is false), so the view can hide the account fields entirely rather
/// than showing broken dropdowns. Revenue and COGS accounts are split so the view can use the
/// appropriate list for each field without client-side filtering.
/// </summary>
private async Task PopulateAccountDropdowns()
{
var allowAccounting = HttpContext.Items["AllowAccounting"] as bool? ?? false;
if (!allowAccounting)
{
ViewBag.RevenueAccounts = new List<SelectListItem>();
ViewBag.CogsAccounts = new List<SelectListItem>();
return;
}
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
var revenueAccounts = accounts
.Where(a => a.AccountType == PowderCoating.Core.Enums.AccountType.Revenue)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
var cogsAccounts = accounts
.Where(a => a.AccountType == PowderCoating.Core.Enums.AccountType.CostOfGoods)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.RevenueAccounts = revenueAccounts;
ViewBag.CogsAccounts = cogsAccounts;
}
/// <summary>
/// Populates <c>ViewBag.Categories</c> with a flat list ordered so parents always appear before
/// their children, with indented display names (non-breaking spaces + em dash prefix) to convey
/// depth visually inside a standard HTML <c>&lt;select&gt;</c> element. A proper hierarchical
/// select control would require JavaScript; this simpler approach works without any JS dependency.
/// </summary>
private async Task PopulateCategoryDropdown()
{
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
// Build hierarchical list (parents before children)
var hierarchicalList = new List<CatalogCategory>();
BuildHierarchicalCategoryList(categories, null, hierarchicalList);
var categoryList = hierarchicalList
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = GetCategoryDisplayName(c, categories)
})
.ToList();
ViewBag.Categories = categoryList;
}
/// <summary>
/// Recursively walks the category tree depth-first, appending each node to <paramref name="result"/>
/// before its children. This pre-order traversal ensures the flat list respects the parent→child
/// visual ordering required by the indented dropdown approach.
/// </summary>
private void BuildHierarchicalCategoryList(List<CatalogCategory> allCategories, int? parentId, List<CatalogCategory> result)
{
var children = allCategories
.Where(c => c.ParentCategoryId == parentId)
.OrderBy(c => c.Name)
.ToList();
foreach (var category in children)
{
result.Add(category);
// Recursively add children
BuildHierarchicalCategoryList(allCategories, category.Id, result);
}
}
/// <summary>
/// Returns an indented display name for a category using non-breaking spaces and an em dash
/// (<c>\u00A0</c>, <c>\u2014</c>) so hierarchy is visible inside a plain HTML select element.
/// Regular spaces are deliberately avoided because browsers collapse them in option text.
/// </summary>
private string GetCategoryDisplayName(CatalogCategory category, List<CatalogCategory> allCategories)
{
var depth = GetCategoryDepth(category, allCategories);
// Use em dash and non-breaking spaces for better visibility
var prefix = depth > 0 ? new string('\u00A0', depth * 2) + "\u2014 " : "";
return $"{prefix}{category.Name}";
}
/// <summary>
/// Calculates how many levels deep a category sits in the tree by walking parent links.
/// Guards against two malformed data scenarios: circular references (detected via a visited
/// set) and runaway chains (hard-capped at 20 levels). Both edge cases log a warning instead
/// of throwing so a bad seed-data row doesn't take down the whole Index page.
/// </summary>
private int GetCategoryDepth(CatalogCategory category, List<CatalogCategory> allCategories)
{
int depth = 0;
var current = category;
var visited = new HashSet<int> { category.Id };
while (current.ParentCategoryId.HasValue)
{
depth++;
// Check for circular reference
if (visited.Contains(current.ParentCategoryId.Value))
{
_logger.LogWarning("Circular reference detected in category hierarchy at category {CategoryId}", category.Id);
break;
}
current = allCategories.FirstOrDefault(c => c.Id == current.ParentCategoryId.Value);
if (current == null) break;
visited.Add(current.Id);
// Safety limit to prevent infinite loops
if (depth > 20)
{
_logger.LogWarning("Category depth exceeded 20 levels at category {CategoryId}", category.Id);
break;
}
}
return depth;
}
/// <summary>
/// Recursively builds the nested <see cref="CategoryWithItems"/> tree that drives the Index view.
/// When a search or category filter is active, empty categories (no direct items AND no
/// descendant items) are pruned from the result so the view only shows categories that are
/// relevant to the current filter. Without filters, all categories are included even if empty,
/// giving admins full visibility of the catalog structure.
/// </summary>
private List<CategoryWithItems> BuildCategoryHierarchy(
List<CatalogCategory> allCategories,
int? parentId,
List<CatalogItem> filteredItems,
string? searchTerm,
int? categoryFilter)
{
var result = new List<CategoryWithItems>();
var categories = allCategories
.Where(c => c.ParentCategoryId == parentId)
.OrderBy(c => c.Name)
.ToList();
foreach (var category in categories)
{
// Get items for this category
var categoryItems = filteredItems
.Where(i => i.CategoryId == category.Id)
.OrderBy(i => i.Name)
.Select(i => _mapper.Map<CatalogItemListDto>(i))
.ToList();
// Recursively get subcategories
var subCategories = BuildCategoryHierarchy(allCategories, category.Id, filteredItems, searchTerm, categoryFilter);
// Only include category if it has items, subcategories with items, or no filters are applied
var hasItems = categoryItems.Any();
var hasSubCategoriesWithItems = subCategories.Any();
var shouldInclude = hasItems || hasSubCategoriesWithItems || (string.IsNullOrWhiteSpace(searchTerm) && !categoryFilter.HasValue);
if (shouldInclude)
{
result.Add(new CategoryWithItems
{
Category = category,
Items = categoryItems,
SubCategories = subCategories,
TotalItems = categoryItems.Count + subCategories.Sum(s => s.TotalItems)
});
}
}
return result;
}
/// <summary>
/// Generates and streams a PDF of all active catalog items, grouped by category, including the
/// company's logo and branding. Only active items are included so the PDF serves as a
/// customer-facing price sheet rather than an internal admin report. The file name embeds the
/// current date so successive exports are distinguishable without renaming.
/// </summary>
public async Task<IActionResult> ExportCatalogPdf()
{
try
{
// Get current user and company
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser?.CompanyId == null)
{
TempData["Error"] = "Company information not found.";
return RedirectToAction(nameof(Index));
}
var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
if (company == null)
{
TempData["Error"] = "Company information not found.";
return RedirectToAction(nameof(Index));
}
// Get all active catalog items with their categories
var items = await _unitOfWork.CatalogItems.FindAsync(
ci => ci.IsActive,
false,
ci => ci.Category
);
var activeItems = items.OrderBy(i => i.Category.Name).ThenBy(i => i.Name).ToList();
// Group items by category
var itemsByCategory = activeItems
.GroupBy(i => i.Category)
.OrderBy(g => g.Key.Name)
.ToList();
// Generate PDF
var pdfBytes = await _pdfService.GenerateCatalogPdfAsync(
itemsByCategory,
company.CompanyName,
company.LogoData,
company.LogoContentType
);
// Return PDF file
var fileName = $"Product-Catalog-{DateTime.Now:yyyy-MM-dd}.pdf";
return File(pdfBytes, "application/pdf", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating catalog PDF");
TempData["Error"] = "An error occurred while generating the PDF.";
return RedirectToAction(nameof(Index));
}
}
}
// Helper class for hierarchical display
public class CategoryWithItems
{
public CatalogCategory Category { get; set; } = null!;
public List<CatalogItemListDto> Items { get; set; } = new();
public List<CategoryWithItems> SubCategories { get; set; } = new();
public int TotalItems { get; set; }
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,324 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Health;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class CompanyHealthController : Controller
{
private readonly ApplicationDbContext _db;
private readonly ICompanyConfigHealthService _configHealth;
public CompanyHealthController(ApplicationDbContext db, ICompanyConfigHealthService configHealth)
{
_db = db;
_configHealth = configHealth;
}
/// <summary>
/// Renders the Company Health dashboard showing a churn-risk score and engagement
/// signals for every tenant company (SuperAdmin only).
/// <para>
/// Data is collected via a series of focused, aggregated DB queries (one per signal
/// type) rather than a single join query, because the join approach would require
/// complex LEFT JOINs across five tables and produce a large intermediate result
/// set. Each query groups by <c>CompanyId</c> and materialises to a dictionary for
/// O(1) lookup when building the per-company <see cref="CompanyHealthDto"/>.
/// </para>
/// <para>
/// All queries use <c>IgnoreQueryFilters()</c> so deleted companies and their
/// data are visible to SuperAdmins. The <c>!c.IsDeleted</c> predicate on the
/// companies query is then applied manually to exclude companies that have been
/// permanently removed from the platform.
/// </para>
/// <para>
/// Health scoring and signal classification are delegated to
/// <see cref="ComputeHealth"/> and the result is used to assign a
/// <see cref="ChurnRisk"/> bucket. Summary counts (for the dashboard header KPI
/// cards) are computed from the full un-filtered list <em>before</em> applying the
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
/// </para>
/// </summary>
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false)
{
var now = DateTime.UtcNow;
var d30 = now.AddDays(-30);
var d90 = now.AddDays(-90);
// One query per signal — all keyed by CompanyId
var companies = await _db.Companies
.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.ToListAsync();
var lastLogins = await _db.Users
.AsNoTracking().IgnoreQueryFilters()
.Where(u => u.LastLoginDate != null)
.GroupBy(u => u.CompanyId)
.Select(g => new { CompanyId = g.Key, Last = g.Max(u => u.LastLoginDate) })
.ToDictionaryAsync(x => x.CompanyId, x => x.Last);
var jobs30 = await _db.Jobs
.AsNoTracking().IgnoreQueryFilters()
.Where(j => !j.IsDeleted && j.CreatedAt >= d30)
.GroupBy(j => j.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var jobs90 = await _db.Jobs
.AsNoTracking().IgnoreQueryFilters()
.Where(j => !j.IsDeleted && j.CreatedAt >= d90)
.GroupBy(j => j.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var totalJobs = await _db.Jobs
.AsNoTracking().IgnoreQueryFilters()
.Where(j => !j.IsDeleted)
.GroupBy(j => j.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var totalCustomers = await _db.Customers
.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.GroupBy(c => c.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var totalQuotes = await _db.Quotes
.AsNoTracking().IgnoreQueryFilters()
.GroupBy(q => q.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var planNames = await _db.SubscriptionPlanConfigs
.AsNoTracking().IgnoreQueryFilters()
.Where(p => p.IsActive)
.ToDictionaryAsync(p => p.Plan, p => p.DisplayName);
// Batch config health check — one set of queries for all companies
var companyIds = companies.Select(c => c.Id).ToList();
var configHealthMap = await _configHealth.CheckBatchAsync(companyIds);
var all = companies.Select(c =>
{
var lastLogin = lastLogins.TryGetValue(c.Id, out var ll) ? ll : null;
var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1;
var j30v = jobs30.TryGetValue(c.Id, out var v30) ? v30 : 0;
var j90v = jobs90.TryGetValue(c.Id, out var v90) ? v90 : 0;
var tjobs = totalJobs.TryGetValue(c.Id, out var tj) ? tj : 0;
var tcust = totalCustomers.TryGetValue(c.Id, out var tc) ? tc : 0;
var tquotes = totalQuotes.TryGetValue(c.Id, out var tq) ? tq : 0;
var planName = planNames.TryGetValue(c.SubscriptionPlan, out var pn) ? pn : c.SubscriptionPlan.ToString();
var (score, signals) = ComputeHealth(c, daysSince, j30v, j90v, tjobs, now);
var neverActivated = tjobs == 0 && tcust == 0 && tquotes == 0
&& c.CreatedAt < now.AddDays(-7);
var riskLevel = neverActivated ? ChurnRisk.NeverActivated
: score >= 75 ? ChurnRisk.Healthy
: score >= 45 ? ChurnRisk.AtRisk
: ChurnRisk.Critical;
var configHealth = configHealthMap.TryGetValue(c.Id, out var ch)
? ch : new CompanyConfigHealth { CompanyId = c.Id };
return new CompanyHealthDto
{
Id = c.Id,
CompanyName = c.CompanyName,
PrimaryContactEmail = c.PrimaryContactEmail,
PlanDisplayName = planName,
SubscriptionStatus = c.SubscriptionStatus,
SubscriptionEndDate = c.SubscriptionEndDate,
IsActive = c.IsActive,
IsComped = c.IsComped,
IsNeverActivated = neverActivated,
CreatedAt = c.CreatedAt,
LastLoginDate = lastLogin,
DaysSinceLastLogin = daysSince,
JobsLast30Days = j30v,
JobsLast90Days = j90v,
TotalJobs = tjobs,
TotalCustomers = tcust,
TotalQuotes = tquotes,
HealthScore = score,
RiskLevel = riskLevel,
RiskSignals = signals,
ConfigHealth = configHealth,
};
}).ToList();
// Summary counts before filtering (always platform-wide totals)
ViewBag.HealthyCount = all.Count(h => h.RiskLevel == ChurnRisk.Healthy);
ViewBag.AtRiskCount = all.Count(h => h.RiskLevel == ChurnRisk.AtRisk);
ViewBag.CriticalCount = all.Count(h => h.RiskLevel == ChurnRisk.Critical);
ViewBag.NeverActivatedCount = all.Count(h => h.RiskLevel == ChurnRisk.NeverActivated);
ViewBag.ConfigIssuesCount = all.Count(h => !h.ConfigHealth.IsHealthy);
ViewBag.Risk = risk;
ViewBag.Search = search;
ViewBag.ConfigIssuesOnly = configIssuesOnly;
if (!string.IsNullOrWhiteSpace(search))
all = all.Where(h =>
h.CompanyName.Contains(search, StringComparison.OrdinalIgnoreCase) ||
h.PrimaryContactEmail.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
if (!string.IsNullOrWhiteSpace(risk) && Enum.TryParse<ChurnRisk>(risk, out var riskEnum))
all = all.Where(h => h.RiskLevel == riskEnum).ToList();
if (configIssuesOnly)
all = all.Where(h => !h.ConfigHealth.IsHealthy).ToList();
// Worst first
all = all.OrderBy(h => (int)h.RiskLevel == 3 ? 3 : 0) // NeverActivated last
.ThenBy(h => h.HealthScore)
.ThenBy(h => h.CompanyName)
.ToList();
return View(all);
}
// ── Health score algorithm ──────────────────────────────────────────────────
/// <summary>
/// Computes a 0100 health score and a list of human-readable risk signals for a
/// single company based on its subscription status, login recency, and job activity.
/// <para>
/// Scoring rules (penalties are cumulative, floor is 0):
/// <list type="bullet">
/// <item>Disabled account: score immediately set to 0, no further evaluation.</item>
/// <item>Subscription expired past the grace period: 50 pts.</item>
/// <item>Subscription within grace period: 30 pts.</item>
/// <item>Subscription expiring within 7 days: 20 pts; within 14 days: 10 pts.</item>
/// <item>Comped companies skip subscription checks entirely.</item>
/// <item>Never logged in: 30 pts; no login in 90+ days: 30; 60+d: 20; 30+d: 10.</item>
/// <item>No jobs ever: 20 pts; no jobs in last 90 days: 10; no jobs in 30d: 5.</item>
/// </list>
/// A <c>daysSinceLogin</c> value of 1 means "never logged in" and is distinct
/// from "logged in exactly 0 days ago" (i.e. today).
/// </para>
/// </summary>
private static (int score, List<string> signals) ComputeHealth(
PowderCoating.Core.Entities.Company c, int daysSinceLogin,
int j30, int j90, int totalJobs, DateTime now)
{
var score = 100;
var signals = new List<string>();
if (!c.IsActive)
{
signals.Add("Account disabled");
return (0, signals);
}
// Subscription health (skip for comped)
if (!c.IsComped && c.SubscriptionEndDate.HasValue)
{
var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays;
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
{
score -= 50;
signals.Add("Subscription expired");
}
else if (daysUntil < 0)
{
score -= 30;
signals.Add("In grace period");
}
else if (daysUntil <= 7)
{
score -= 20;
signals.Add($"Expires in {daysUntil}d");
}
else if (daysUntil <= 14)
{
score -= 10;
signals.Add($"Expires in {daysUntil}d");
}
}
// Login activity
if (daysSinceLogin == -1)
{
score -= 30;
signals.Add("Never logged in");
}
else if (daysSinceLogin >= 90)
{
score -= 30;
signals.Add($"No login {daysSinceLogin}d");
}
else if (daysSinceLogin >= 60)
{
score -= 20;
signals.Add($"No login {daysSinceLogin}d");
}
else if (daysSinceLogin >= 30)
{
score -= 10;
signals.Add($"No login {daysSinceLogin}d");
}
// Job activity
if (totalJobs == 0)
{
score -= 20;
signals.Add("No jobs ever");
}
else if (j90 == 0)
{
score -= 10;
signals.Add("No jobs in 90d");
}
else if (j30 == 0)
{
score -= 5;
signals.Add("No jobs in 30d");
}
return (Math.Max(0, score), signals);
}
}
// ── View models ────────────────────────────────────────────────────────────────
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
public class CompanyHealthDto
{
public int Id { get; set; }
public string CompanyName { get; set; } = "";
public string PrimaryContactEmail { get; set; } = "";
public string PlanDisplayName { get; set; } = "";
public SubscriptionStatus SubscriptionStatus { get; set; }
public DateTime? SubscriptionEndDate { get; set; }
public bool IsActive { get; set; }
public bool IsComped { get; set; }
public bool IsNeverActivated { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? LastLoginDate { get; set; }
public int DaysSinceLastLogin { get; set; } // -1 = never
public int JobsLast30Days { get; set; }
public int JobsLast90Days { get; set; }
public int TotalJobs { get; set; }
public int TotalCustomers { get; set; }
public int TotalQuotes { get; set; }
public int HealthScore { get; set; }
public ChurnRisk RiskLevel { get; set; }
public List<string> RiskSignals { get; set; } = new();
// Configuration health (setup gaps that break features)
public CompanyConfigHealth ConfigHealth { get; set; } = new();
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,207 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using System.ComponentModel.DataAnnotations;
namespace PowderCoating.Web.Controllers;
[Authorize]
public class ContactController : Controller
{
private readonly IAdminNotificationService _adminNotification;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ITenantContext _tenantContext;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ContactController> _logger;
public ContactController(
IAdminNotificationService adminNotification,
UserManager<ApplicationUser> userManager,
ITenantContext tenantContext,
IUnitOfWork unitOfWork,
ILogger<ContactController> logger)
{
_adminNotification = adminNotification;
_userManager = userManager;
_tenantContext = tenantContext;
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Renders the Contact Us page pre-filled with the current user's name, email, and company.
/// </summary>
[HttpGet]
public async Task<IActionResult> Index()
{
var model = await BuildViewModelAsync();
return View(model);
}
/// <summary>
/// Handles the contact form submission. Saves to the database and sends an email notification
/// to configured admin addresses with reply-to set to the submitter.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Submit(ContactFormModel form)
{
if (!ModelState.IsValid)
{
var vm = await BuildViewModelAsync();
vm.Form = form;
return View("Index", vm);
}
var user = await _userManager.GetUserAsync(User);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Persist first — email is best-effort, record must survive even if email fails
var submission = new ContactSubmission
{
CompanyId = companyId,
SenderName = form.Name,
SenderEmail = form.Email,
CompanyName = form.CompanyName,
Category = form.Category,
Subject = form.Subject,
Message = form.Message,
CreatedBy = user?.Id,
};
await _unitOfWork.ContactSubmissions.AddAsync(submission);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Contact form saved (id {Id}) from {Email} ({Company}): [{Category}] {Subject}",
submission.Id, form.Email, form.CompanyName, form.Category, form.Subject);
// Email notification is best-effort — a failure doesn't prevent the success message
try
{
await _adminNotification.NotifyContactFormSubmittedAsync(
senderName: form.Name,
senderEmail: form.Email,
companyName: form.CompanyName,
category: form.Category,
subject: form.Subject,
message: form.Message);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Contact form email notification failed for submission {Id} — record was saved", submission.Id);
}
TempData["Success"] = "Your message has been sent. We'll get back to you as soon as possible.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Admin-only list of all contact form submissions, newest first.
/// Unread submissions are highlighted. SuperAdmin sees all companies; company admins see their own.
/// </summary>
[HttpGet]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> Submissions()
{
var all = await _unitOfWork.ContactSubmissions.GetAllAsync();
var list = all.OrderByDescending(s => s.CreatedAt).ToList();
return View(list);
}
/// <summary>
/// Marks a submission as read and optionally saves an admin note. SuperAdmin only.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> MarkRead(int id, string? adminNotes)
{
var submission = await _unitOfWork.ContactSubmissions.GetByIdAsync(id, ignoreQueryFilters: true);
if (submission == null) return NotFound();
var user = await _userManager.GetUserAsync(User);
submission.IsRead = true;
submission.ReadAt = DateTime.UtcNow;
submission.ReadByUserId = user?.Id;
submission.ReadByUserName = user != null ? $"{user.FirstName} {user.LastName}".Trim() : null;
if (adminNotes != null)
submission.AdminNotes = adminNotes.Trim();
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Submissions));
}
// ─── Helpers ─────────────────────────────────────────────────────────────
/// <summary>
/// Builds the ContactViewModel pre-filled with the current user's identity and company name.
/// </summary>
private async Task<ContactViewModel> BuildViewModelAsync()
{
var user = await _userManager.GetUserAsync(User);
var companyId = _tenantContext.GetCurrentCompanyId();
var company = companyId.HasValue
? await _unitOfWork.Companies.GetByIdAsync(companyId.Value)
: null;
return new ContactViewModel
{
Form = new ContactFormModel
{
Name = user != null ? $"{user.FirstName} {user.LastName}".Trim() : string.Empty,
Email = user?.Email ?? string.Empty,
CompanyName = company?.CompanyName ?? string.Empty,
}
};
}
}
// ─── View models ─────────────────────────────────────────────────────────────
public class ContactViewModel
{
public ContactFormModel Form { get; set; } = new();
}
public class ContactFormModel
{
[Required, MaxLength(150)]
[Display(Name = "Your Name")]
public string Name { get; set; } = string.Empty;
[Required, EmailAddress, MaxLength(200)]
[Display(Name = "Your Email")]
public string Email { get; set; } = string.Empty;
[Required, MaxLength(200)]
[Display(Name = "Company")]
public string CompanyName { get; set; } = string.Empty;
[Required]
[Display(Name = "Category")]
public string Category { get; set; } = string.Empty;
[Required, MaxLength(200)]
[Display(Name = "Subject")]
public string Subject { get; set; } = string.Empty;
[Required, MaxLength(4000)]
[Display(Name = "Message")]
public string Message { get; set; } = string.Empty;
/// <summary>Standard contact reason categories shown in the dropdown.</summary>
public static readonly string[] Categories =
[
"General Question",
"Technical Issue / Bug",
"Billing & Subscription",
"Feature Request",
"Account Access Issue",
"Data Import / Migration Help",
"Training & Onboarding",
"Other",
];
}
@@ -0,0 +1,988 @@
using AutoMapper;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Customer;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Web.Helpers;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanManageCustomers)]
public class CustomersController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<CustomersController> _logger;
private readonly INotificationService _notificationService;
private readonly ISubscriptionService _subscriptionService;
private readonly ITenantContext _tenantContext;
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
public CustomersController(
IUnitOfWork unitOfWork,
IMapper mapper,
ILogger<CustomersController> logger,
INotificationService notificationService,
ISubscriptionService subscriptionService,
ITenantContext tenantContext,
ApplicationDbContext context,
UserManager<ApplicationUser> userManager)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
_notificationService = notificationService;
_subscriptionService = subscriptionService;
_tenantContext = tenantContext;
_context = context;
_userManager = userManager;
}
/// <summary>
/// Displays the paginated, searchable customer list. Sanitizes the search term before
/// building the EF filter expression to guard against injection; sorting is resolved
/// server-side via a switch so the column name never reaches raw SQL.
/// </summary>
public async Task<IActionResult> Index(
string? searchTerm,
string? sortColumn,
string sortDirection = "asc",
int pageNumber = 1,
int pageSize = 25)
{
try
{
// SECURITY: Sanitize search input to prevent injection attacks
searchTerm = SecurityHelper.SanitizeSearchTerm(searchTerm);
// Create and validate grid request
var gridRequest = new GridRequest
{
PageNumber = pageNumber,
PageSize = pageSize,
SortColumn = sortColumn ?? "CompanyName",
SortDirection = sortDirection,
SearchTerm = searchTerm
};
gridRequest.Validate();
// Build search filter
System.Linq.Expressions.Expression<Func<Customer, bool>>? filter = null;
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
filter = c => c.CompanyName.ToLower().Contains(search)
|| (c.ContactFirstName != null && c.ContactFirstName.ToLower().Contains(search))
|| (c.ContactLastName != null && c.ContactLastName.ToLower().Contains(search))
|| (c.Email != null && c.Email.ToLower().Contains(search))
|| (c.Phone != null && c.Phone.ToLower().Contains(search));
}
// Build orderBy function
Func<IQueryable<Customer>, IOrderedQueryable<Customer>> orderBy = gridRequest.SortColumn switch
{
"CompanyName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CompanyName) : q.OrderByDescending(c => c.CompanyName),
"ContactName" => q => gridRequest.SortDirection == "asc"
? q.OrderBy(c => c.ContactFirstName).ThenBy(c => c.ContactLastName)
: q.OrderByDescending(c => c.ContactFirstName).ThenByDescending(c => c.ContactLastName),
"Email" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.Email) : q.OrderByDescending(c => c.Email),
"Phone" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.Phone) : q.OrderByDescending(c => c.Phone),
"CurrentBalance" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CurrentBalance) : q.OrderByDescending(c => c.CurrentBalance),
"IsActive" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.IsActive) : q.OrderByDescending(c => c.IsActive),
"LastContactDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.LastContactDate) : q.OrderByDescending(c => c.LastContactDate),
_ => q => q.OrderBy(c => c.CompanyName)
};
// Get paged data
var (items, totalCount) = await _unitOfWork.Customers.GetPagedAsync(
gridRequest.PageNumber,
gridRequest.PageSize,
filter,
orderBy);
// Map to DTOs
var customerDtos = items.Select(c => new CustomerListDto
{
Id = c.Id,
CompanyName = c.CompanyName,
ContactName = !string.IsNullOrEmpty(c.ContactFirstName) || !string.IsNullOrEmpty(c.ContactLastName)
? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
: string.Empty,
Phone = c.Phone,
Email = c.Email,
IsCommercial = c.IsCommercial,
CurrentBalance = c.CurrentBalance,
IsActive = c.IsActive,
LastContactDate = c.LastContactDate
}).ToList();
// Create paged result
var pagedResult = new PagedResult<CustomerListDto>
{
Items = customerDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
// Set ViewBag for sorting
ViewBag.SearchTerm = searchTerm;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
return View(pagedResult);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving customers");
this.ToastError("An error occurred while loading customers.");
return View(new PagedResult<CustomerListDto>());
}
}
/// <summary>
/// Renders the customer detail page, including the 10 most-recent non-voided credit memos.
/// Credit memos are loaded separately (not via eager loading) because the customer entity
/// does not navigate to CreditMemo; this keeps the Customer aggregate lean.
/// </summary>
public async Task<IActionResult> Details(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
if (customer == null)
{
return NotFound();
}
var creditMemos = await _unitOfWork.CreditMemos.FindAsync(
cm => cm.CustomerId == id.Value && cm.Status != CreditMemoStatus.Voided);
ViewBag.CreditMemos = creditMemos
.OrderByDescending(cm => cm.IssueDate)
.Take(10)
.ToList();
var customerDto = _mapper.Map<CustomerDto>(customer);
return View(customerDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving customer {CustomerId}", id);
this.ToastError("An error occurred while loading the customer.");
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Shows the full paginated job history for a single customer. Defaults to descending
/// by creation date so the newest jobs appear first. JobStatus and JobPriority are
/// eagerly loaded so their display names and sort orders are available without N+1 queries.
/// </summary>
public async Task<IActionResult> JobHistory(
int? id,
string? searchTerm,
string? sortColumn,
string sortDirection = "desc",
int pageNumber = 1,
int pageSize = 25)
{
if (id == null)
{
return NotFound();
}
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
if (customer == null)
{
return NotFound();
}
var gridRequest = new GridRequest
{
PageNumber = pageNumber,
PageSize = pageSize,
SortColumn = sortColumn ?? "CreatedAt",
SortDirection = sortDirection,
SearchTerm = searchTerm
};
gridRequest.Validate();
System.Linq.Expressions.Expression<Func<Core.Entities.Job, bool>> filter;
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
filter = j => j.CustomerId == id.Value
&& (j.JobNumber.ToLower().Contains(search)
|| j.Description.ToLower().Contains(search)
|| (j.CustomerPO != null && j.CustomerPO.ToLower().Contains(search))
|| j.JobStatus.DisplayName.ToLower().Contains(search)
|| j.JobPriority.DisplayName.ToLower().Contains(search));
}
else
{
filter = j => j.CustomerId == id.Value;
}
Func<IQueryable<Core.Entities.Job>, IOrderedQueryable<Core.Entities.Job>> orderBy = gridRequest.SortColumn switch
{
"JobNumber" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.JobNumber) : q.OrderByDescending(j => j.JobNumber),
"Status" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.JobStatus.DisplayOrder) : q.OrderByDescending(j => j.JobStatus.DisplayOrder),
"Priority" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.JobPriority.DisplayOrder) : q.OrderByDescending(j => j.JobPriority.DisplayOrder),
"ScheduledDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.ScheduledDate) : q.OrderByDescending(j => j.ScheduledDate),
"DueDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.DueDate) : q.OrderByDescending(j => j.DueDate),
"FinalPrice" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.FinalPrice) : q.OrderByDescending(j => j.FinalPrice),
_ => q => q.OrderByDescending(j => j.CreatedAt)
};
var (items, totalCount) = await _unitOfWork.Jobs.GetPagedAsync(
gridRequest.PageNumber,
gridRequest.PageSize,
filter,
orderBy,
j => j.JobStatus,
j => j.JobPriority);
// Use AutoMapper to map jobs to DTOs
var jobDtos = _mapper.Map<List<Application.DTOs.Job.JobListDto>>(items);
var pagedResult = new Application.DTOs.Common.PagedResult<Application.DTOs.Job.JobListDto>
{
Items = jobDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
ViewBag.CustomerId = id.Value;
ViewBag.CustomerName = !string.IsNullOrWhiteSpace(customer.CompanyName)
? customer.CompanyName
: $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
ViewBag.SearchTerm = searchTerm;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
return View(pagedResult);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving job history for customer {CustomerId}", id);
this.ToastError("An error occurred while loading the job history.");
return RedirectToAction(nameof(Details), new { id });
}
}
/// <summary>
/// Renders the customer activity tab-panel, showing jobs and quotes in independent
/// paginated grids. Each grid carries its own sort/page state in the query string so
/// both can be navigated without resetting the other. Page sizes are clamped to
/// [10, 100] server-side to prevent accidental full-table loads.
/// </summary>
public async Task<IActionResult> Activity(
int? id,
string activeTab = "jobs",
string jobSort = "CreatedAt",
string jobDir = "desc",
int jobPage = 1,
int jobSize = 25,
string quoteSort = "QuoteDate",
string quoteDir = "desc",
int quotePage = 1,
int quoteSize = 25)
{
if (id == null)
{
return NotFound();
}
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
if (customer == null)
{
return NotFound();
}
var customerName = !string.IsNullOrWhiteSpace(customer.CompanyName)
? customer.CompanyName
: $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
// Clamp page sizes
jobSize = Math.Clamp(jobSize, 10, 100);
quoteSize = Math.Clamp(quoteSize, 10, 100);
if (jobPage < 1) jobPage = 1;
if (quotePage < 1) quotePage = 1;
// --- Jobs ---
Func<IQueryable<Core.Entities.Job>, IOrderedQueryable<Core.Entities.Job>> jobOrderBy = jobSort switch
{
"JobNumber" => q => jobDir == "asc" ? q.OrderBy(j => j.JobNumber) : q.OrderByDescending(j => j.JobNumber),
"Status" => q => jobDir == "asc" ? q.OrderBy(j => j.JobStatus.DisplayOrder) : q.OrderByDescending(j => j.JobStatus.DisplayOrder),
"Priority" => q => jobDir == "asc" ? q.OrderBy(j => j.JobPriority.DisplayOrder) : q.OrderByDescending(j => j.JobPriority.DisplayOrder),
"DueDate" => q => jobDir == "asc" ? q.OrderBy(j => j.DueDate) : q.OrderByDescending(j => j.DueDate),
"FinalPrice"=> q => jobDir == "asc" ? q.OrderBy(j => j.FinalPrice) : q.OrderByDescending(j => j.FinalPrice),
_ => q => jobDir == "asc" ? q.OrderBy(j => j.CreatedAt) : q.OrderByDescending(j => j.CreatedAt)
};
var (jobItems, jobTotal) = await _unitOfWork.Jobs.GetPagedAsync(
jobPage, jobSize,
j => j.CustomerId == id.Value,
jobOrderBy,
j => j.JobStatus,
j => j.JobPriority);
var jobDtos = _mapper.Map<List<Application.DTOs.Job.JobListDto>>(jobItems);
var pagedJobs = new Application.DTOs.Common.PagedResult<Application.DTOs.Job.JobListDto>
{
Items = jobDtos,
PageNumber = jobPage,
PageSize = jobSize,
TotalCount = jobTotal
};
// --- Quotes ---
Func<IQueryable<Core.Entities.Quote>, IOrderedQueryable<Core.Entities.Quote>> quoteOrderBy = quoteSort switch
{
"QuoteNumber" => q => quoteDir == "asc" ? q.OrderBy(x => x.QuoteNumber) : q.OrderByDescending(x => x.QuoteNumber),
"Status" => q => quoteDir == "asc" ? q.OrderBy(x => x.QuoteStatus.DisplayOrder) : q.OrderByDescending(x => x.QuoteStatus.DisplayOrder),
"Total" => q => quoteDir == "asc" ? q.OrderBy(x => x.Total) : q.OrderByDescending(x => x.Total),
"Expiration" => q => quoteDir == "asc" ? q.OrderBy(x => x.ExpirationDate) : q.OrderByDescending(x => x.ExpirationDate),
_ => q => quoteDir == "asc" ? q.OrderBy(x => x.QuoteDate) : q.OrderByDescending(x => x.QuoteDate)
};
var (quoteItems, quoteTotal) = await _unitOfWork.Quotes.GetPagedAsync(
quotePage, quoteSize,
q => q.CustomerId == id.Value,
quoteOrderBy,
q => q.QuoteStatus);
var quoteDtos = _mapper.Map<List<Application.DTOs.Quote.QuoteListDto>>(quoteItems);
var pagedQuotes = new Application.DTOs.Common.PagedResult<Application.DTOs.Quote.QuoteListDto>
{
Items = quoteDtos,
PageNumber = quotePage,
PageSize = quoteSize,
TotalCount = quoteTotal
};
ViewBag.CustomerId = id.Value;
ViewBag.CustomerName = customerName;
ViewBag.ActiveTab = activeTab;
ViewBag.Jobs = pagedJobs;
ViewBag.JobSort = jobSort;
ViewBag.JobDir = jobDir;
ViewBag.Quotes = pagedQuotes;
ViewBag.QuoteSort = quoteSort;
ViewBag.QuoteDir = quoteDir;
return View();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving activity for customer {CustomerId}", id);
this.ToastError("An error occurred while loading customer activity.");
return RedirectToAction(nameof(Details), new { id });
}
}
/// <summary>
/// Displays the paginated invoice list scoped to a single customer. Defaults to
/// descending invoice date so the most recent invoice is shown first, matching the
/// typical accounts-receivable workflow of reviewing the latest outstanding balance.
/// </summary>
public async Task<IActionResult> Invoices(
int? id,
string? sortColumn,
string sortDirection = "desc",
int pageNumber = 1,
int pageSize = 25)
{
if (id == null)
{
return NotFound();
}
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
if (customer == null)
{
return NotFound();
}
var gridRequest = new GridRequest
{
PageNumber = pageNumber,
PageSize = pageSize,
SortColumn = sortColumn ?? "InvoiceDate",
SortDirection = sortDirection
};
gridRequest.Validate();
Func<IQueryable<Core.Entities.Invoice>, IOrderedQueryable<Core.Entities.Invoice>> orderBy = gridRequest.SortColumn switch
{
"InvoiceNumber" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.InvoiceNumber) : q.OrderByDescending(i => i.InvoiceNumber),
"Status" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.Status) : q.OrderByDescending(i => i.Status),
"DueDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.DueDate) : q.OrderByDescending(i => i.DueDate),
"Total" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.Total) : q.OrderByDescending(i => i.Total),
"BalanceDue" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.BalanceDue) : q.OrderByDescending(i => i.BalanceDue),
_ => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.InvoiceDate) : q.OrderByDescending(i => i.InvoiceDate)
};
var (items, totalCount) = await _unitOfWork.Invoices.GetPagedAsync(
gridRequest.PageNumber,
gridRequest.PageSize,
i => i.CustomerId == id.Value,
orderBy);
var invoiceDtos = _mapper.Map<List<Application.DTOs.Invoice.InvoiceListDto>>(items);
var pagedResult = new Application.DTOs.Common.PagedResult<Application.DTOs.Invoice.InvoiceListDto>
{
Items = invoiceDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
ViewBag.CustomerId = id.Value;
ViewBag.CustomerName = !string.IsNullOrWhiteSpace(customer.CompanyName)
? customer.CompanyName
: $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
return View(pagedResult);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving invoices for customer {CustomerId}", id);
this.ToastError("An error occurred while loading the invoices.");
return RedirectToAction(nameof(Details), new { id });
}
}
/// <summary>
/// Renders the customer creation form. Checks the subscription plan limit before
/// allowing access; redirects with an error if the company has reached its customer cap.
/// Pricing tiers are pre-loaded via <see cref="PopulatePricingTiersAsync"/> so the
/// dropdown is available immediately without a round-trip.
/// </summary>
public async Task<IActionResult> Create()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
if (!await _subscriptionService.CanAddCustomerAsync(companyId))
{
var (used, max) = await _subscriptionService.GetCustomerCountAsync(companyId);
TempData["Error"] = $"You have reached your plan limit of {max} customers. " +
"Please upgrade your plan to add more customers.";
return RedirectToAction(nameof(Index));
}
await PopulatePricingTiersAsync();
return View();
}
/// <summary>
/// Persists a new customer record. Re-validates the subscription plan limit on POST
/// to guard against concurrent session exploitation. If the staff member records
/// verbal SMS consent, sets NotifyBySms, stamps SmsConsentedAt, and immediately
/// sends a confirmation SMS via <see cref="INotificationService"/>; the resulting
/// notification log entry is then surfaced as a toast so staff can confirm delivery.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateCustomerDto dto)
{
if (!ModelState.IsValid)
{
await PopulatePricingTiersAsync();
return View(dto);
}
// Subscription limit check
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
if (!await _subscriptionService.CanAddCustomerAsync(companyId))
{
var (used, max) = await _subscriptionService.GetCustomerCountAsync(companyId);
ModelState.AddModelError(string.Empty,
$"You have reached your plan limit of {max} customers. " +
"Please upgrade your plan to add more customers.");
await PopulatePricingTiersAsync();
return View(dto);
}
try
{
var customer = _mapper.Map<Customer>(dto);
customer.CreatedAt = DateTime.UtcNow;
customer.IsActive = true;
// SMS consent: only enable if staff has checked the consent box
if (dto.SmsConsentGranted && !string.IsNullOrWhiteSpace(dto.MobilePhone ?? dto.Phone))
{
customer.NotifyBySms = true;
customer.SmsConsentedAt = DateTime.UtcNow;
customer.SmsConsentMethod = "Staff-recorded verbal consent";
}
else
{
customer.NotifyBySms = false;
}
await _unitOfWork.Customers.AddAsync(customer);
await _unitOfWork.SaveChangesAsync();
// Send welcome/confirmation SMS after the customer record is saved
if (customer.NotifyBySms)
{
try { await _notificationService.NotifySmsConsentGrantedAsync(customer); }
catch (Exception ex) { _logger.LogWarning(ex, "SMS consent confirmation failed for customer {Id}", customer.Id); }
var smsLog = await _context.NotificationLogs
.IgnoreQueryFilters()
.Where(n => n.CustomerId == customer.Id)
.OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync();
this.SetNotificationResultToast(smsLog);
}
this.ToastSuccess("Customer created successfully.");
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating customer");
this.ToastError("An error occurred while creating the customer.");
return View(dto);
}
}
/// <summary>
/// Renders the customer edit form, pre-populated via AutoMapper from the stored entity.
/// Pricing tiers are loaded so the dropdown reflects current active tiers even if
/// the tier list has changed since the customer was created.
/// </summary>
public async Task<IActionResult> Edit(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
if (customer == null)
{
return NotFound();
}
var dto = _mapper.Map<UpdateCustomerDto>(customer);
await PopulatePricingTiersAsync();
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving customer {CustomerId} for edit", id);
this.ToastError("An error occurred while loading the customer.");
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Persists edits to an existing customer. Special handling covers two sensitive fields:
/// (1) Tax-exempt certificate bytes are captured before AutoMapper runs and restored after,
/// preventing the Edit form from inadvertently clearing an already-uploaded file.
/// (2) SMS consent is append-only: newly granted consent stamps SmsConsentedAt and sends
/// a confirmation SMS; existing consent is not re-granted, but NotifyBySms can be
/// toggled on/off after the first grant to pause or resume messages.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateCustomerDto dto)
{
if (id != dto.Id)
{
return NotFound();
}
if (!ModelState.IsValid)
{
await PopulatePricingTiersAsync();
return View(dto);
}
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null)
{
return NotFound();
}
// Capture consent state before mapping (mapping ignores SmsConsentedAt/Method)
var hadConsentBefore = customer.SmsConsentedAt.HasValue;
// Preserve cert data before mapping (mapper ignores these, but save explicitly for safety)
var certData = customer.TaxExemptCertificateData;
var certContentType = customer.TaxExemptCertificateContentType;
var certFileName = customer.TaxExemptCertificateFileName;
_mapper.Map(dto, customer);
customer.UpdatedAt = DateTime.UtcNow;
// Restore cert data (defensive: ensure Edit form never clears uploaded certificates)
customer.TaxExemptCertificateData = certData;
customer.TaxExemptCertificateContentType = certContentType;
customer.TaxExemptCertificateFileName = certFileName;
// If consent was not previously granted and staff has now checked the consent box
var newlyGranted = !hadConsentBefore && dto.SmsConsentGranted
&& !string.IsNullOrWhiteSpace(customer.MobilePhone ?? customer.Phone);
if (newlyGranted)
{
customer.NotifyBySms = true;
customer.SmsConsentedAt = DateTime.UtcNow;
customer.SmsConsentMethod = "Staff-recorded verbal consent";
}
else if (!hadConsentBefore)
{
// No consent exists and none newly granted — keep SMS off
customer.NotifyBySms = false;
}
// If hadConsentBefore: NotifyBySms is mapped from the toggle as-is (allow pause/resume)
await _unitOfWork.Customers.UpdateAsync(customer);
await _unitOfWork.SaveChangesAsync();
// Send welcome SMS if consent was just granted
if (newlyGranted)
{
try { await _notificationService.NotifySmsConsentGrantedAsync(customer); }
catch (Exception ex) { _logger.LogWarning(ex, "SMS consent confirmation failed for customer {Id}", customer.Id); }
var smsLog = await _context.NotificationLogs
.IgnoreQueryFilters()
.Where(n => n.CustomerId == customer.Id)
.OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync();
this.SetNotificationResultToast(smsLog);
}
this.ToastSuccess("Customer updated successfully.");
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating customer {CustomerId}", id);
this.ToastError("An error occurred while updating the customer.");
return View(dto);
}
}
/// <summary>
/// Renders the delete confirmation page for a customer. The view shows a full
/// summary so the user can verify they are about to remove the correct record
/// before submitting the confirmation POST.
/// </summary>
public async Task<IActionResult> Delete(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
if (customer == null)
{
return NotFound();
}
var customerDto = _mapper.Map<CustomerDto>(customer);
return View(customerDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving customer {CustomerId} for delete", id);
this.ToastError("An error occurred while loading the customer.");
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Performs a soft delete on the customer record. A soft delete sets IsDeleted = true
/// rather than removing the row, preserving referential integrity with historical jobs,
/// quotes, and invoices. The record is filtered from all normal queries by the global
/// EF query filter, and can only be seen by SuperAdmins with ignoreQueryFilters.
/// </summary>
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null)
{
return NotFound();
}
await _unitOfWork.Customers.SoftDeleteAsync(customer);
await _unitOfWork.SaveChangesAsync();
this.ToastSuccess("Customer deleted successfully.");
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting customer {CustomerId}", id);
this.ToastError("An error occurred while deleting the customer.");
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Streams the tax-exempt certificate file stored as binary in the Customer entity back
/// to the browser using the original content-type and filename. Certificates are stored
/// in-database (not on disk) to keep them tenant-isolated without requiring blob storage
/// configuration in every deployment environment.
/// </summary>
[HttpGet]
public async Task<IActionResult> TaxExemptCertificate(int id)
{
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null || customer.TaxExemptCertificateData == null || customer.TaxExemptCertificateData.Length == 0)
{
return NotFound();
}
var contentType = customer.TaxExemptCertificateContentType ?? "application/pdf";
var fileName = customer.TaxExemptCertificateFileName ?? "tax-exempt-certificate.pdf";
return File(customer.TaxExemptCertificateData, contentType, fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving tax exempt certificate for customer {CustomerId}", id);
return NotFound();
}
}
/// <summary>
/// Accepts a tax-exempt certificate upload (PDF, JPG, or PNG, max 10 MB) and stores the
/// raw bytes directly on the Customer entity. Storing in-database avoids the need for a
/// shared file system or Azure Blob container in single-tenant or on-premise deployments.
/// Content-type and original filename are stored alongside the bytes so
/// <see cref="TaxExemptCertificate"/> can return them faithfully.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadTaxExemptCertificate(int id, IFormFile certificateFile)
{
try
{
if (certificateFile == null || certificateFile.Length == 0)
{
this.ToastError("Please select a file to upload.");
return RedirectToAction(nameof(Edit), new { id });
}
// Validate file type
var allowedContentTypes = new[] { "application/pdf", "image/jpeg", "image/jpg", "image/png" };
if (!allowedContentTypes.Contains(certificateFile.ContentType, StringComparer.OrdinalIgnoreCase))
{
this.ToastError("Invalid file type. Only PDF and image files (JPG, PNG) are allowed.");
return RedirectToAction(nameof(Edit), new { id });
}
// Validate file size (10 MB max)
const long maxSize = 10 * 1024 * 1024;
if (certificateFile.Length > maxSize)
{
this.ToastError("File size exceeds the 10 MB limit.");
return RedirectToAction(nameof(Edit), new { id });
}
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null)
{
return NotFound();
}
// Read file data
using var ms = new MemoryStream();
await certificateFile.CopyToAsync(ms);
customer.TaxExemptCertificateData = ms.ToArray();
customer.TaxExemptCertificateContentType = certificateFile.ContentType;
customer.TaxExemptCertificateFileName = certificateFile.FileName;
await _unitOfWork.Customers.UpdateAsync(customer);
await _unitOfWork.SaveChangesAsync();
this.ToastSuccess("Tax exempt certificate uploaded successfully.");
return RedirectToAction(nameof(Edit), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading tax exempt certificate for customer {CustomerId}", id);
this.ToastError("An error occurred while uploading the certificate.");
return RedirectToAction(nameof(Edit), new { id });
}
}
/// <summary>
/// Clears the tax-exempt certificate from the customer record by nulling all three
/// certificate fields (Data, ContentType, FileName). A separate action (rather than
/// embedding the delete in Edit) avoids race conditions where the Edit form could
/// clear the certificate bytes if the user saves without re-uploading.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteTaxExemptCertificate(int id)
{
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null)
{
return NotFound();
}
customer.TaxExemptCertificateData = null;
customer.TaxExemptCertificateContentType = null;
customer.TaxExemptCertificateFileName = null;
await _unitOfWork.Customers.UpdateAsync(customer);
await _unitOfWork.SaveChangesAsync();
this.ToastSuccess("Tax exempt certificate deleted successfully.");
return RedirectToAction(nameof(Edit), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting tax exempt certificate for customer {CustomerId}", id);
this.ToastError("An error occurred while deleting the certificate.");
return RedirectToAction(nameof(Edit), new { id });
}
}
/// <summary>
/// Issues a standalone credit memo and increments the customer's CreditBalance.
/// Restricted to CompanyAdmin because credits affect the financial ledger. The memo
/// number follows the CM-YYMM-#### format generated by <see cref="GenerateCreditMemoNumberAsync"/>.
/// The memo is not tied to any invoice at creation; it will be applied automatically
/// when the next invoice is created for this customer.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> AddCredit(int id, AddCreditDto dto)
{
if (!ModelState.IsValid)
{
TempData["Error"] = "Invalid credit amount or missing reason.";
return RedirectToAction(nameof(Details), new { id });
}
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null) return NotFound();
var currentUser = await _userManager.GetUserAsync(User);
var memoNumber = await GenerateCreditMemoNumberAsync();
var memo = new CreditMemo
{
MemoNumber = memoNumber,
CustomerId = id,
OriginalInvoiceId = null, // not tied to an invoice
Amount = dto.Amount,
AmountApplied = 0,
IssueDate = DateTime.UtcNow,
ExpiryDate = dto.ExpiryDate,
Reason = dto.Reason,
Notes = dto.Notes,
Status = CreditMemoStatus.Active,
IssuedById = currentUser?.Id,
CompanyId = customer.CompanyId,
CreatedAt = DateTime.UtcNow,
CreatedBy = currentUser?.Email
};
await _unitOfWork.CreditMemos.AddAsync(memo);
customer.CreditBalance += dto.Amount;
await _unitOfWork.Customers.UpdateAsync(customer);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Credit of {dto.Amount:C} added to {customer.CompanyName ?? customer.ContactFirstName}'s account (Memo {memoNumber}).";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding credit to customer {CustomerId}", id);
TempData["Error"] = "An error occurred while adding the credit.";
}
return RedirectToAction(nameof(Details), new { id });
}
/// <summary>
/// Generates the next sequential credit memo number in CM-YYMM-#### format.
/// Uses <c>ignoreQueryFilters: true</c> when scanning all existing memos so that
/// soft-deleted records are included in the sequence check and their numbers are
/// never reused, maintaining an audit-friendly gap-free numbering history.
/// </summary>
private async Task<string> GenerateCreditMemoNumberAsync()
{
var allMemos = await _unitOfWork.CreditMemos.GetAllAsync(true);
var prefix = $"CM-{DateTime.Now:yyMM}-";
var maxNum = allMemos
.Where(m => m.MemoNumber.StartsWith(prefix))
.Select(m => { int.TryParse(m.MemoNumber.Replace(prefix, ""), out int n); return n; })
.DefaultIfEmpty(0).Max();
return $"{prefix}{(maxNum + 1):D4}";
}
/// <summary>
/// Loads all active pricing tiers into ViewBag.PricingTiers for the Create and Edit
/// dropdowns. Tiers are sorted alphabetically and include the discount percentage in
/// the label text so staff can see the benefit without opening the tier detail page.
/// </summary>
private async Task PopulatePricingTiersAsync()
{
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.IsActive);
ViewBag.PricingTiers = tiers
.OrderBy(t => t.TierName)
.Select(t => new SelectListItem
{
Value = t.Id.ToString(),
Text = t.DiscountPercent > 0
? $"{t.TierName} ({t.DiscountPercent:0.##}% discount)"
: t.TierName
})
.ToList();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,172 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only CRUD interface for managing the rotating tip-of-the-day entries
/// displayed on the tenant dashboard. The system ships with 40 seed tips loaded by
/// <c>SeedDataService.SeedSystemDataAsync()</c>; this controller allows platform
/// operators to add, edit, deactivate, or remove tips without a code deployment.
/// The tip shown each day is selected by the dashboard controller using
/// <c>DayOfYear % activeCount</c> so it rotates predictably.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class DashboardTipsController : Controller
{
private readonly ApplicationDbContext _db;
public DashboardTipsController(ApplicationDbContext db)
{
_db = db;
}
/// <summary>
/// Returns a paginated, optionally filtered list of all dashboard tips.
/// Active tips are sorted first (then by newest Id) to make the currently
/// live pool easy to review at a glance. ViewBag includes both the filtered
/// count and the global active/total counts for the header summary cards.
/// </summary>
// GET: /DashboardTips
public async Task<IActionResult> Index(string? search, bool? activeOnly, int page = 1)
{
const int pageSize = 25;
var query = _db.DashboardTips.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(t => t.TipText.Contains(search));
if (activeOnly == true)
query = query.Where(t => t.IsActive);
var total = await query.CountAsync();
var tips = await query
.OrderByDescending(t => t.IsActive)
.ThenByDescending(t => t.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
ViewBag.Search = search;
ViewBag.ActiveOnly = activeOnly ?? false;
ViewBag.Page = page;
ViewBag.PageSize = pageSize;
ViewBag.Total = total;
ViewBag.TotalPages = (int)Math.Ceiling(total / (double)pageSize);
ViewBag.ActiveCount = await _db.DashboardTips.CountAsync(t => t.IsActive);
ViewBag.TotalCount = await _db.DashboardTips.CountAsync();
return View(tips);
}
/// <summary>Returns the Create form with an empty <see cref="DashboardTip"/> model.</summary>
// GET: /DashboardTips/Create
public IActionResult Create() => View(new DashboardTip());
/// <summary>
/// Persists a new dashboard tip. Text is trimmed before saving to prevent
/// whitespace-only entries from appearing as blank tiles on the dashboard.
/// Model validation is done manually (rather than relying solely on
/// <c>[Required]</c> attributes) to ensure a meaningful error message is shown.
/// </summary>
// POST: /DashboardTips/Create
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Create(DashboardTip model)
{
if (string.IsNullOrWhiteSpace(model.TipText))
{
ModelState.AddModelError(nameof(model.TipText), "Tip text is required.");
return View(model);
}
_db.DashboardTips.Add(new DashboardTip
{
TipText = model.TipText.Trim(),
IsActive = model.IsActive,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
TempData["Success"] = "Tip added successfully.";
return RedirectToAction(nameof(Index));
}
/// <summary>Returns the Edit form for an existing tip, or 404 if not found.</summary>
// GET: /DashboardTips/Edit/5
public async Task<IActionResult> Edit(int id)
{
var tip = await _db.DashboardTips.FindAsync(id);
if (tip == null) return NotFound();
return View(tip);
}
/// <summary>
/// Updates text and active flag for an existing tip. Returns the tracked entity
/// (not the posted model) to the view on validation failure so the form shows
/// the database version rather than potentially mangled posted data.
/// </summary>
// POST: /DashboardTips/Edit/5
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, DashboardTip model)
{
var tip = await _db.DashboardTips.FindAsync(id);
if (tip == null) return NotFound();
if (string.IsNullOrWhiteSpace(model.TipText))
{
ModelState.AddModelError(nameof(model.TipText), "Tip text is required.");
return View(tip);
}
tip.TipText = model.TipText.Trim();
tip.IsActive = model.IsActive;
await _db.SaveChangesAsync();
TempData["Success"] = "Tip updated.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Permanently (hard) deletes a dashboard tip. Tips are platform metadata so
/// they do not use soft delete — they have no foreign-key relationships to
/// tenant data and nothing references a deleted tip's Id. A missing Id is
/// silently ignored to keep the action idempotent.
/// </summary>
// POST: /DashboardTips/Delete/5
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var tip = await _db.DashboardTips.FindAsync(id);
if (tip != null)
{
_db.DashboardTips.Remove(tip);
await _db.SaveChangesAsync();
TempData["Success"] = "Tip deleted.";
}
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Flips the <c>IsActive</c> flag on a tip without a full edit round-trip.
/// This lets operators quickly remove a tip from the rotation (deactivate)
/// without deleting it, preserving the ability to reactivate it later.
/// A missing Id is silently ignored to keep the action idempotent.
/// </summary>
// POST: /DashboardTips/ToggleActive/5
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleActive(int id)
{
var tip = await _db.DashboardTips.FindAsync(id);
if (tip != null)
{
tip.IsActive = !tip.IsActive;
await _db.SaveChangesAsync();
}
return RedirectToAction(nameof(Index));
}
}
@@ -0,0 +1,806 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OfficeOpenXml;
using OfficeOpenXml.Style;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Drawing;
using System.IO.Compression;
using System.Text;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only controller for exporting any tenant company's operational data.
/// Supports XLSX (single workbook, multiple sheets) and CSV (per-sheet files inside a ZIP archive).
/// All queries use <c>IgnoreQueryFilters()</c> to bypass the global soft-delete and multi-tenancy
/// filters that normally restrict non-SuperAdmin users to their own company's non-deleted records.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class DataExportController : Controller
{
private readonly ApplicationDbContext _db;
/// <summary>
/// Initializes the controller and sets the EPPlus license context to NonCommercial.
/// EPPlus 5+ requires an explicit license declaration at startup or an exception is thrown;
/// this must be done before any <see cref="ExcelPackage"/> is constructed.
/// </summary>
public DataExportController(ApplicationDbContext db)
{
_db = db;
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
}
// ── GET: Index ───────────────────────────────────────────────────────────
/// <summary>
/// Displays the export selection page, listing every non-deleted tenant company so the
/// SuperAdmin can choose which company to export and which data sets to include.
/// Uses <c>IgnoreQueryFilters()</c> because the global filter would otherwise scope results
/// to the SuperAdmin's own company context, which would return nothing meaningful here.
/// </summary>
public async Task<IActionResult> Index()
{
var companies = await _db.Companies
.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.OrderBy(c => c.CompanyName)
.Select(c => new CompanyExportSummary
{
Id = c.Id,
CompanyName = c.CompanyName,
Plan = c.SubscriptionPlan.ToString(),
IsActive = c.IsActive,
CreatedAt = c.CreatedAt
})
.ToListAsync();
return View(companies);
}
// ── POST: Export ─────────────────────────────────────────────────────────
/// <summary>
/// Accepts the company ID, selected sheet names, and desired format (<c>xlsx</c> or <c>csv</c>)
/// and delegates to the appropriate format builder.
/// Sheet names are reordered into a canonical order by <see cref="OrderSheets"/> so that the
/// resulting file has a consistent layout regardless of the order the user checked the boxes.
/// Illegal filename characters are stripped from the company name to produce a safe file name.
/// </summary>
/// <param name="companyId">ID of the tenant company whose data should be exported.</param>
/// <param name="sheets">Array of sheet/entity names selected on the form (e.g. "Customers", "Jobs").</param>
/// <param name="format">Output format: <c>"xlsx"</c> (default) or <c>"csv"</c>.</param>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Export(int companyId, string[] sheets, string format = "xlsx")
{
var company = await _db.Companies
.AsNoTracking().IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == companyId && !c.IsDeleted);
if (company == null) return NotFound();
if (sheets == null || sheets.Length == 0)
{
TempData["Error"] = "Select at least one data type to export.";
return RedirectToAction(nameof(Index));
}
var safeName = string.Concat(company.CompanyName.Split(Path.GetInvalidFileNameChars()));
var ordered = OrderSheets(sheets);
if (format == "csv")
return await ExportAsCsv(companyId, company.CompanyName, safeName, ordered);
return await ExportAsXlsx(companyId, company.CompanyName, safeName, ordered);
}
/// <summary>
/// Builds the XLSX export: one EPPlus workbook with one worksheet per selected data type,
/// plus a leading "Export Info" metadata sheet.
/// The workbook is assembled entirely in memory (no temp files) and returned as a file download,
/// which avoids disk I/O and temp-file cleanup concerns on the server.
/// </summary>
/// <param name="companyId">Tenant company ID used to filter all sheet queries.</param>
/// <param name="companyName">Human-readable company name written into the metadata sheet.</param>
/// <param name="safeName">Filesystem-safe company name used in the download file name.</param>
/// <param name="ordered">Sheet names in canonical order; only listed names are added.</param>
private async Task<IActionResult> ExportAsXlsx(int companyId, string companyName, string safeName, string[] ordered)
{
using var package = new ExcelPackage();
var headerColor = Color.FromArgb(31, 78, 121);
foreach (var sheet in ordered)
{
switch (sheet)
{
case "Customers": await AddCustomersSheet(package, companyId, headerColor); break;
case "Jobs": await AddJobsSheet(package, companyId, headerColor); break;
case "Quotes": await AddQuotesSheet(package, companyId, headerColor); break;
case "Invoices": await AddInvoicesSheet(package, companyId, headerColor); break;
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
}
}
AddMetadataSheet(package, companyName, ordered);
var fileName = $"{safeName}_Export_{DateTime.UtcNow:yyyyMMdd}.xlsx";
return File(package.GetAsByteArray(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
fileName);
}
/// <summary>
/// Builds the CSV export: one CSV file per selected data type, bundled into a single in-memory
/// ZIP archive that is returned as a file download.
/// A ZIP is used rather than multiple individual file downloads because browsers do not support
/// multi-file responses; the archive also keeps all related CSVs together for the recipient.
/// <c>leaveOpen: true</c> is passed to <see cref="ZipArchive"/> so the underlying
/// <see cref="MemoryStream"/> remains valid after the archive is disposed, allowing
/// <c>ms.ToArray()</c> to capture the final bytes before the stream goes out of scope.
/// </summary>
/// <param name="companyId">Tenant company ID used to filter all CSV queries.</param>
/// <param name="companyName">Human-readable company name written into the metadata CSV.</param>
/// <param name="safeName">Filesystem-safe company name used in the download file name.</param>
/// <param name="ordered">Sheet names in canonical order; only listed names are included.</param>
private async Task<IActionResult> ExportAsCsv(int companyId, string companyName, string safeName, string[] ordered)
{
using var ms = new MemoryStream();
using var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true);
// Metadata entry
var meta = new StringBuilder();
meta.AppendLine("Field,Value");
meta.AppendLine($"Company,{CsvEscape(companyName)}");
meta.AppendLine($"Exported At,{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
meta.AppendLine($"Sheets,{CsvEscape(string.Join("; ", ordered))}");
WriteCsvEntry(zip, "Export_Info.csv", meta.ToString());
foreach (var sheet in ordered)
{
switch (sheet)
{
case "Customers": WriteCsvEntry(zip, "Customers.csv", await BuildCustomersCsv(companyId)); break;
case "Jobs": WriteCsvEntry(zip, "Jobs.csv", await BuildJobsCsv(companyId)); break;
case "Quotes": WriteCsvEntry(zip, "Quotes.csv", await BuildQuotesCsv(companyId)); break;
case "Invoices": WriteCsvEntry(zip, "Invoices.csv", await BuildInvoicesCsv(companyId)); break;
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
}
}
zip.Dispose();
ms.Position = 0;
var fileName = $"{safeName}_Export_{DateTime.UtcNow:yyyyMMdd}.zip";
return File(ms.ToArray(), "application/zip", fileName);
}
/// <summary>
/// Writes a single CSV string as a named entry inside an existing <see cref="ZipArchive"/>.
/// The BOM (<c>encoderShouldEmitUTF8Identifier: true</c>) is included so that Excel opens
/// the CSV with correct encoding without requiring an import wizard.
/// </summary>
/// <param name="zip">The open archive to write the entry into.</param>
/// <param name="entryName">File name for the entry inside the ZIP (e.g. "Customers.csv").</param>
/// <param name="content">The complete CSV text to write.</param>
private static void WriteCsvEntry(ZipArchive zip, string entryName, string content)
{
var entry = zip.CreateEntry(entryName, System.IO.Compression.CompressionLevel.Optimal);
using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
writer.Write(content);
}
// ── Sheet builders ───────────────────────────────────────────────────────
/// <summary>
/// Adds an "Export Info" worksheet at position 1 of the workbook containing company name,
/// export timestamp, and list of included sheets.
/// Inserting this sheet first (via <c>MoveToStart</c>) gives recipients orientation context
/// before they open the data sheets.
/// </summary>
/// <param name="pkg">The EPPlus workbook to add the sheet to.</param>
/// <param name="companyName">Company name to record in the sheet.</param>
/// <param name="sheets">Names of the data sheets included in this export.</param>
private void AddMetadataSheet(ExcelPackage pkg, string companyName, string[] sheets)
{
// Insert at position 1
var ws = pkg.Workbook.Worksheets.Add("Export Info");
pkg.Workbook.Worksheets.MoveToStart("Export Info");
ws.Cells[1, 1].Value = "Company";
ws.Cells[1, 2].Value = companyName;
ws.Cells[2, 1].Value = "Exported At";
ws.Cells[2, 2].Value = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss UTC");
ws.Cells[3, 1].Value = "Sheets";
ws.Cells[3, 2].Value = string.Join(", ", sheets);
ws.Column(1).Width = 20;
ws.Column(2).Width = 40;
ws.Cells[1, 1, 3, 1].Style.Font.Bold = true;
}
/// <summary>
/// Adds a "Customers" worksheet with one row per non-deleted customer for the specified company.
/// <c>IgnoreQueryFilters()</c> is required because the global EF filter would otherwise
/// restrict results to the SuperAdmin's own tenant context.
/// </summary>
private async Task AddCustomersSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Customers.AsNoTracking().IgnoreQueryFilters()
.Where(c => c.CompanyId == companyId && !c.IsDeleted)
.OrderBy(c => c.CompanyName)
.ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Customers");
var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone",
"Commercial", "City", "State", "Active", "Credit Limit",
"Current Balance", "Created At" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2;
var c = data[i];
ws.Cells[r, 1].Value = c.Id;
ws.Cells[r, 2].Value = c.CompanyName;
ws.Cells[r, 3].Value = c.ContactFirstName;
ws.Cells[r, 4].Value = c.ContactLastName;
ws.Cells[r, 5].Value = c.Email;
ws.Cells[r, 6].Value = c.Phone;
ws.Cells[r, 7].Value = c.IsCommercial ? "Yes" : "No";
ws.Cells[r, 8].Value = c.City;
ws.Cells[r, 9].Value = c.State;
ws.Cells[r, 10].Value = c.IsActive ? "Yes" : "No";
ws.Cells[r, 11].Value = c.CreditLimit;
ws.Cells[r, 12].Value = c.CurrentBalance;
ws.Cells[r, 13].Value = c.CreatedAt.ToString("yyyy-MM-dd");
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Jobs" worksheet with one row per non-deleted job for the specified company.
/// Job status and priority are lookup-table entities (not enums), so they must be eagerly
/// loaded via <c>Include</c>; the <c>DisplayName</c> navigation property is used for the
/// human-readable label, falling back to the raw FK integer if the navigation is null.
/// </summary>
private async Task AddJobsSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Jobs.AsNoTracking().IgnoreQueryFilters()
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority)
.OrderByDescending(j => j.CreatedAt)
.ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Jobs");
var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority",
"Description", "Due Date", "Final Price", "Created At" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2;
var j = data[i];
ws.Cells[r, 1].Value = j.Id;
ws.Cells[r, 2].Value = j.JobNumber;
ws.Cells[r, 3].Value = j.Customer?.CompanyName ?? $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim();
ws.Cells[r, 4].Value = j.JobStatus?.DisplayName ?? j.JobStatusId.ToString();
ws.Cells[r, 5].Value = j.JobPriority?.DisplayName ?? j.JobPriorityId.ToString();
ws.Cells[r, 6].Value = j.Description;
ws.Cells[r, 7].Value = j.DueDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 8].Value = j.FinalPrice;
ws.Cells[r, 9].Value = j.CreatedAt.ToString("yyyy-MM-dd");
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Quotes" worksheet with one row per non-deleted quote for the specified company.
/// Quotes can belong to prospects (no <c>CustomerId</c>) or linked customers; the customer/prospect
/// column shows <c>ProspectCompanyName</c> when set, otherwise falls back to the customer FK.
/// Quote status is a lookup-table entity and is eagerly loaded so <c>DisplayName</c> is available.
/// </summary>
private async Task AddQuotesSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Quotes.AsNoTracking().IgnoreQueryFilters()
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
.Include(q => q.QuoteStatus)
.OrderByDescending(q => q.QuoteDate)
.ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Quotes");
var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status",
"Quote Date", "Expiration Date", "Subtotal", "Tax", "Total" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2;
var q = data[i];
ws.Cells[r, 1].Value = q.Id;
ws.Cells[r, 2].Value = q.QuoteNumber;
ws.Cells[r, 3].Value = string.IsNullOrEmpty(q.ProspectCompanyName) ? $"Customer #{q.CustomerId}" : q.ProspectCompanyName;
ws.Cells[r, 4].Value = q.QuoteStatus?.DisplayName ?? q.QuoteStatusId.ToString();
ws.Cells[r, 5].Value = q.QuoteDate.ToString("yyyy-MM-dd");
ws.Cells[r, 6].Value = q.ExpirationDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 7].Value = q.SubTotal;
ws.Cells[r, 8].Value = q.TaxAmount;
ws.Cells[r, 9].Value = q.Total;
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds an "Inventory" worksheet with one row per non-deleted inventory item for the
/// specified company, ordered alphabetically by name for easy scanning.
/// </summary>
private async Task AddInventorySheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.InventoryItems.AsNoTracking().IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
.OrderBy(i => i.Name)
.ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Inventory");
var headers = new[] { "ID", "Name", "SKU", "Category", "Qty on Hand",
"Unit", "Unit Cost", "Reorder Point", "Manufacturer", "Color" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2;
var inv = data[i];
ws.Cells[r, 1].Value = inv.Id;
ws.Cells[r, 2].Value = inv.Name;
ws.Cells[r, 3].Value = inv.SKU;
ws.Cells[r, 4].Value = inv.Category;
ws.Cells[r, 5].Value = inv.QuantityOnHand;
ws.Cells[r, 6].Value = inv.UnitOfMeasure;
ws.Cells[r, 7].Value = inv.UnitCost;
ws.Cells[r, 8].Value = inv.ReorderPoint;
ws.Cells[r, 9].Value = inv.Manufacturer;
ws.Cells[r, 10].Value = inv.ColorName;
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds an "Equipment" worksheet with one row per non-deleted equipment record for the
/// specified company. Equipment status is stored as an enum on the entity and is
/// serialized via <c>ToString()</c> for a human-readable string in the export.
/// </summary>
private async Task AddEquipmentSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Equipment.AsNoTracking().IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId && !e.IsDeleted)
.OrderBy(e => e.EquipmentName)
.ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Equipment");
var headers = new[] { "ID", "Name", "Type", "Serial Number", "Model",
"Status", "Purchase Date", "Purchase Price", "Next Maintenance" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2;
var e = data[i];
ws.Cells[r, 1].Value = e.Id;
ws.Cells[r, 2].Value = e.EquipmentName;
ws.Cells[r, 3].Value = e.EquipmentType;
ws.Cells[r, 4].Value = e.SerialNumber;
ws.Cells[r, 5].Value = e.Model;
ws.Cells[r, 6].Value = e.Status.ToString();
ws.Cells[r, 7].Value = e.PurchaseDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 8].Value = e.PurchasePrice;
ws.Cells[r, 9].Value = e.NextScheduledMaintenance?.ToString("yyyy-MM-dd");
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Vendors" worksheet with one row per non-deleted vendor (supplier) for the
/// specified company, ordered alphabetically by company name.
/// </summary>
private async Task AddVendorsSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Vendors.AsNoTracking().IgnoreQueryFilters()
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
.OrderBy(s => s.CompanyName)
.ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Vendors");
var headers = new[] { "ID", "Company Name", "Contact", "Email", "Phone",
"City", "State", "Preferred", "Active" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2;
var s = data[i];
ws.Cells[r, 1].Value = s.Id;
ws.Cells[r, 2].Value = s.CompanyName;
ws.Cells[r, 3].Value = s.ContactName;
ws.Cells[r, 4].Value = s.Email;
ws.Cells[r, 5].Value = s.Phone;
ws.Cells[r, 6].Value = s.City;
ws.Cells[r, 7].Value = s.State;
ws.Cells[r, 8].Value = s.IsPreferred ? "Yes" : "No";
ws.Cells[r, 9].Value = s.IsActive ? "Yes" : "No";
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Shop Workers" worksheet with one row per non-deleted shop worker for the
/// specified company. <c>Role.ToString()</c> converts the enum to a string; the view
/// typically formats these with spaces (e.g. "QualityControl" → "Quality Control") but the
/// raw enum name is used here so the export value is round-trip parseable.
/// </summary>
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
.OrderBy(w => w.Name)
.ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2;
var w = data[i];
ws.Cells[r, 1].Value = w.Id;
ws.Cells[r, 2].Value = w.Name;
ws.Cells[r, 3].Value = w.Role.ToString();
ws.Cells[r, 4].Value = w.Phone;
ws.Cells[r, 5].Value = w.Email;
ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
ws.Cells[r, 7].Value = w.Notes;
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds an "Invoices" worksheet with one row per non-deleted invoice for the specified company.
/// The customer navigation is eagerly loaded so the customer name can be rendered; when a
/// customer record is missing (data anomaly), the FK integer is shown as a fallback.
/// <c>BalanceDue</c> is a computed property (<c>Total - AmountPaid</c>) that reflects partial
/// payment status without requiring an additional query.
/// </summary>
private async Task AddInvoicesSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Invoices.AsNoTracking().IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
.Include(i => i.Customer)
.OrderByDescending(i => i.InvoiceDate)
.ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Invoices");
var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date",
"Due Date", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2;
var inv = data[i];
var customerName = inv.Customer != null
? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim())
: $"Customer #{inv.CustomerId}";
ws.Cells[r, 1].Value = inv.Id;
ws.Cells[r, 2].Value = inv.InvoiceNumber;
ws.Cells[r, 3].Value = customerName;
ws.Cells[r, 4].Value = inv.Status.ToString();
ws.Cells[r, 5].Value = inv.InvoiceDate.ToString("yyyy-MM-dd");
ws.Cells[r, 6].Value = inv.DueDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 7].Value = inv.SubTotal;
ws.Cells[r, 8].Value = inv.TaxAmount;
ws.Cells[r, 9].Value = inv.Total;
ws.Cells[r, 10].Value = inv.AmountPaid;
ws.Cells[r, 11].Value = inv.BalanceDue;
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Users" worksheet with one row per user belonging to the specified company.
/// Unlike most other sheet builders this query does NOT add <c>&amp;&amp; !c.IsDeleted</c>
/// because ASP.NET Identity users are soft-deleted by setting <c>IsActive = false</c>
/// rather than the standard <c>IsDeleted</c> flag; all users, active or inactive, are included
/// so the tenant has a full record for compliance/audit purposes.
/// </summary>
private async Task AddUsersSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.Users.AsNoTracking().IgnoreQueryFilters()
.Where(u => u.CompanyId == companyId)
.OrderBy(u => u.LastName)
.ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Users");
var headers = new[] { "ID", "First Name", "Last Name", "Email", "Role",
"Active", "Hire Date", "Last Login", "Created At" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2;
var u = data[i];
ws.Cells[r, 1].Value = u.Id;
ws.Cells[r, 2].Value = u.FirstName;
ws.Cells[r, 3].Value = u.LastName;
ws.Cells[r, 4].Value = u.Email;
ws.Cells[r, 5].Value = u.CompanyRole;
ws.Cells[r, 6].Value = u.IsActive ? "Yes" : "No";
ws.Cells[r, 7].Value = u.HireDate.ToString("yyyy-MM-dd");
ws.Cells[r, 8].Value = u.LastLoginDate?.ToString("yyyy-MM-dd") ?? "Never";
ws.Cells[r, 9].Value = u.CreatedAt.ToString("yyyy-MM-dd");
}
AutoFit(ws, headers.Length);
}
// ── CSV builders ─────────────────────────────────────────────────────────
/// <summary>
/// Builds the customers CSV string for the specified company.
/// Column names match <see cref="CustomerImportDto"/> exactly so the file can be
/// re-imported via Tools → Bulk Import without any manual header editing.
/// </summary>
private async Task<string> BuildCustomersCsv(int companyId)
{
var data = await _db.Customers.AsNoTracking().IgnoreQueryFilters()
.Include(c => c.PricingTier)
.Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes");
foreach (var c in data)
{
var customerType = c.IsCommercial ? "Commercial" : "Non-Commercial";
sb.AppendLine($"{CsvEscape(c.CompanyName)},{CsvEscape(c.ContactFirstName)},{CsvEscape(c.ContactLastName)},{CsvEscape(c.Email)},{CsvEscape(c.Phone)},{CsvEscape(c.MobilePhone)},{CsvEscape(c.Address)},{CsvEscape(c.City)},{CsvEscape(c.State)},{CsvEscape(c.ZipCode)},{CsvEscape(c.Country)},{customerType},{CsvEscape(c.PricingTier?.TierName)},{c.CreditLimit},{CsvEscape(c.PaymentTerms)},{c.IsTaxExempt.ToString().ToLower()},{CsvEscape(c.TaxId)},{c.IsActive.ToString().ToLower()},{CsvEscape(c.GeneralNotes)}");
}
return sb.ToString();
}
/// <summary>
/// Builds the jobs CSV string for the specified company.
/// Column names match <see cref="JobImportDto"/> exactly so the file can be re-imported.
/// CustomerEmail is included (not the display name) because the importer resolves
/// the customer FK by email lookup.
/// </summary>
private async Task<string> BuildJobsCsv(int companyId)
{
var data = await _db.Jobs.AsNoTracking().IgnoreQueryFilters()
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
.OrderByDescending(j => j.CreatedAt).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,FinalPrice,CustomerPO,SpecialInstructions,Notes");
foreach (var j in data)
{
var customerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
? j.Customer.CompanyName
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim();
sb.AppendLine($"{CsvEscape(j.JobNumber)},{CsvEscape(j.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(j.JobStatus?.DisplayName)},{CsvEscape(j.JobPriority?.DisplayName)},{j.ScheduledDate?.ToString("yyyy-MM-dd")},{j.DueDate?.ToString("yyyy-MM-dd")},{j.FinalPrice},{CsvEscape(j.CustomerPO)},{CsvEscape(j.SpecialInstructions)},{CsvEscape(j.InternalNotes)}");
}
return sb.ToString();
}
/// <summary>
/// Builds the quotes CSV string for the specified company.
/// Column names match <see cref="QuoteImportDto"/> exactly so the file can be re-imported.
/// Customer-linked quotes use CustomerEmail; prospect quotes use the ProspectCompany/Contact/Email/Phone fields.
/// </summary>
private async Task<string> BuildQuotesCsv(int companyId)
{
var data = await _db.Quotes.AsNoTracking().IgnoreQueryFilters()
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
.Include(q => q.Customer).Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,Subtotal,TaxAmount,Total,Notes,TermsAndConditions");
foreach (var q in data)
{
var customerName = !string.IsNullOrWhiteSpace(q.Customer?.CompanyName)
? q.Customer.CompanyName
: $"{q.Customer?.ContactFirstName} {q.Customer?.ContactLastName}".Trim();
sb.AppendLine($"{CsvEscape(q.QuoteNumber)},{CsvEscape(q.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(q.ProspectCompanyName)},{CsvEscape(q.ProspectContactName)},{CsvEscape(q.ProspectEmail)},{CsvEscape(q.ProspectPhone)},{CsvEscape(q.QuoteStatus?.DisplayName)},{q.QuoteDate:yyyy-MM-dd},{q.ExpirationDate?.ToString("yyyy-MM-dd")},{q.SubTotal},{q.TaxAmount},{q.Total},{CsvEscape(q.Notes)},{CsvEscape(q.Terms)}");
}
return sb.ToString();
}
/// <summary>
/// Builds the invoices CSV string for the specified company.
/// Eagerly loads <c>Customer</c> so the customer name is available without a second query.
/// When a customer record cannot be found the FK integer is used as a fallback.
/// </summary>
private async Task<string> BuildInvoicesCsv(int companyId)
{
var data = await _db.Invoices.AsNoTracking().IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
.Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Subtotal,Tax,Total,Amount Paid,Balance Due");
foreach (var inv in data)
{
var cust = inv.Customer != null
? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim())
: $"Customer #{inv.CustomerId}";
sb.AppendLine($"{inv.Id},{CsvEscape(inv.InvoiceNumber)},{CsvEscape(cust)},{inv.Status},{inv.InvoiceDate:yyyy-MM-dd},{inv.DueDate?.ToString("yyyy-MM-dd")},{inv.SubTotal},{inv.TaxAmount},{inv.Total},{inv.AmountPaid},{inv.BalanceDue}");
}
return sb.ToString();
}
/// <summary>
/// Builds the inventory CSV string for the specified company, ordered alphabetically by name.
/// Column names match <see cref="InventoryItemImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildInventoryCsv(int companyId)
{
var data = await _db.InventoryItems.AsNoTracking().IgnoreQueryFilters()
.Include(i => i.PrimaryVendor)
.Where(i => i.CompanyId == companyId && !i.IsDeleted).OrderBy(i => i.Name).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("SKU,ItemName,Description,CategoryName,Manufacturer,ManufacturerPartNumber,ColorName,ColorCode,Finish,VendorName,VendorPartNumber,QuantityInStock,UnitOfMeasure,UnitCost,LastPurchasePrice,ReorderPoint,ReorderQuantity,MinimumStock,MaximumStock,CoverageSqFtPerLb,TransferEfficiencyPct,Location,IsActive,Notes");
foreach (var i in data)
sb.AppendLine($"{CsvEscape(i.SKU)},{CsvEscape(i.Name)},{CsvEscape(i.Description)},{CsvEscape(i.Category)},{CsvEscape(i.Manufacturer)},{CsvEscape(i.ManufacturerPartNumber)},{CsvEscape(i.ColorName)},{CsvEscape(i.ColorCode)},{CsvEscape(i.Finish)},{CsvEscape(i.PrimaryVendor?.CompanyName)},{CsvEscape(i.VendorPartNumber)},{i.QuantityOnHand},{CsvEscape(i.UnitOfMeasure)},{i.UnitCost},{i.LastPurchasePrice},{i.ReorderPoint},{i.ReorderQuantity},{i.MinimumStock},{i.MaximumStock},{i.CoverageSqFtPerLb},{i.TransferEfficiency},{i.Location},{i.IsActive.ToString().ToLower()},{CsvEscape(i.Notes)}");
return sb.ToString();
}
/// <summary>
/// Builds the equipment CSV string for the specified company, ordered alphabetically by name.
/// Column names match <see cref="EquipmentImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildEquipmentCsv(int companyId)
{
var data = await _db.Equipment.AsNoTracking().IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId && !e.IsDeleted).OrderBy(e => e.EquipmentName).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("EquipmentName,EquipmentNumber,EquipmentType,Manufacturer,Model,SerialNumber,PurchaseDate,PurchasePrice,WarrantyExpiration,Location,RecommendedMaintenanceIntervalDays,Status,IsActive,Notes");
foreach (var e in data)
sb.AppendLine($"{CsvEscape(e.EquipmentName)},{CsvEscape(e.EquipmentNumber)},{CsvEscape(e.EquipmentType)},{CsvEscape(e.Manufacturer)},{CsvEscape(e.Model)},{CsvEscape(e.SerialNumber)},{e.PurchaseDate?.ToString("yyyy-MM-dd")},{e.PurchasePrice},{e.WarrantyExpiration?.ToString("yyyy-MM-dd")},{CsvEscape(e.Location)},{e.RecommendedMaintenanceIntervalDays},{e.Status},{e.IsActive.ToString().ToLower()},{CsvEscape(e.Notes)}");
return sb.ToString();
}
/// <summary>
/// Builds the vendors CSV string for the specified company, ordered alphabetically by company name.
/// Column names match <see cref="VendorImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildVendorsCsv(int companyId)
{
var data = await _db.Vendors.AsNoTracking().IgnoreQueryFilters()
.Where(s => s.CompanyId == companyId && !s.IsDeleted).OrderBy(s => s.CompanyName).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("CompanyName,ContactName,Email,Phone,Address,City,State,ZipCode,Country,Website,AccountNumber,TaxId,PaymentTerms,CreditLimit,IsPreferred,IsActive,Notes");
foreach (var s in data)
sb.AppendLine($"{CsvEscape(s.CompanyName)},{CsvEscape(s.ContactName)},{CsvEscape(s.Email)},{CsvEscape(s.Phone)},{CsvEscape(s.Address)},{CsvEscape(s.City)},{CsvEscape(s.State)},{CsvEscape(s.ZipCode)},{CsvEscape(s.Country)},{CsvEscape(s.Website)},{CsvEscape(s.AccountNumber)},{CsvEscape(s.TaxId)},{CsvEscape(s.PaymentTerms)},{s.CreditLimit},{s.IsPreferred.ToString().ToLower()},{s.IsActive.ToString().ToLower()},{CsvEscape(s.Notes)}");
return sb.ToString();
}
/// <summary>
/// Builds the shop workers CSV string for the specified company, ordered alphabetically by name.
/// Column names match <see cref="ShopWorkerImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildShopWorkersCsv(int companyId)
{
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
foreach (var w in data)
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
return sb.ToString();
}
/// <summary>
/// Builds the users CSV string for the specified company, ordered by last name.
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> filter is intentionally omitted
/// because Identity users use <c>IsActive</c> for soft-deletion, not the <c>IsDeleted</c> flag.
/// </summary>
private async Task<string> BuildUsersCsv(int companyId)
{
var data = await _db.Users.AsNoTracking().IgnoreQueryFilters()
.Where(u => u.CompanyId == companyId).OrderBy(u => u.LastName).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("ID,First Name,Last Name,Email,Role,Active,Hire Date,Last Login,Created At");
foreach (var u in data)
sb.AppendLine($"{CsvEscape(u.Id)},{CsvEscape(u.FirstName)},{CsvEscape(u.LastName)},{CsvEscape(u.Email)},{CsvEscape(u.CompanyRole)},{(u.IsActive ? "Yes" : "No")},{u.HireDate:yyyy-MM-dd},{u.LastLoginDate?.ToString("yyyy-MM-dd") ?? "Never"},{u.CreatedAt:yyyy-MM-dd}");
return sb.ToString();
}
// ── Utilities ────────────────────────────────────────────────────────────
/// <summary>
/// Writes a styled header row to the first row of an EPPlus worksheet using the specified
/// column labels and background color. White bold font on a dark background provides high
/// contrast and visually separates headers from data in exported workbooks.
/// </summary>
/// <param name="ws">The worksheet to write headers into (always row 1).</param>
/// <param name="headers">Ordered column header labels.</param>
/// <param name="bgColor">Background fill color for the header row.</param>
private static void WriteHeader(ExcelWorksheet ws, string[] headers, Color bgColor)
{
for (int c = 0; c < headers.Length; c++)
{
var cell = ws.Cells[1, c + 1];
cell.Value = headers[c];
cell.Style.Font.Bold = true;
cell.Style.Font.Color.SetColor(Color.White);
cell.Style.Fill.PatternType = ExcelFillStyle.Solid;
cell.Style.Fill.BackgroundColor.SetColor(bgColor);
}
}
/// <summary>
/// Auto-fits all columns in the worksheet to their content width, then caps each column at
/// 50 characters to prevent excessively wide columns caused by long free-text fields
/// (e.g., job descriptions or notes).
/// </summary>
/// <param name="ws">The worksheet to auto-fit.</param>
/// <param name="colCount">Total number of data columns, used for the width-cap loop.</param>
private static void AutoFit(ExcelWorksheet ws, int colCount)
{
ws.Cells[ws.Dimension?.Address ?? "A1"].AutoFitColumns();
// Cap column width
for (int c = 1; c <= colCount; c++)
{
if (ws.Column(c).Width > 50)
ws.Column(c).Width = 50;
}
}
/// <summary>
/// Returns the requested sheet names sorted into the canonical export order
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → ShopWorkers → Users).
/// This ensures that the workbook and ZIP archive always have a predictable, logical layout
/// regardless of the order the administrator checked the boxes on the form.
/// Any sheet name not in the canonical list is silently ignored.
/// </summary>
/// <param name="sheets">Raw sheet names from the form POST.</param>
private static string[] OrderSheets(string[] sheets)
{
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" };
return order.Where(sheets.Contains).ToArray();
}
/// <summary>
/// RFC 4180-compliant CSV field escaper. Wraps the value in double-quotes and escapes
/// embedded double-quotes (by doubling them) whenever the value contains a comma,
/// double-quote, carriage return, or line feed. Returns an empty string for null values
/// so CSV rows never contain bare null literals.
/// </summary>
/// <param name="value">The field value to escape; may be null or any object type.</param>
private static string CsvEscape(object? value)
{
if (value == null) return "";
var s = value.ToString() ?? "";
if (s.Contains(',') || s.Contains('"') || s.Contains('\n') || s.Contains('\r'))
return $"\"{s.Replace("\"", "\"\"")}\"";
return s;
}
}
// ── DTOs ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Lightweight projection used on the export index page to list available tenant companies
/// without loading every column from the Companies table.
/// </summary>
public class CompanyExportSummary
{
public int Id { get; set; }
public string CompanyName { get; set; } = "";
public string Plan { get; set; } = "";
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,385 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Text;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class DataPurgeController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IJobPhotoService _jobPhotoService;
private readonly ILogger<DataPurgeController> _logger;
public DataPurgeController(
ApplicationDbContext db,
IJobPhotoService jobPhotoService,
ILogger<DataPurgeController> logger)
{
_db = db;
_jobPhotoService = jobPhotoService;
_logger = logger;
}
// ── GET: Index ───────────────────────────────────────────────────────────
/// <summary>
/// Renders the data-purge dashboard showing, per entity type, how many
/// soft-deleted records are waiting to be permanently removed and how they
/// are distributed across age buckets (030 days, 3090 days, 90+ days).
/// Restricted to SuperAdmin only.
/// <para>
/// Stats are built by <see cref="BuildStatsAsync"/>. The age buckets help
/// administrators understand whether a purge operation will reclaim a lot of
/// space or only a handful of old records, and inform a sensible
/// <c>olderThanDays</c> cutoff before executing.
/// </para>
/// </summary>
public async Task<IActionResult> Index()
{
var stats = await BuildStatsAsync();
return View(stats);
}
// ── POST: Preview (AJAX) ─────────────────────────────────────────────────
/// <summary>
/// Returns a JSON preview of how many records would be permanently deleted if
/// <see cref="Execute"/> were called with the same parameters, without making any
/// changes to the database.
/// <para>
/// Called from the purge UI before the user confirms the destructive action,
/// allowing them to see the exact impact (record count and oldest deletion date)
/// for each selected entity type. The minimum cutoff is clamped to 1 day to
/// prevent accidental purges of records deleted moments ago.
/// </para>
/// </summary>
[HttpPost]
public async Task<IActionResult> Preview([FromBody] PurgeRequest req)
{
if (req.OlderThanDays < 1) req.OlderThanDays = 30;
var cutoff = DateTime.UtcNow.AddDays(-req.OlderThanDays);
var results = new List<object>();
foreach (var entity in req.Entities ?? [])
{
var (count, oldest) = await CountDeletableAsync(entity, cutoff);
results.Add(new { entity, count, oldest = oldest?.ToString("yyyy-MM-dd") });
}
return Json(results);
}
// ── POST: Execute ────────────────────────────────────────────────────────
/// <summary>
/// Permanently (hard) deletes all soft-deleted records of the selected entity
/// types that were deleted more than <paramref name="olderThanDays"/> days ago.
/// This operation is irreversible and restricted to SuperAdmin.
/// <para>
/// Key design decisions:
/// <list type="bullet">
/// <item>Entity types are processed in child-before-parent order (determined by
/// <see cref="OrderForDeletion"/>) to honour foreign-key constraints without
/// temporarily disabling them.</item>
/// <item><c>JobPhotos</c> are purged via <see cref="PurgeEntityAsync"/> which
/// calls <c>IJobPhotoService.DeleteJobPhotoAsync</c> to also remove the
/// corresponding files from storage before the DB row is deleted.</item>
/// <item>All DB deletions are batched into a single <c>SaveChangesAsync()</c>
/// call at the end (rather than per entity) for performance; this means
/// if <c>SaveChanges</c> throws, no records are deleted.</item>
/// <item>The operation is logged at <c>Warning</c> level with the full entity
/// breakdown so there is a permanent audit trail in the Serilog log files.</item>
/// </list>
/// </para>
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Execute(int olderThanDays, string[] entities)
{
if (olderThanDays < 1) olderThanDays = 30;
var cutoff = DateTime.UtcNow.AddDays(-olderThanDays);
var totalDeleted = 0;
var log = new StringBuilder();
// Children must be deleted before parents to respect FK constraints
var ordered = OrderForDeletion(entities);
foreach (var entity in ordered)
{
var deleted = await PurgeEntityAsync(entity, cutoff, log);
totalDeleted += deleted;
}
if (totalDeleted > 0)
await _db.SaveChangesAsync();
_logger.LogWarning("DataPurge by {User}: {Total} records permanently deleted (>{Days}d old). {Detail}",
User.Identity?.Name, totalDeleted, olderThanDays, log);
TempData["PurgeSuccess"] = $"Permanently deleted {totalDeleted:N0} records older than {olderThanDays} days.";
TempData["PurgeDetail"] = log.ToString();
return RedirectToAction(nameof(Index));
}
// ── Helpers: Stats ───────────────────────────────────────────────────────
/// <summary>
/// Builds the full list of <see cref="EntityPurgeStat"/> entries shown on the
/// dashboard Index page by querying each tracked entity type.
/// <para>
/// Uses a local <c>Stat</c> helper function with an <c>EF.Property</c> shadow
/// property accessor for <c>DeletedAt</c> so the age buckets can be computed in
/// a single DB round-trip per entity type without requiring a concrete CLR type
/// for each query. All queries use <c>IgnoreQueryFilters()</c> because the
/// purpose here is to count records that the global soft-delete filter would
/// normally hide.
/// </para>
/// </summary>
private async Task<List<EntityPurgeStat>> BuildStatsAsync()
{
var now = DateTime.UtcNow;
var stats = new List<EntityPurgeStat>();
async Task<EntityPurgeStat> Stat<T>(string name, string label, string icon, string group,
IQueryable<T> baseQuery) where T : class
{
var q = baseQuery.AsNoTracking().IgnoreQueryFilters();
var total = await q.CountAsync();
var d30 = await q.CountAsync(x => EF.Property<DateTime?>(x, "DeletedAt") >= now.AddDays(-30));
var d90 = await q.CountAsync(x => EF.Property<DateTime?>(x, "DeletedAt") < now.AddDays(-30)
&& EF.Property<DateTime?>(x, "DeletedAt") >= now.AddDays(-90));
var old = await q.CountAsync(x => EF.Property<DateTime?>(x, "DeletedAt") < now.AddDays(-90));
var oldest = total > 0
? await q.MinAsync(x => EF.Property<DateTime?>(x, "DeletedAt"))
: null;
return new EntityPurgeStat(name, label, icon, group, total, d30, d90, old, oldest);
}
// Jobs & Customers group
stats.Add(await Stat("Customers", "Customers", "bi-people", "Jobs & CRM", _db.Customers.Where(e => e.IsDeleted)));
stats.Add(await Stat("CustomerNotes", "Customer Notes", "bi-chat-left-text", "Jobs & CRM", _db.CustomerNotes.Where(e => e.IsDeleted)));
stats.Add(await Stat("Jobs", "Jobs", "bi-briefcase", "Jobs & CRM", _db.Jobs.Where(e => e.IsDeleted)));
stats.Add(await Stat("JobItems", "Job Items", "bi-list-ul", "Jobs & CRM", _db.JobItems.Where(e => e.IsDeleted)));
stats.Add(await Stat("JobPhotos", "Job Photos", "bi-camera", "Jobs & CRM", _db.JobPhotos.Where(e => e.IsDeleted)));
stats.Add(await Stat("JobNotes", "Job Notes", "bi-sticky", "Jobs & CRM", _db.JobNotes.Where(e => e.IsDeleted)));
// Quotes group
stats.Add(await Stat("Quotes", "Quotes", "bi-file-earmark-text", "Quotes", _db.Quotes.Where(e => e.IsDeleted)));
stats.Add(await Stat("QuoteItems", "Quote Items", "bi-list-check", "Quotes", _db.QuoteItems.Where(e => e.IsDeleted)));
// Inventory & Operations group
stats.Add(await Stat("InventoryItems", "Inventory Items", "bi-boxes", "Inventory & Ops", _db.InventoryItems.Where(e => e.IsDeleted)));
stats.Add(await Stat("Equipment", "Equipment", "bi-tools", "Inventory & Ops", _db.Equipment.Where(e => e.IsDeleted)));
stats.Add(await Stat("MaintenanceRecords","Maintenance Records", "bi-wrench", "Inventory & Ops", _db.MaintenanceRecords.Where(e => e.IsDeleted)));
stats.Add(await Stat("Vendors", "Vendors", "bi-truck", "Inventory & Ops", _db.Vendors.Where(e => e.IsDeleted)));
stats.Add(await Stat("ShopWorkers", "Shop Workers", "bi-person-badge","Inventory & Ops", _db.ShopWorkers.Where(e => e.IsDeleted)));
return stats;
}
/// <summary>
/// Returns the number of soft-deleted records of the given <paramref name="entity"/>
/// type that were deleted on or before <paramref name="cutoff"/>, together with the
/// earliest deletion timestamp in that set.
/// Used by <see cref="Preview"/> to estimate purge impact without committing changes.
/// Delegates to the generic <see cref="QueryCount{T}"/> helper for each known type;
/// unknown entity names safely return (0, null).
/// </summary>
private async Task<(int count, DateTime? oldest)> CountDeletableAsync(string entity, DateTime cutoff)
{
return entity switch
{
"Customers" => await QueryCount(_db.Customers, cutoff),
"CustomerNotes" => await QueryCount(_db.CustomerNotes, cutoff),
"Jobs" => await QueryCount(_db.Jobs, cutoff),
"JobItems" => await QueryCount(_db.JobItems, cutoff),
"JobPhotos" => await QueryCount(_db.JobPhotos, cutoff),
"JobNotes" => await QueryCount(_db.JobNotes, cutoff),
"Quotes" => await QueryCount(_db.Quotes, cutoff),
"QuoteItems" => await QueryCount(_db.QuoteItems, cutoff),
"InventoryItems" => await QueryCount(_db.InventoryItems, cutoff),
"Equipment" => await QueryCount(_db.Equipment, cutoff),
"MaintenanceRecords" => await QueryCount(_db.MaintenanceRecords, cutoff),
"Vendors" => await QueryCount(_db.Vendors, cutoff),
"ShopWorkers" => await QueryCount(_db.ShopWorkers, cutoff),
_ => (0, null)
};
}
/// <summary>
/// Generic helper that counts soft-deleted rows in <paramref name="set"/> whose
/// <c>DeletedAt</c> shadow property is at or before <paramref name="cutoff"/>,
/// and returns the minimum (oldest) deletion timestamp in the matching set.
/// <para>
/// <c>EF.Property&lt;bool&gt;(e, "IsDeleted")</c> and
/// <c>EF.Property&lt;DateTime?&gt;(e, "DeletedAt")</c> are used instead of
/// interface casts so this method works with any entity type that has those
/// shadow/column properties, without requiring all entities to implement a
/// common interface. <c>IgnoreQueryFilters()</c> is applied because the caller
/// already passes a pre-filtered <c>IQueryable</c> from the DbSet (e.g.
/// <c>_db.Customers.Where(e =&gt; e.IsDeleted)</c>).
/// </para>
/// </summary>
private static async Task<(int, DateTime?)> QueryCount<T>(IQueryable<T> set, DateTime cutoff) where T : class
{
var q = set.AsNoTracking().IgnoreQueryFilters()
.Where(e => EF.Property<bool>(e, "IsDeleted")
&& EF.Property<DateTime?>(e, "DeletedAt") <= cutoff);
var count = await q.CountAsync();
var oldest = count > 0 ? await q.MinAsync(e => EF.Property<DateTime?>(e, "DeletedAt")) : null;
return (count, oldest);
}
// ── Helpers: Purge ───────────────────────────────────────────────────────
/// <summary>
/// Permanently removes soft-deleted records of the given <paramref name="entity"/>
/// type that are older than <paramref name="cutoff"/> and appends a summary line
/// to <paramref name="log"/> for audit purposes.
/// <para>
/// <c>JobPhotos</c> require a two-step process: the physical files are deleted from
/// storage via <c>IJobPhotoService</c> before the DB rows are removed, so they use
/// <c>RemoveRange</c> with a materialised list rather than <c>ExecuteDeleteAsync</c>.
/// All other entity types use <c>ExecuteDeleteAsync</c> (EF bulk delete) for
/// performance — no change-tracker involvement, direct SQL DELETE statement.
/// Note: <c>ExecuteDeleteAsync</c> does not call <c>SaveChanges</c>; the caller
/// (<see cref="Execute"/>) issues a single <c>SaveChangesAsync</c> at the end.
/// However, <c>ExecuteDeleteAsync</c> executes immediately on the database, so
/// the final <c>SaveChanges</c> is only needed to flush the <c>RemoveRange</c>
/// changes for JobPhotos.
/// </para>
/// </summary>
private async Task<int> PurgeEntityAsync(string entity, DateTime cutoff, StringBuilder log)
{
int count;
switch (entity)
{
case "JobPhotos":
var photos = await _db.JobPhotos.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ToListAsync();
foreach (var p in photos)
{
if (!string.IsNullOrEmpty(p.FilePath))
await _jobPhotoService.DeleteJobPhotoAsync(p.FilePath);
}
_db.JobPhotos.RemoveRange(photos);
count = photos.Count;
break;
case "JobItems":
count = await _db.JobItems.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "JobNotes":
count = await _db.JobNotes.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "CustomerNotes":
count = await _db.CustomerNotes.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "QuoteItems":
count = await _db.QuoteItems.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "Customers":
count = await _db.Customers.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "Jobs":
count = await _db.Jobs.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "Quotes":
count = await _db.Quotes.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "InventoryItems":
count = await _db.InventoryItems.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "Equipment":
count = await _db.Equipment.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "MaintenanceRecords":
count = await _db.MaintenanceRecords.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "Vendors":
count = await _db.Vendors.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "ShopWorkers":
count = await _db.ShopWorkers.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
default:
return 0;
}
if (count > 0)
log.AppendLine($" {entity}: {count} records");
return count;
}
/// <summary>
/// Filters and reorders the caller-supplied entity names so that child entities
/// always appear before their parent entities, satisfying SQL foreign-key
/// constraints during bulk deletion.
/// <para>
/// Example: <c>JobItems</c> and <c>JobPhotos</c> must be deleted before
/// <c>Jobs</c>; <c>QuoteItems</c> before <c>Quotes</c>;
/// <c>CustomerNotes</c> before <c>Customers</c>. Any entity name not in the
/// master order array (i.e. unknown / unsupported) is silently dropped.
/// </para>
/// </summary>
private static string[] OrderForDeletion(string[] entities)
{
// Children before parents to respect FK constraints
var order = new[] {
"JobPhotos", "JobNotes", "JobItems",
"CustomerNotes",
"QuoteItems",
"MaintenanceRecords",
"Jobs", "Customers", "Quotes",
"InventoryItems", "Equipment",
"Vendors", "ShopWorkers"
};
return order.Where(entities.Contains).ToArray();
}
}
// ── DTOs ─────────────────────────────────────────────────────────────────────
public record EntityPurgeStat(
string EntityName,
string Label,
string Icon,
string Group,
int Total,
int DeletedLast30Days,
int Deleted30To90Days,
int DeletedOlderThan90Days,
DateTime? OldestDeletion);
public class PurgeRequest
{
public int OlderThanDays { get; set; } = 90;
public string[]? Entities { get; set; }
}
@@ -0,0 +1,420 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Company;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public class DepositsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<DepositsController> _logger;
private readonly ApplicationDbContext _context;
public DepositsController(
IUnitOfWork unitOfWork,
UserManager<ApplicationUser> userManager,
ILogger<DepositsController> logger,
ApplicationDbContext context)
{
_unitOfWork = unitOfWork;
_userManager = userManager;
_logger = logger;
_context = context;
}
// -----------------------------------------------------------------------
// POST: /Deposits/Record — AJAX call from Job/Quote Details modal
// -----------------------------------------------------------------------
/// <summary>
/// Records a customer deposit via AJAX from the Job Details or Quote Details modal. A deposit
/// must be linked to at least one of <paramref name="jobId"/> or <paramref name="quoteId"/>
/// so it can be matched to a specific obligation. The deposit is stored as unapplied
/// (<c>AppliedToInvoiceId</c> is null) until <c>InvoicesController</c> auto-applies it when
/// creating the invoice, at which point <c>AppliedToInvoiceId</c> is set and the deposit can
/// no longer be deleted. Returns a JSON object so the view's JavaScript can append the new
/// row to the deposits table without a full page reload.
/// A receipt number in the format <c>DEP-YYMM-####</c> is generated via
/// <see cref="GenerateReceiptNumberAsync"/> using <c>IgnoreQueryFilters</c> to prevent
/// number reuse after soft deletion.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Record(
int? jobId,
int? quoteId,
int customerId,
decimal amount,
string paymentMethod,
DateTime receivedDate,
string? reference,
string? notes)
{
try
{
if (amount <= 0)
return Json(new { success = false, message = "Amount must be greater than zero." });
if (jobId == null && quoteId == null)
return Json(new { success = false, message = "A Job or Quote must be specified." });
if (!Enum.TryParse<PaymentMethod>(paymentMethod, out var method))
return Json(new { success = false, message = "Invalid payment method." });
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
var deposit = new Deposit
{
ReceiptNumber = receiptNumber,
CustomerId = customerId,
JobId = jobId,
QuoteId = quoteId,
Amount = amount,
PaymentMethod = method,
ReceivedDate = receivedDate,
Reference = reference,
Notes = notes,
RecordedById = currentUser.Id,
CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow,
CreatedBy = currentUser.Email
};
await _unitOfWork.Deposits.AddAsync(deposit);
await _unitOfWork.CompleteAsync();
return Json(new
{
success = true,
depositId = deposit.Id,
receiptNumber = deposit.ReceiptNumber,
amount = deposit.Amount,
paymentMethod = GetPaymentMethodDisplay(method),
receivedDate = deposit.ReceivedDate.ToString("MM/dd/yyyy"),
notes = deposit.Notes,
reference = deposit.Reference
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recording deposit for job={JobId} quote={QuoteId}", jobId, quoteId);
return Json(new { success = false, message = "An error occurred recording the deposit." });
}
}
// -----------------------------------------------------------------------
// POST: /Deposits/Delete/5
// -----------------------------------------------------------------------
/// <summary>
/// Soft-deletes a deposit via AJAX. A deposit that has already been applied to an invoice
/// (<c>AppliedToInvoiceId</c> is not null) cannot be deleted because removing it would make
/// the invoice's payment history inconsistent — the invoice <c>AmountPaid</c> field would
/// no longer match the sum of its payment records. Returns JSON so the calling page can
/// remove the row from the UI without a full page reload.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id, string? returnUrl)
{
try
{
var deposit = await _unitOfWork.Deposits.GetByIdAsync(id);
if (deposit == null) return Json(new { success = false, message = "Deposit not found." });
if (deposit.AppliedToInvoiceId != null)
return Json(new { success = false, message = "This deposit has already been applied to an invoice and cannot be deleted." });
await _unitOfWork.Deposits.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting deposit {DepositId}", id);
return Json(new { success = false, message = "An error occurred deleting the deposit." });
}
}
// -----------------------------------------------------------------------
// GET: /Deposits/Receipt/5 — PDF receipt download
// -----------------------------------------------------------------------
/// <summary>
/// Generates and streams a deposit receipt PDF inline (browser opens rather than downloads).
/// The PDF is built entirely inside this action using QuestPDF directly — deliberately NOT
/// delegated to <c>IPdfService</c> — because the deposit receipt layout is small and
/// self-contained, and adding it to the shared PDF service would require coupling the service
/// to deposit-specific entities. The receipt uses the tenant's accent colour (from
/// <c>CompanyPreferences.InAccentColor</c>) for branding; if no colour is configured it
/// falls back to <c>Colors.Blue.Darken2</c>. A company-isolation check guards against
/// cross-tenant access; SuperAdmins bypass this check.
/// </summary>
public async Task<IActionResult> Receipt(int id)
{
try
{
var deposit = await _unitOfWork.Deposits.GetByIdAsync(id, false,
d => d.Customer,
d => d.Job,
d => d.Quote);
if (deposit == null) return NotFound();
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
// Verify the deposit belongs to the user's company
if (deposit.CompanyId != currentUser.CompanyId && !User.IsInRole("SuperAdmin"))
return Forbid();
var company = await _unitOfWork.Companies.GetByIdAsync(deposit.CompanyId);
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == deposit.CompanyId && !p.IsDeleted);
var companyInfo = new CompanyInfoDto
{
CompanyName = company?.CompanyName ?? string.Empty,
Phone = company?.Phone,
Address = company?.Address,
City = company?.City,
State = company?.State,
ZipCode = company?.ZipCode,
PrimaryContactEmail = company?.PrimaryContactEmail
};
var pdfBytes = GenerateReceiptPdf(deposit, company?.LogoData, company?.LogoContentType, companyInfo, prefs?.InAccentColor);
Response.Headers["Content-Disposition"] = $"inline; filename=\"Deposit-Receipt-{deposit.ReceiptNumber}.pdf\"";
return File(pdfBytes, "application/pdf");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating receipt for deposit {DepositId}", id);
return StatusCode(500, "An error occurred generating the receipt.");
}
}
// -----------------------------------------------------------------------
// Private helpers
// -----------------------------------------------------------------------
/// <summary>
/// Generates a monotonically increasing deposit receipt number in the format
/// <c>DEP-YYMM-####</c> (e.g. <c>DEP-2604-0001</c> for April 2026). Scoped to the company
/// so each tenant has an independent sequence. Uses <c>IgnoreQueryFilters()</c> to include
/// soft-deleted records in the scan and prevent number reuse.
/// </summary>
private async Task<string> GenerateReceiptNumberAsync(int companyId)
{
var prefix = $"DEP-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
var existing = await _context.Set<Deposit>()
.IgnoreQueryFilters()
.Where(d => d.CompanyId == companyId && d.ReceiptNumber.StartsWith(prefix))
.Select(d => d.ReceiptNumber)
.ToListAsync();
var maxNum = 0;
foreach (var num in existing)
{
var suffix = num.Length >= prefix.Length + 4 ? num.Substring(prefix.Length) : "";
if (int.TryParse(suffix, out int n) && n > maxNum)
maxNum = n;
}
return $"{prefix}{(maxNum + 1):D4}";
}
/// <summary>
/// Maps a <see cref="PaymentMethod"/> enum value to the user-facing display string shown on
/// receipts and in the AJAX response. Uses exhaustive switch-expression rather than
/// <c>.ToString()</c> so that internal enum names (e.g. <c>CreditDebitCard</c>) can be
/// displayed in a more readable form (e.g. "Credit / Debit Card").
/// </summary>
private static string GetPaymentMethodDisplay(PaymentMethod method) => method switch
{
PaymentMethod.Cash => "Cash",
PaymentMethod.Check => "Check",
PaymentMethod.CreditDebitCard => "Credit / Debit Card",
PaymentMethod.BankTransferACH => "Bank Transfer / ACH",
PaymentMethod.DigitalPayment => "Digital Payment",
PaymentMethod.StoreCredit => "Store Credit",
_ => method.ToString()
};
/// <summary>
/// Builds the deposit receipt PDF bytes using QuestPDF's fluent API. The layout places the
/// company header band (with logo or text fallback, phone, and email) at the top, followed
/// by a "Received From" / "Applied To" row, a large amount box, optional notes, and a footer
/// note indicating whether the deposit is pending or has been applied to an invoice. The
/// accent colour is taken from the tenant's <c>CompanyPreferences.InAccentColor</c> hex
/// string; invalid or missing values fall back to QuestPDF's <c>Colors.Blue.Darken2</c>.
/// PDF generation is entirely synchronous (QuestPDF is not async) so this method is not async.
/// </summary>
private byte[] GenerateReceiptPdf(
Deposit deposit,
byte[]? logoData,
string? logoContentType,
CompanyInfoDto companyInfo,
string? accentHex)
{
QuestPDF.Settings.License = LicenseType.Community;
var accent = ResolveColor(accentHex, Colors.Blue.Darken2);
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Content().Column(col =>
{
// Header band
col.Item().Background(accent).Padding(16).Row(row =>
{
// Logo or company name
if (logoData != null && logoData.Length > 0)
{
row.ConstantItem(80).Image(logoData).FitArea();
row.RelativeItem().PaddingLeft(12).Column(c =>
{
c.Item().Text(companyInfo.CompanyName).FontSize(18).Bold().FontColor(Colors.White);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
c.Item().Text(companyInfo.Phone).FontSize(9).FontColor(Colors.White);
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
c.Item().Text(companyInfo.PrimaryContactEmail).FontSize(9).FontColor(Colors.White);
});
}
else
{
row.RelativeItem().Column(c =>
{
c.Item().Text(companyInfo.CompanyName).FontSize(20).Bold().FontColor(Colors.White);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
c.Item().Text(companyInfo.Phone).FontSize(9).FontColor(Colors.White);
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
c.Item().Text(companyInfo.PrimaryContactEmail).FontSize(9).FontColor(Colors.White);
});
}
row.ConstantItem(160).AlignRight().Column(c =>
{
c.Item().Text("DEPOSIT RECEIPT").FontSize(22).Bold().FontColor(Colors.White);
c.Item().Text(deposit.ReceiptNumber).FontSize(11).FontColor(Colors.White);
c.Item().Text(deposit.ReceivedDate.ToString("MMMM dd, yyyy")).FontSize(9).FontColor(Colors.White);
});
});
col.Item().PaddingTop(24).Row(row =>
{
// Bill To
row.RelativeItem().Column(c =>
{
c.Item().Text("RECEIVED FROM").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(deposit.Customer.CompanyName ?? $"{deposit.Customer.ContactFirstName} {deposit.Customer.ContactLastName}".Trim()).FontSize(12).Bold();
if (!string.IsNullOrWhiteSpace(deposit.Customer.Email))
c.Item().Text(deposit.Customer.Email).FontSize(9);
if (!string.IsNullOrWhiteSpace(deposit.Customer.Phone))
c.Item().Text(deposit.Customer.Phone).FontSize(9);
});
// Applied To
row.RelativeItem().Column(c =>
{
c.Item().Text("APPLIED TO").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
if (deposit.Job != null)
{
c.Item().Text($"Job #{deposit.Job.JobNumber}").FontSize(11).Bold();
if (!string.IsNullOrWhiteSpace(deposit.Job.Description))
c.Item().Text(deposit.Job.Description).FontSize(9);
}
else if (deposit.Quote != null)
{
c.Item().Text($"Quote #{deposit.Quote.QuoteNumber}").FontSize(11).Bold();
if (!string.IsNullOrWhiteSpace(deposit.Quote.Description))
c.Item().Text(deposit.Quote.Description).FontSize(9);
}
});
});
// Divider
col.Item().PaddingTop(20).PaddingBottom(20)
.LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
// Amount box
col.Item().Background(Colors.Grey.Lighten4).Padding(20).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().Text("AMOUNT RECEIVED").FontSize(10).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text($"{deposit.Amount:C}").FontSize(32).Bold().FontColor(accent);
});
row.ConstantItem(200).AlignRight().Column(c =>
{
c.Item().Text("PAYMENT METHOD").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(GetPaymentMethodDisplay(deposit.PaymentMethod)).FontSize(13);
if (!string.IsNullOrWhiteSpace(deposit.Reference))
{
c.Item().PaddingTop(6).Text("REFERENCE").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(deposit.Reference).FontSize(11);
}
});
});
// Notes
if (!string.IsNullOrWhiteSpace(deposit.Notes))
{
col.Item().PaddingTop(16).Column(c =>
{
c.Item().Text("NOTES").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(deposit.Notes).FontSize(10);
});
}
// Status note
col.Item().PaddingTop(28).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
col.Item().PaddingTop(12).AlignCenter().Text(text =>
{
text.Span("This deposit will be applied to your invoice when the job is complete. ").FontSize(9).FontColor(Colors.Grey.Darken1);
if (deposit.AppliedToInvoiceId != null)
text.Span("(Applied)").FontSize(9).Bold().FontColor(Colors.Green.Darken2);
else
text.Span("(Pending application)").FontSize(9).Italic().FontColor(Colors.Orange.Darken2);
});
col.Item().PaddingTop(8).AlignCenter()
.Text($"Thank you for your business — {companyInfo.CompanyName}")
.FontSize(9).Italic().FontColor(Colors.Grey.Medium);
});
});
});
return document.GeneratePdf();
}
/// <summary>
/// Returns <paramref name="hex"/> if it is non-empty and starts with <c>#</c> (a valid CSS
/// hex colour), otherwise returns <paramref name="fallback"/>. QuestPDF requires the leading
/// hash, so this guard prevents colour values imported without it from breaking the PDF.
/// </summary>
private static string ResolveColor(string? hex, string fallback)
{
if (string.IsNullOrWhiteSpace(hex)) return fallback;
return hex.StartsWith("#") ? hex : fallback;
}
}
@@ -0,0 +1,397 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Reflection;
using System.Security.Principal;
namespace PowderCoating.Web.Controllers;
[Authorize(Roles = "SuperAdmin,Administrator")]
public class DiagnosticsController : Controller
{
private readonly ILogger<DiagnosticsController> _logger;
private readonly IWebHostEnvironment _environment;
public DiagnosticsController(ILogger<DiagnosticsController> logger, IWebHostEnvironment environment)
{
_logger = logger;
_environment = environment;
}
/// <summary>
/// Renders the diagnostics overview page with environment, path, logging, and
/// log-file metadata (restricted to SuperAdmin and Administrator roles).
/// <para>
/// Probes write access to both the application root and the <c>logs/</c>
/// subdirectory by creating and immediately deleting a temporary file via
/// <see cref="CanWriteToDirectory"/>. This catches permission problems (e.g. the
/// process identity has read-only access) before they silently break Serilog.
/// A live <c>LogInformation</c> call is also made and the success/failure is
/// surfaced in the view so administrators can verify the logging pipeline end-to-end.
/// </para>
/// </summary>
public IActionResult Index()
{
var diagnostics = new DiagnosticsInfo
{
CurrentTime = DateTime.Now,
ApplicationPath = _environment.ContentRootPath,
LogsPath = Path.Combine(_environment.ContentRootPath, "logs"),
LogsDirectoryExists = Directory.Exists(Path.Combine(_environment.ContentRootPath, "logs")),
EnvironmentName = _environment.EnvironmentName,
UserIdentity = OperatingSystem.IsWindows() ? WindowsIdentity.GetCurrent().Name : Environment.UserName,
CanWriteToAppPath = CanWriteToDirectory(_environment.ContentRootPath),
CanWriteToLogsPath = CanWriteToDirectory(Path.Combine(_environment.ContentRootPath, "logs"))
};
// Try to test logging
try
{
_logger.LogInformation("Diagnostics page accessed by {User} at {Time}", User.Identity?.Name, DateTime.Now);
diagnostics.LoggingTestSuccess = true;
diagnostics.LoggingTestMessage = "Successfully called logger.LogInformation";
}
catch (Exception ex)
{
diagnostics.LoggingTestSuccess = false;
diagnostics.LoggingTestMessage = $"Logging failed: {ex.Message}";
}
// Get log files if they exist
var logsPath = Path.Combine(_environment.ContentRootPath, "logs");
if (Directory.Exists(logsPath))
{
try
{
diagnostics.LogFiles = Directory.GetFiles(logsPath, "*.txt")
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.LastWriteTime)
.Select(f => new LogFileInfo
{
Name = f.Name,
Size = f.Length,
LastModified = f.LastWriteTime,
FullPath = f.FullName
})
.ToList();
}
catch (Exception ex)
{
diagnostics.LogFilesError = ex.Message;
}
}
return View(diagnostics);
}
/// <summary>
/// Displays the contents of a specific Serilog log file (or the most recent error
/// log by default), with optional keyword search and line-count trimming.
/// <para>
/// Security hardening applied in this action:
/// <list type="bullet">
/// <item>Filename is validated against a strict regex (alphanumeric, hyphens,
/// underscores, <c>.txt</c> extension only) to block directory-traversal
/// payloads like <c>../../appsettings.json</c>.</item>
/// <item>The resolved full path is checked with <c>StartsWith(basePath)</c>
/// after calling <c>Path.GetFullPath</c> so OS path-normalisation cannot
/// be abused to escape the logs directory.</item>
/// <item>Files are read with <c>FileShare.ReadWrite</c> so that Serilog, which
/// holds the active log file open, is not blocked.</item>
/// </list>
/// Only the last <paramref name="lines"/> lines are returned to avoid sending
/// megabytes of text to the browser; if a search filter is active it is applied
/// first and then the tail is taken from the filtered set.
/// </para>
/// </summary>
public IActionResult ViewLogs(string? fileName = null, int lines = 500, string? search = null)
{
var logsPath = Path.Combine(_environment.ContentRootPath, "logs");
if (!Directory.Exists(logsPath))
{
return View(new LogViewerModel { Error = "Logs directory does not exist." });
}
var model = new LogViewerModel
{
LogsPath = logsPath,
SelectedLines = lines
};
// Get all log files
try
{
model.AvailableLogFiles = Directory.GetFiles(logsPath, "*.txt")
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.LastWriteTime)
.Select(f => new LogFileInfo
{
Name = f.Name,
Size = f.Length,
LastModified = f.LastWriteTime,
FullPath = f.FullName
})
.ToList();
}
catch (Exception ex)
{
model.Error = $"Error loading log files: {ex.Message}";
return View(model);
}
// If no file specified, use the most recent error log
if (string.IsNullOrEmpty(fileName))
{
fileName = model.AvailableLogFiles
.FirstOrDefault(f => f.Name.StartsWith("errors-"))?.Name
?? model.AvailableLogFiles.FirstOrDefault()?.Name;
}
if (string.IsNullOrEmpty(fileName))
{
model.Error = "No log files found.";
return View(model);
}
model.SelectedFileName = fileName;
// SECURITY: Sanitize filename - only allow alphanumeric, hyphens, underscores, and .txt extension
if (!System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9\-_]+\.txt$"))
{
_logger.LogWarning("SECURITY: Invalid log filename requested: {FileName} by {User}", fileName, User.Identity?.Name);
model.Error = "Invalid file name. Only .txt log files are allowed.";
return View(model);
}
var filePath = Path.Combine(logsPath, fileName);
// SECURITY: Enhanced path traversal protection
var fullPath = Path.GetFullPath(filePath);
var basePath = Path.GetFullPath(logsPath);
// Check if resolved path starts with base path AND ensure no path traversal
if (!fullPath.StartsWith(basePath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) &&
fullPath != basePath)
{
_logger.LogWarning("SECURITY: Path traversal attempt detected: {FilePath} by {User}", fullPath, User.Identity?.Name);
model.Error = "Invalid file path.";
return View(model);
}
// Verify file extension
if (Path.GetExtension(fullPath) != ".txt")
{
_logger.LogWarning("SECURITY: Non-txt file access attempted: {FilePath} by {User}", fullPath, User.Identity?.Name);
model.Error = "Only .txt files are allowed.";
return View(model);
}
if (!System.IO.File.Exists(filePath))
{
model.Error = $"Log file '{fileName}' not found.";
return View(model);
}
try
{
// Read the file with shared access to handle active log files
string[] allLines;
using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var streamReader = new StreamReader(fileStream))
{
var content = streamReader.ReadToEnd();
allLines = content.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
}
model.TotalLines = allLines.Length;
// Apply search filter if provided
if (!string.IsNullOrWhiteSpace(search))
{
allLines = allLines.Where(line =>
line.Contains(search, StringComparison.OrdinalIgnoreCase)).ToArray();
model.SearchTerm = search;
model.FilteredLines = allLines.Length;
}
// Take last N lines (most recent)
model.LogContent = string.Join(Environment.NewLine,
allLines.TakeLast(lines));
model.DisplayedLines = Math.Min(lines, allLines.Length);
}
catch (Exception ex)
{
model.Error = $"Error reading log file: {ex.Message}";
}
return View(model);
}
/// <summary>
/// Serves a Serilog log file as a downloadable <c>text/plain</c> response so
/// administrators can pull log archives from the server without direct file-system
/// access.
/// <para>
/// Applies the same path-containment security check as <see cref="ViewLogs"/>
/// (resolved full path must start with the logs directory path) to prevent
/// arbitrary file downloads. The file is read into a byte array with
/// <c>FileShare.ReadWrite</c> to avoid locking Serilog's active sink.
/// </para>
/// </summary>
public IActionResult DownloadLog(string fileName)
{
var logsPath = Path.Combine(_environment.ContentRootPath, "logs");
var filePath = Path.Combine(logsPath, fileName);
// Security check - ensure the file is in the logs directory
var fullPath = Path.GetFullPath(filePath);
if (!fullPath.StartsWith(Path.GetFullPath(logsPath)))
{
return BadRequest("Invalid file path.");
}
if (!System.IO.File.Exists(filePath))
{
return NotFound($"Log file '{fileName}' not found.");
}
try
{
// Read file with shared access to handle active log files
byte[] fileBytes;
using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var memoryStream = new MemoryStream())
{
fileStream.CopyTo(memoryStream);
fileBytes = memoryStream.ToArray();
}
return File(fileBytes, "text/plain", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error downloading log file {FileName}", fileName);
return StatusCode(500, $"Error downloading log file: {ex.Message}");
}
}
/// <summary>
/// Permanently deletes Serilog <c>.txt</c> log files whose last-write timestamp
/// is older than <paramref name="daysToKeep"/> days (defaults to 30).
/// <para>
/// This is a destructive, irreversible file-system operation. Only the
/// <c>Administrator</c> / <c>SuperAdmin</c> roles can reach this action (enforced
/// at controller level). The deletion is logged at Warning level so there is an
/// audit trail in any newer log files that survive the purge.
/// </para>
/// </summary>
public IActionResult ClearOldLogs(int daysToKeep = 30)
{
var logsPath = Path.Combine(_environment.ContentRootPath, "logs");
if (!Directory.Exists(logsPath))
{
TempData["ErrorMessage"] = "Logs directory does not exist.";
return RedirectToAction(nameof(ViewLogs));
}
try
{
var cutoffDate = DateTime.Now.AddDays(-daysToKeep);
var files = Directory.GetFiles(logsPath, "*.txt")
.Select(f => new FileInfo(f))
.Where(f => f.LastWriteTime < cutoffDate)
.ToList();
var deletedCount = 0;
foreach (var file in files)
{
file.Delete();
deletedCount++;
}
_logger.LogInformation("Cleared {Count} old log files (older than {Days} days) by {User}",
deletedCount, daysToKeep, User.Identity?.Name);
TempData["SuccessMessage"] = $"Deleted {deletedCount} old log file(s).";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error clearing old logs");
TempData["ErrorMessage"] = $"Error clearing logs: {ex.Message}";
}
return RedirectToAction(nameof(ViewLogs));
}
/// <summary>
/// Tests whether the current process identity can write files to
/// <paramref name="path"/> by creating and immediately deleting a uniquely-named
/// temporary file.
/// <para>
/// Creates the directory if it does not yet exist so that a missing <c>logs/</c>
/// folder does not produce a false negative on fresh deployments.
/// Any exception (UnauthorizedAccess, IOException, etc.) is swallowed and returns
/// <c>false</c> — this is intentional because the method is purely diagnostic and
/// must not throw.
/// </para>
/// </summary>
private bool CanWriteToDirectory(string path)
{
try
{
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
var testFile = Path.Combine(path, $"write_test_{Guid.NewGuid()}.tmp");
System.IO.File.WriteAllText(testFile, "test");
System.IO.File.Delete(testFile);
return true;
}
catch
{
return false;
}
}
}
public class DiagnosticsInfo
{
public DateTime CurrentTime { get; set; }
public string ApplicationPath { get; set; } = string.Empty;
public string LogsPath { get; set; } = string.Empty;
public bool LogsDirectoryExists { get; set; }
public string EnvironmentName { get; set; } = string.Empty;
public string UserIdentity { get; set; } = string.Empty;
public bool CanWriteToAppPath { get; set; }
public bool CanWriteToLogsPath { get; set; }
public bool LoggingTestSuccess { get; set; }
public string LoggingTestMessage { get; set; } = string.Empty;
public List<LogFileInfo> LogFiles { get; set; } = new();
public string? LogFilesError { get; set; }
}
public class LogFileInfo
{
public string Name { get; set; } = string.Empty;
public long Size { get; set; }
public DateTime LastModified { get; set; }
public string FullPath { get; set; } = string.Empty;
}
public class LogViewerModel
{
public string LogsPath { get; set; } = string.Empty;
public List<LogFileInfo> AvailableLogFiles { get; set; } = new();
public string? SelectedFileName { get; set; }
public string LogContent { get; set; } = string.Empty;
public int TotalLines { get; set; }
public int DisplayedLines { get; set; }
public int FilteredLines { get; set; }
public int SelectedLines { get; set; } = 500;
public string? SearchTerm { get; set; }
public string? Error { get; set; }
}
@@ -0,0 +1,207 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only tool for sending platform-wide broadcast emails to tenant
/// company contacts. Emails are sent one at a time via <see cref="IEmailService"/>
/// rather than bulk API because each message requires a personalised unsubscribe link
/// containing the company's unique <c>MarketingUnsubscribeToken</c>.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class EmailBroadcastController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IEmailService _emailService;
private readonly ILogger<EmailBroadcastController> _logger;
public EmailBroadcastController(
ApplicationDbContext db,
IEmailService emailService,
ILogger<EmailBroadcastController> logger)
{
_db = db;
_emailService = emailService;
_logger = logger;
}
/// <summary>
/// Renders the broadcast compose form, pre-populating ViewBag with plan configs
/// and the active company list so the targeting dropdowns are populated without
/// a separate AJAX call.
/// </summary>
public async Task<IActionResult> Index()
{
await PopulateViewBag();
return View(new BroadcastForm());
}
/// <summary>Returns JSON count of recipients for the current filter — used for the live preview.</summary>
[HttpGet]
public async Task<IActionResult> RecipientCount(string target, string? plan, int[]? companyIds)
{
var recipients = await BuildRecipientListAsync(target, plan, companyIds);
return Json(new { count = recipients.Count });
}
/// <summary>
/// Sends the composed broadcast email to all recipients matching the chosen
/// targeting criteria. Aborts early (with a validation error) if no recipients
/// are found, to prevent accidental empty sends.
/// <para>
/// Each email body is HTML-encoded (then line-breaks converted to
/// <c>&lt;br&gt;</c>) and wrapped in a branded container that appends a
/// per-company unsubscribe footer. Failures are counted and reported in the
/// success banner rather than aborting the remainder of the batch, so a single
/// bad address does not block delivery to every other recipient.
/// </para>
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Send(BroadcastForm form)
{
if (!ModelState.IsValid)
{
await PopulateViewBag();
return View("Index", form);
}
var recipients = await BuildRecipientListAsync(form.Target, form.PlanFilter, form.CompanyIds);
if (recipients.Count == 0)
{
TempData["Error"] = "No recipients matched the selected criteria.";
await PopulateViewBag();
return View("Index", form);
}
int sent = 0, failed = 0;
var baseUrl = $"{Request.Scheme}://{Request.Host}";
var encodedBody = System.Net.WebUtility.HtmlEncode(form.Body).Replace("\n", "<br>");
foreach (var (email, name, unsubToken) in recipients)
{
var unsubUrl = $"{baseUrl}/Unsubscribe/BroadcastEmail/{unsubToken}";
var htmlBody = $@"
<div style=""font-family:sans-serif;max-width:600px;margin:0 auto"">
<p>{encodedBody}</p>
<hr style=""border:none;border-top:1px solid #eee;margin:24px 0"">
<p style=""font-size:12px;color:#999"">
This message was sent by the Powder Coating Logix platform team.<br>
<a href=""{unsubUrl}"" style=""color:#999"">Unsubscribe from platform announcements</a>
</p>
</div>";
var (success, error) = await _emailService.SendEmailAsync(
email, name, form.Subject, form.Body, htmlBody);
if (success) sent++;
else
{
failed++;
_logger.LogWarning("Broadcast email failed for {Email}: {Error}", email, error);
}
}
TempData["Success"] = $"Broadcast sent: {sent} delivered, {failed} failed. Total recipients: {recipients.Count}.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Builds the list of (email, name, unsubscribe-token) tuples for the given
/// targeting criteria. Companies are excluded when <c>MarketingEmailOptOut</c>
/// is true — honouring prior unsubscribes — or when <c>PrimaryContactEmail</c>
/// is missing. The "specific" target requires at least one <c>companyIds</c>
/// entry and returns an empty list otherwise to prevent accidental all-company sends.
/// <c>IgnoreQueryFilters()</c> is required because this query spans companies.
/// </summary>
private async Task<List<(string Email, string Name, string UnsubToken)>> BuildRecipientListAsync(
string? target, string? planFilter, int[]? companyIds)
{
var companyQuery = _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted && c.IsActive && !string.IsNullOrEmpty(c.PrimaryContactEmail)
&& !c.MarketingEmailOptOut);
target ??= "active";
switch (target)
{
case "active":
companyQuery = companyQuery.Where(c =>
c.SubscriptionStatus == SubscriptionStatus.Active ||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod);
break;
case "plan":
if (!string.IsNullOrWhiteSpace(planFilter) && int.TryParse(planFilter, out var planInt))
companyQuery = companyQuery.Where(c => c.SubscriptionPlan == planInt);
break;
case "status_grace":
companyQuery = companyQuery.Where(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod);
break;
case "status_expired":
companyQuery = companyQuery.Where(c => c.SubscriptionStatus == SubscriptionStatus.Expired);
break;
case "specific":
if (companyIds != null && companyIds.Length > 0)
companyQuery = companyQuery.Where(c => companyIds.Contains(c.Id));
else
return new List<(string, string, string)>();
break;
case "all":
default:
break;
}
var companies = await companyQuery
.Select(c => new { c.PrimaryContactEmail, c.CompanyName, c.MarketingUnsubscribeToken })
.ToListAsync();
return companies
.Where(c => !string.IsNullOrWhiteSpace(c.PrimaryContactEmail))
.Select(c => (c.PrimaryContactEmail!, c.CompanyName, c.MarketingUnsubscribeToken))
.ToList();
}
/// <summary>
/// Hydrates ViewBag with the data sets needed by the broadcast compose view:
/// active subscription plan configs (for the plan-filter dropdown),
/// all non-deleted active companies (for the specific-company picker),
/// and a live count of active/grace-period companies shown in the UI summary.
/// Centralised here so it can be called from both <see cref="Index"/> and the
/// validation-failure branch of <see cref="Send"/>.
/// </summary>
private async Task PopulateViewBag()
{
ViewBag.PlanConfigs = await _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
.Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToListAsync();
ViewBag.Companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted && c.IsActive)
.OrderBy(c => c.CompanyName)
.Select(c => new { c.Id, c.CompanyName })
.ToListAsync();
ViewBag.ActiveCount = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.CountAsync(c => !c.IsDeleted && c.IsActive &&
(c.SubscriptionStatus == SubscriptionStatus.Active ||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod));
}
}
public class BroadcastForm
{
public string Target { get; set; } = "active";
public string? PlanFilter { get; set; }
public int[]? CompanyIds { get; set; }
[System.ComponentModel.DataAnnotations.Required]
public string Subject { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required]
public string Body { get; set; } = string.Empty;
}
@@ -0,0 +1,611 @@
using AutoMapper;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Equipment;
using PowderCoating.Application.DTOs.Maintenance;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanManageEquipment)]
public class EquipmentController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly IFileService _fileService; // Legacy - kept for backward compatibility
private readonly IEquipmentManualService _manualService;
private readonly ITenantContext _tenantContext;
private readonly ILogger<EquipmentController> _logger;
public EquipmentController(
IUnitOfWork unitOfWork,
IMapper mapper,
IFileService fileService,
IEquipmentManualService manualService,
ITenantContext tenantContext,
ILogger<EquipmentController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_fileService = fileService;
_manualService = manualService;
_tenantContext = tenantContext;
_logger = logger;
}
/// <summary>
/// Displays the paginated equipment list with optional keyword search and status filter.
/// Search covers name, equipment number, serial number, manufacturer, and model so
/// shop staff can find a piece of equipment using any identifier they have on hand.
/// Sorting is resolved server-side via a switch expression so column names never reach
/// raw SQL.
/// </summary>
public async Task<IActionResult> Index(
string? searchTerm,
EquipmentStatus? statusFilter,
string? sortColumn,
string sortDirection = "asc",
int pageNumber = 1,
int pageSize = 25)
{
try
{
// Create and validate grid request
var gridRequest = new GridRequest
{
PageNumber = pageNumber,
PageSize = pageSize,
SortColumn = sortColumn ?? "Name",
SortDirection = sortDirection,
SearchTerm = searchTerm
};
gridRequest.Validate();
// Build search and status filter
System.Linq.Expressions.Expression<Func<Equipment, bool>>? filter = null;
if (!string.IsNullOrWhiteSpace(searchTerm) && statusFilter.HasValue)
{
// Both search and status filter
var search = searchTerm.ToLower();
var status = statusFilter.Value;
filter = e => (e.EquipmentName.ToLower().Contains(search)
|| (e.EquipmentNumber != null && e.EquipmentNumber.ToLower().Contains(search))
|| (e.SerialNumber != null && e.SerialNumber.ToLower().Contains(search))
|| (e.Manufacturer != null && e.Manufacturer.ToLower().Contains(search))
|| (e.Model != null && e.Model.ToLower().Contains(search)))
&& e.Status == status;
}
else if (!string.IsNullOrWhiteSpace(searchTerm))
{
// Search only
var search = searchTerm.ToLower();
filter = e => e.EquipmentName.ToLower().Contains(search)
|| (e.EquipmentNumber != null && e.EquipmentNumber.ToLower().Contains(search))
|| (e.SerialNumber != null && e.SerialNumber.ToLower().Contains(search))
|| (e.Manufacturer != null && e.Manufacturer.ToLower().Contains(search))
|| (e.Model != null && e.Model.ToLower().Contains(search));
}
else if (statusFilter.HasValue)
{
// Status filter only
var status = statusFilter.Value;
filter = e => e.Status == status;
}
// Build orderBy function
Func<IQueryable<Equipment>, IOrderedQueryable<Equipment>> orderBy = gridRequest.SortColumn switch
{
"Name" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(e => e.EquipmentName) : q.OrderByDescending(e => e.EquipmentName),
"EquipmentCode" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(e => e.EquipmentNumber) : q.OrderByDescending(e => e.EquipmentNumber),
"Status" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(e => e.Status) : q.OrderByDescending(e => e.Status),
"PurchaseDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(e => e.PurchaseDate) : q.OrderByDescending(e => e.PurchaseDate),
"NextMaintenanceDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(e => e.NextScheduledMaintenance) : q.OrderByDescending(e => e.NextScheduledMaintenance),
_ => q => q.OrderBy(e => e.EquipmentName)
};
// Get paged data
var (items, totalCount) = await _unitOfWork.Equipment.GetPagedAsync(
gridRequest.PageNumber,
gridRequest.PageSize,
filter,
orderBy);
// Map to DTOs
var equipmentDtos = _mapper.Map<List<EquipmentListDto>>(items);
// Create paged result
var pagedResult = new PagedResult<EquipmentListDto>
{
Items = equipmentDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
// Set ViewBag for sorting and filters
ViewBag.SearchTerm = searchTerm;
ViewBag.StatusFilter = statusFilter;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
return View(pagedResult);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving equipment");
TempData["Error"] = "An error occurred while loading equipment.";
return View(new PagedResult<EquipmentListDto>());
}
}
/// <summary>
/// Renders the equipment detail page. Maintenance history is loaded via a separate
/// FindAsync call (not eager loading on the Equipment entity) and sorted descending
/// by ScheduledDate so the most recent maintenance tasks appear first. This gives
/// technicians an at-a-glance view of the equipment's service history.
/// </summary>
public async Task<IActionResult> Details(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var equipment = await _unitOfWork.Equipment.GetByIdAsync(id.Value);
if (equipment == null)
{
return NotFound();
}
var equipmentDto = _mapper.Map<EquipmentDto>(equipment);
// Load maintenance history
var maintenanceRecords = await _unitOfWork.MaintenanceRecords
.FindAsync(m => m.EquipmentId == id.Value);
var maintenanceList = _mapper.Map<List<MaintenanceListDto>>(maintenanceRecords.OrderByDescending(m => m.ScheduledDate));
ViewBag.MaintenanceHistory = maintenanceList;
return View(equipmentDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving equipment {EquipmentId}", id);
TempData["Error"] = "An error occurred while loading the equipment.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Renders the equipment creation form, defaulting status to Operational because new
/// equipment entering the shop is presumed ready to use until a maintenance issue
/// is logged. The full EquipmentStatus enum is passed to the view for the status
/// dropdown so adding new statuses to the enum automatically appears in the form.
/// </summary>
public IActionResult Create()
{
var dto = new CreateEquipmentDto
{
Status = EquipmentStatus.Operational.ToString()
};
ViewBag.StatusList = Enum.GetValues<EquipmentStatus>();
return View(dto);
}
/// <summary>
/// Persists a new equipment record and sets IsActive to true. The equipment starts
/// with no manual file; manuals are uploaded separately via <see cref="UploadManual"/>
/// to avoid coupling file I/O to the core create transaction.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateEquipmentDto dto)
{
if (!ModelState.IsValid)
{
ViewBag.StatusList = Enum.GetValues<EquipmentStatus>();
return View(dto);
}
try
{
var equipment = _mapper.Map<Equipment>(dto);
equipment.CreatedAt = DateTime.UtcNow;
equipment.IsActive = true;
await _unitOfWork.Equipment.AddAsync(equipment);
await _unitOfWork.SaveChangesAsync();
TempData["Success"] = "Equipment created successfully.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating equipment");
TempData["Error"] = "An error occurred while creating the equipment.";
ViewBag.StatusList = Enum.GetValues<EquipmentStatus>();
return View(dto);
}
}
/// <summary>
/// Renders the equipment edit form, pre-populated via AutoMapper. The status dropdown
/// is reloaded from the enum so it always reflects any additions to <see cref="EquipmentStatus"/>.
/// </summary>
public async Task<IActionResult> Edit(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var equipment = await _unitOfWork.Equipment.GetByIdAsync(id.Value);
if (equipment == null)
{
return NotFound();
}
var dto = _mapper.Map<UpdateEquipmentDto>(equipment);
ViewBag.StatusList = Enum.GetValues<EquipmentStatus>();
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving equipment {EquipmentId} for edit", id);
TempData["Error"] = "An error occurred while loading the equipment.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Persists edits to an existing equipment record. The Notes field is captured before
/// AutoMapper runs and restored if the submitted dto.Notes is blank, because the Notes
/// field doubles as legacy file metadata storage (the original upload path was stored
/// there). This prevents an Edit save from silently erasing a manual reference that
/// was uploaded before the dedicated ManualFilePath column existed.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateEquipmentDto dto)
{
if (id != dto.Id)
{
return NotFound();
}
if (!ModelState.IsValid)
{
ViewBag.StatusList = Enum.GetValues<EquipmentStatus>();
return View(dto);
}
try
{
var equipment = await _unitOfWork.Equipment.GetByIdAsync(id);
if (equipment == null)
{
return NotFound();
}
// Preserve Notes field (contains file metadata)
var preservedNotes = equipment.Notes;
_mapper.Map(dto, equipment);
// If dto.Notes is empty, restore the preserved notes (file metadata)
if (string.IsNullOrWhiteSpace(dto.Notes))
{
equipment.Notes = preservedNotes;
}
equipment.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Equipment.UpdateAsync(equipment);
await _unitOfWork.SaveChangesAsync();
TempData["Success"] = "Equipment updated successfully.";
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating equipment {EquipmentId}", id);
TempData["Error"] = "An error occurred while updating the equipment.";
ViewBag.StatusList = Enum.GetValues<EquipmentStatus>();
return View(dto);
}
}
/// <summary>
/// Renders the equipment delete confirmation page, showing a full summary so the user
/// can verify they are removing the correct piece of equipment before confirming.
/// </summary>
public async Task<IActionResult> Delete(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var equipment = await _unitOfWork.Equipment.GetByIdAsync(id.Value);
if (equipment == null)
{
return NotFound();
}
var equipmentDto = _mapper.Map<EquipmentDto>(equipment);
return View(equipmentDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving equipment {EquipmentId} for delete", id);
TempData["Error"] = "An error occurred while loading the equipment.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Soft-deletes the equipment record and cleans up any associated manual file from
/// both the new filesystem storage (ManualFilePath via <see cref="IEquipmentManualService"/>)
/// and the legacy uploads folder (Notes field starting with "uploads/"). File cleanup
/// runs before the soft delete so that orphaned files on disk are not left behind if
/// the delete succeeds.
/// </summary>
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
try
{
var equipment = await _unitOfWork.Equipment.GetByIdAsync(id);
if (equipment == null)
{
return NotFound();
}
// Delete associated manual file if exists (new filesystem storage)
if (!string.IsNullOrEmpty(equipment.ManualFilePath))
{
await _manualService.DeleteEquipmentManualAsync(equipment.ManualFilePath);
}
// Delete associated manual file if exists (legacy uploads folder)
else if (!string.IsNullOrWhiteSpace(equipment.Notes) && equipment.Notes.StartsWith("uploads/"))
{
await _fileService.DeleteFileAsync(equipment.Notes);
}
await _unitOfWork.Equipment.SoftDeleteAsync(equipment);
await _unitOfWork.SaveChangesAsync();
TempData["Success"] = "Equipment deleted successfully.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting equipment {EquipmentId}", id);
TempData["Error"] = "An error occurred while deleting the equipment.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Accepts an equipment manual file upload and saves it to the filesystem via
/// <see cref="IEquipmentManualService"/>. If a manual already exists (either in the
/// new ManualFilePath column or in the legacy Notes/uploads path), it is deleted first
/// to prevent orphaned files accumulating on disk. Returns JSON { success, message,
/// fileName } so the Details page can update the manual section without a full reload.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadManual(int equipmentId, IFormFile manualFile)
{
try
{
if (manualFile == null || manualFile.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 equipment = await _unitOfWork.Equipment.GetByIdAsync(equipmentId);
if (equipment == null)
{
return Json(new { success = false, message = "Equipment not found." });
}
// Delete old manual if exists (new filesystem storage)
if (!string.IsNullOrEmpty(equipment.ManualFilePath))
{
await _manualService.DeleteEquipmentManualAsync(equipment.ManualFilePath);
}
// Delete old manual if exists (legacy uploads folder)
else if (!string.IsNullOrWhiteSpace(equipment.Notes) && equipment.Notes.StartsWith("uploads/"))
{
await _fileService.DeleteFileAsync(equipment.Notes);
equipment.Notes = null; // Clear legacy field
}
// Save new file to filesystem
var (success, filePath, errorMessage) = await _manualService.SaveEquipmentManualAsync(
manualFile,
companyId.Value,
equipmentId);
if (!success)
{
return Json(new { success = false, message = errorMessage });
}
// Update equipment record
equipment.ManualFilePath = filePath;
equipment.ManualFileName = manualFile.FileName;
equipment.ManualFileSize = manualFile.Length;
equipment.ManualContentType = manualFile.ContentType;
equipment.ManualUploadedDate = DateTime.UtcNow;
equipment.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Equipment.UpdateAsync(equipment);
await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("Equipment {EquipmentId} manual uploaded to filesystem", equipmentId);
return Json(new
{
success = true,
message = "Manual uploaded successfully.",
fileName = manualFile.FileName
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading manual for equipment {EquipmentId}", equipmentId);
return Json(new { success = false, message = "An error occurred while uploading the file." });
}
}
/// <summary>
/// Streams the equipment manual file back to the browser as a file download. Checks
/// the new ManualFilePath column first, then falls back to the legacy Notes/uploads
/// path for equipment whose manuals were uploaded before the ManualFilePath column
/// was added. For legacy files, the GUID prefix (added by the old upload service) is
/// stripped from the download filename so the user sees the original filename.
/// </summary>
public async Task<IActionResult> DownloadManual(int id)
{
try
{
var equipment = await _unitOfWork.Equipment.GetByIdAsync(id);
if (equipment == null)
{
return NotFound();
}
// Try new filesystem storage first
if (!string.IsNullOrEmpty(equipment.ManualFilePath))
{
var (success, fileContent, contentType, errorMessage) = await _manualService.GetEquipmentManualAsync(equipment.ManualFilePath);
if (success)
{
var fileName = equipment.ManualFileName ?? Path.GetFileName(equipment.ManualFilePath);
return File(fileContent, contentType, fileName);
}
TempData["Error"] = errorMessage;
return RedirectToAction(nameof(Details), new { id });
}
// Fallback to legacy storage (uploads folder)
if (!string.IsNullOrWhiteSpace(equipment.Notes) && equipment.Notes.StartsWith("uploads/"))
{
var result = await _fileService.GetFileAsync(equipment.Notes);
if (!result.Success)
{
TempData["Error"] = result.ErrorMessage;
return RedirectToAction(nameof(Details), new { id });
}
var fileName = Path.GetFileName(equipment.Notes);
// Remove GUID prefix from filename for download
if (fileName.Length > 37 && fileName[36] == '-')
{
fileName = fileName.Substring(37);
}
return File(result.FileContent, result.ContentType, fileName);
}
return NotFound();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error downloading manual for equipment {EquipmentId}", id);
TempData["Error"] = "An error occurred while downloading the file.";
return RedirectToAction(nameof(Details), new { id });
}
}
/// <summary>
/// Deletes the equipment manual from disk and clears the manual metadata fields on the
/// equipment entity. Handles both the current filesystem storage path (ManualFilePath)
/// and the legacy Notes/uploads path so that manuals uploaded under either storage
/// scheme can be removed without leaving orphaned files. Returns JSON so the Details
/// page can remove the download link without a full reload.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteManual(int equipmentId)
{
try
{
var equipment = await _unitOfWork.Equipment.GetByIdAsync(equipmentId);
if (equipment == null)
{
return Json(new { success = false, message = "Equipment not found." });
}
// Check if manual exists
if (string.IsNullOrEmpty(equipment.ManualFilePath) && string.IsNullOrWhiteSpace(equipment.Notes))
{
return Json(new { success = false, message = "No manual found." });
}
// Delete from new filesystem storage if exists
if (!string.IsNullOrEmpty(equipment.ManualFilePath))
{
var (success, errorMessage) = await _manualService.DeleteEquipmentManualAsync(equipment.ManualFilePath);
if (!success)
{
return Json(new { success = false, message = errorMessage });
}
equipment.ManualFilePath = null;
equipment.ManualFileName = null;
equipment.ManualFileSize = null;
equipment.ManualContentType = null;
equipment.ManualUploadedDate = null;
}
// Delete from legacy storage if exists
else if (!string.IsNullOrWhiteSpace(equipment.Notes) && equipment.Notes.StartsWith("uploads/"))
{
var result = await _fileService.DeleteFileAsync(equipment.Notes);
if (!result.Success)
{
return Json(new { success = false, message = result.ErrorMessage });
}
equipment.Notes = null;
}
equipment.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Equipment.UpdateAsync(equipment);
await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("Equipment {EquipmentId} manual deleted", equipmentId);
return Json(new { success = true, message = "Manual deleted successfully." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting manual for equipment {EquipmentId}", equipmentId);
return Json(new { success = false, message = "An error occurred while deleting the file." });
}
}
}
@@ -0,0 +1,555 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanViewData)]
public class ExpensesController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<ExpensesController> _logger;
private readonly ApplicationDbContext _context;
private readonly IAzureBlobStorageService _blobStorage;
private readonly StorageSettings _storageSettings;
private readonly IAccountBalanceService _accountBalanceService;
private readonly IAccountingAiService _accountingAi;
private readonly IAiUsageLogger _usageLogger;
private static readonly string[] AllowedReceiptTypes = { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf" };
private const long MaxReceiptBytes = 10 * 1024 * 1024; // 10 MB
public ExpensesController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<ExpensesController> logger,
ApplicationDbContext context,
IAzureBlobStorageService blobStorage,
IOptions<StorageSettings> storageSettings,
IAccountBalanceService accountBalanceService,
IAccountingAiService accountingAi,
IAiUsageLogger usageLogger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_context = context;
_blobStorage = blobStorage;
_storageSettings = storageSettings.Value;
_accountBalanceService = accountBalanceService;
_accountingAi = accountingAi;
_usageLogger = usageLogger;
}
// ── Index ────────────────────────────────────────────────────────────────
/// <summary>
/// Redirects to the unified bills/expenses ledger (<see cref="BillsController.Index"/>)
/// pre-filtered to Expense entries. The two entry types share a single list view to reduce
/// navigation surface area; this redirect keeps the <c>/Expenses</c> URL working for
/// existing bookmarks and links.
/// </summary>
public IActionResult Index()
{
return RedirectToAction("Index", "Bills", new { type = "Expense" });
}
/// <summary>
/// Legacy standalone expense list — kept for reference but disabled via <c>[NonAction]</c>
/// since the unified Bills/Expenses index in <see cref="BillsController"/> superseded it.
/// Do not route traffic here; use <see cref="Index"/> instead.
/// </summary>
[NonAction]
public async Task<IActionResult> IndexLegacy(string? search, int? accountId, DateTime? from, DateTime? to, int page = 1, int pageSize = 25)
{
var query = _context.Expenses
.Include(e => e.Vendor)
.Include(e => e.ExpenseAccount)
.Include(e => e.PaymentAccount)
.Include(e => e.Job)
.Where(e => !e.IsDeleted);
if (!string.IsNullOrEmpty(search))
query = query.Where(e => e.ExpenseNumber.Contains(search) ||
e.Memo!.Contains(search) ||
(e.Vendor != null && e.Vendor.CompanyName.Contains(search)));
if (accountId.HasValue)
query = query.Where(e => e.ExpenseAccountId == accountId.Value);
if (from.HasValue)
query = query.Where(e => e.Date >= from.Value);
if (to.HasValue)
query = query.Where(e => e.Date <= to.Value);
var expenses = await query.OrderByDescending(e => e.CreatedAt).ToListAsync();
var dtos = _mapper.Map<List<ExpenseListDto>>(expenses);
ViewBag.Search = search;
ViewBag.AccountId = accountId;
ViewBag.From = from?.ToString("yyyy-MM-dd");
ViewBag.To = to?.ToString("yyyy-MM-dd");
ViewBag.TotalAmount = dtos.Sum(e => e.Amount);
var expenseAccounts = await _context.Accounts
.Where(a => !a.IsDeleted && a.IsActive &&
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods))
.OrderBy(a => a.AccountNumber)
.ToListAsync();
ViewBag.AccountFilter = expenseAccounts
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
return View(dtos);
}
// ── Create ───────────────────────────────────────────────────────────────
/// <summary>
/// Returns the blank expense creation form. Unlike vendor bills, direct expenses record
/// an immediate payment (no AP liability step) — the expense account is debited and the
/// payment account (bank/credit card) is credited at the point of saving.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
public async Task<IActionResult> Create()
{
await PopulateDropdownsAsync();
return View(new CreateExpenseDto { Date = DateTime.Today });
}
/// <summary>
/// Persists a new direct expense. The receipt file (if provided) is uploaded after the
/// entity is saved so that the <c>Expense.Id</c> is available for the blob path. Double-entry
/// effect: the expense account is debited (<c>DebitAsync</c>) and the payment account is
/// credited (<c>CreditAsync</c>). An optional <paramref name="receiptFile"/> is stored in
/// Azure Blob Storage; upload failure is non-fatal (a warning is shown) so the expense record
/// is never lost due to a transient storage outage.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
public async Task<IActionResult> Create(CreateExpenseDto dto, IFormFile? receiptFile)
{
if (!ModelState.IsValid)
{
await PopulateDropdownsAsync();
return View(dto);
}
if (receiptFile != null && !IsValidReceiptFile(receiptFile, out var fileError))
{
ModelState.AddModelError(string.Empty, fileError);
await PopulateDropdownsAsync();
return View(dto);
}
try
{
var currentUser = await _userManager.GetUserAsync(User);
var expense = _mapper.Map<Expense>(dto);
expense.ExpenseNumber = await GenerateExpenseNumberAsync();
expense.CompanyId = currentUser!.CompanyId;
expense.CreatedBy = currentUser.Email;
await _unitOfWork.Expenses.AddAsync(expense);
await _unitOfWork.CompleteAsync();
if (receiptFile != null)
expense.ReceiptFilePath = await UploadReceiptAsync(receiptFile, expense.Id, currentUser.CompanyId);
// Update account balances: debit expense account, credit payment account
await _accountBalanceService.DebitAsync(expense.ExpenseAccountId, expense.Amount);
await _accountBalanceService.CreditAsync(expense.PaymentAccountId, expense.Amount);
if (expense.ReceiptFilePath != null)
await _unitOfWork.Expenses.UpdateAsync(expense);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Expense {expense.ExpenseNumber} recorded.";
return RedirectToAction(nameof(Details), new { id = expense.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating expense");
ModelState.AddModelError(string.Empty, "An error occurred while saving.");
await PopulateDropdownsAsync();
return View(dto);
}
}
// ── Edit ─────────────────────────────────────────────────────────────────
/// <summary>
/// Returns the edit form for an existing expense.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
public async Task<IActionResult> Edit(int? id)
{
if (id == null) return NotFound();
var expense = await _unitOfWork.Expenses.GetByIdAsync(id.Value);
if (expense == null) return NotFound();
await PopulateDropdownsAsync();
return View(_mapper.Map<EditExpenseDto>(expense));
}
/// <summary>
/// Saves expense edits. Because account balances were already applied when the expense was
/// created, the old balances must be reversed before applying the new ones to keep the ledger
/// accurate: the old expense account is credited (reversing the original debit) and the old
/// payment account is debited (reversing the original credit), then the new accounts are
/// updated with the new amount. If the account or amount is unchanged the net effect is zero.
/// The old receipt blob is deleted from storage before uploading the replacement to avoid
/// orphaned files.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
public async Task<IActionResult> Edit(int id, EditExpenseDto dto, IFormFile? receiptFile)
{
if (id != dto.Id) return NotFound();
if (!ModelState.IsValid)
{
await PopulateDropdownsAsync();
return View(dto);
}
if (receiptFile != null && !IsValidReceiptFile(receiptFile, out var fileError))
{
ModelState.AddModelError(string.Empty, fileError);
await PopulateDropdownsAsync();
return View(dto);
}
try
{
var expense = await _unitOfWork.Expenses.GetByIdAsync(id);
if (expense == null) return NotFound();
// Capture old values before overwriting
var oldAmount = expense.Amount;
var oldExpenseAccountId = expense.ExpenseAccountId;
var oldPaymentAccountId = expense.PaymentAccountId;
var currentUser = await _userManager.GetUserAsync(User);
_mapper.Map(dto, expense);
expense.UpdatedAt = DateTime.UtcNow;
expense.UpdatedBy = currentUser?.Email;
if (receiptFile != null)
{
// Delete old receipt if present
if (!string.IsNullOrEmpty(expense.ReceiptFilePath))
await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, expense.ReceiptFilePath);
expense.ReceiptFilePath = await UploadReceiptAsync(receiptFile, expense.Id, currentUser!.CompanyId);
}
// Reverse old balances, apply new balances
await _accountBalanceService.CreditAsync(oldExpenseAccountId, oldAmount);
await _accountBalanceService.DebitAsync(oldPaymentAccountId, oldAmount);
await _accountBalanceService.DebitAsync(expense.ExpenseAccountId, expense.Amount);
await _accountBalanceService.CreditAsync(expense.PaymentAccountId, expense.Amount);
await _unitOfWork.Expenses.UpdateAsync(expense);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Expense {expense.ExpenseNumber} updated.";
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating expense {Id}", id);
ModelState.AddModelError(string.Empty, "An error occurred while saving.");
await PopulateDropdownsAsync();
return View(dto);
}
}
// ── Details ──────────────────────────────────────────────────────────────
/// <summary>
/// Displays the read-only expense detail view, including vendor, expense account, payment
/// account, and linked job (if cost was allocated to a job).
/// </summary>
public async Task<IActionResult> Details(int? id)
{
if (id == null) return NotFound();
var expense = await _context.Expenses
.Include(e => e.Vendor)
.Include(e => e.ExpenseAccount)
.Include(e => e.PaymentAccount)
.Include(e => e.Job)
.FirstOrDefaultAsync(e => e.Id == id && !e.IsDeleted);
if (expense == null) return NotFound();
return View(_mapper.Map<ExpenseDto>(expense));
}
// ── Delete ───────────────────────────────────────────────────────────────
/// <summary>
/// Soft-deletes an expense and reverses its account-balance effects: the expense account is
/// credited (reversing the original debit) and the payment account is debited (reversing the
/// original credit). The receipt blob is permanently deleted from Azure Blob Storage because
/// it is no longer referenced by any active record.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
public async Task<IActionResult> Delete(int id)
{
var expense = await _unitOfWork.Expenses.GetByIdAsync(id);
if (expense != null)
{
if (!string.IsNullOrEmpty(expense.ReceiptFilePath))
await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, expense.ReceiptFilePath);
// Reverse account balances
await _accountBalanceService.CreditAsync(expense.ExpenseAccountId, expense.Amount);
await _accountBalanceService.DebitAsync(expense.PaymentAccountId, expense.Amount);
}
await _unitOfWork.Expenses.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Expense deleted.";
return RedirectToAction(nameof(Index));
}
// ── Receipt ──────────────────────────────────────────────────────────────
/// <summary>
/// Streams the receipt file from Azure Blob Storage. Images are served inline
/// (<c>Content-Disposition: inline</c>) so the browser can preview them in a tab; PDFs are
/// served as downloads (<c>Content-Disposition: attachment</c>) because inline PDF rendering
/// varies between browsers and can cause a poor UX.
/// </summary>
public async Task<IActionResult> ViewReceipt(int id)
{
var expense = await _unitOfWork.Expenses.GetByIdAsync(id);
if (expense == null || string.IsNullOrEmpty(expense.ReceiptFilePath))
return NotFound();
var result = await _blobStorage.DownloadAsync(_storageSettings.Containers.ReceiptImages, expense.ReceiptFilePath);
if (!result.Success) return NotFound();
// Inline for images so the browser previews them; attachment for PDFs triggers download
var ext = Path.GetExtension(expense.ReceiptFilePath).ToLowerInvariant();
var contentType = result.ContentType.Length > 0 ? result.ContentType : MimeFromExt(ext);
var filename = $"Receipt-{expense.ExpenseNumber}{ext}";
Response.Headers["Content-Disposition"] = ext == ".pdf"
? $"attachment; filename=\"{filename}\""
: $"inline; filename=\"{filename}\"";
return File(result.Content, contentType);
}
/// <summary>
/// Removes the receipt attachment from an expense without deleting the expense itself. The
/// blob is permanently deleted from Azure Blob Storage and the database path is nulled in the
/// same save operation to keep them in sync.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
public async Task<IActionResult> DeleteReceipt(int id)
{
var expense = await _unitOfWork.Expenses.GetByIdAsync(id);
if (expense == null) return NotFound();
if (!string.IsNullOrEmpty(expense.ReceiptFilePath))
{
await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, expense.ReceiptFilePath);
expense.ReceiptFilePath = null;
expense.UpdatedAt = DateTime.UtcNow;
expense.UpdatedBy = (await _userManager.GetUserAsync(User))?.Email;
await _unitOfWork.Expenses.UpdateAsync(expense);
await _unitOfWork.CompleteAsync();
}
TempData["Success"] = "Receipt removed.";
return RedirectToAction(nameof(Details), new { id });
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>
/// Loads all dropdowns required by the Create and Edit expense views: expense accounts
/// (Expense and CostOfGoods types), payment accounts (Checking, Savings, CreditCard sub-types),
/// active vendors, open jobs (for cost allocation), and payment methods. A single
/// <c>FindAsync</c> fetches all accounts and then in-memory LINQ filters split them into the
/// relevant subsets to avoid multiple database round trips.
/// </summary>
private async Task PopulateDropdownsAsync()
{
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
ViewBag.ExpenseAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.PaymentAccounts = allAccounts
.Where(a => a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.Vendors = (await _unitOfWork.Vendors.FindAsync(s => s.IsActive))
.OrderBy(s => s.CompanyName)
.Select(s => new SelectListItem(s.CompanyName, s.Id.ToString()))
.ToList();
ViewBag.Jobs = (await _unitOfWork.Jobs.FindAsync(j =>
j.JobStatus.StatusCode != "COMPLETED" &&
j.JobStatus.StatusCode != "CANCELLED" &&
j.JobStatus.StatusCode != "DELIVERED"))
.OrderBy(j => j.JobNumber)
.Select(j => new SelectListItem($"{j.JobNumber} {j.Description ?? "No description"}", j.Id.ToString()))
.ToList();
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
.Select(m => new SelectListItem(m.ToString(), ((int)m).ToString()))
.ToList();
}
/// <summary>
/// Generates a sequential expense number in the format <c>EXP-YYMM-####</c>. Uses
/// <c>IgnoreQueryFilters()</c> so that soft-deleted expense records are included in the
/// max-sequence scan, preventing number reuse after deletion.
/// </summary>
private async Task<string> GenerateExpenseNumberAsync()
{
var prefix = $"EXP-{DateTime.Now:yyMM}-";
var last = await _context.Expenses
.IgnoreQueryFilters()
.Where(e => e.ExpenseNumber.StartsWith(prefix))
.OrderByDescending(e => e.ExpenseNumber)
.Select(e => e.ExpenseNumber)
.FirstOrDefaultAsync();
int next = 1;
if (last != null && int.TryParse(last[prefix.Length..], out int num))
next = num + 1;
return $"{prefix}{next:D4}";
}
/// <summary>
/// Uploads a receipt file to Azure Blob Storage at
/// <c>{companyId}/expense-receipts/{expenseId}{ext}</c>. Returns the blob name (relative
/// path) on success or <c>null</c> on failure, allowing the caller to continue saving the
/// expense even when storage is unavailable.
/// </summary>
private async Task<string?> UploadReceiptAsync(IFormFile file, int expenseId, int companyId)
{
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
var blobName = $"{companyId}/expense-receipts/{expenseId}{ext}";
var contentType = MimeFromExt(ext);
using var stream = file.OpenReadStream();
var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, contentType);
if (!result.Success)
{
_logger.LogError("Receipt upload failed for expense {Id}: {Error}", expenseId, result.ErrorMessage);
return null;
}
return blobName;
}
/// <summary>
/// Validates a receipt file against the allowed extension whitelist and the 10 MB size cap.
/// Returns <c>false</c> and sets <paramref name="error"/> when validation fails.
/// </summary>
private static bool IsValidReceiptFile(IFormFile file, out string error)
{
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedReceiptTypes.Contains(ext))
{
error = $"File type '{ext}' is not allowed. Accepted types: {string.Join(", ", AllowedReceiptTypes)}";
return false;
}
if (file.Length > MaxReceiptBytes)
{
error = "Receipt file must be 10 MB or smaller.";
return false;
}
error = string.Empty;
return true;
}
private static string MimeFromExt(string ext) => ext switch
{
".pdf" => "application/pdf",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
// ── AI: Account Suggestion ────────────────────────────────────────────────
/// <summary>
/// AI-powered account categorisation for a single expense entry. If the caller does not
/// supply <c>AvailableAccounts</c>, the controller fetches the active Expense and CostOfGoods
/// accounts and merges them into the request before forwarding to
/// <see cref="IAccountingAiService.SuggestAccountAsync"/>. Called on blur from the expense
/// account dropdown when the user types a memo, helping reduce mis-categorisation. Rate-limited
/// to the <c>Ai</c> policy to control Anthropic API usage.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request)
{
if (request == null)
return Json(new { success = false, error = "Invalid request." });
if (!request.AvailableAccounts.Any())
{
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
request.AvailableAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods)
.Select(a => new AccountSummary
{
Id = a.Id,
AccountNumber = a.AccountNumber,
Name = a.Name,
AccountType = a.AccountType.ToString(),
AccountSubType = a.AccountSubType.ToString()
})
.ToList();
}
var result = await _accountingAi.SuggestAccountAsync(request);
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.AccountSuggest, inputLength: (request.Description?.Length ?? 0));
return Json(result);
}
}
@@ -0,0 +1,393 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Application.DTOs.GiftCertificate;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Manages gift certificate issuance, redemption tracking, voiding, and PDF generation.
/// Gift certificates progress through five statuses: Active → PartiallyRedeemed → FullyRedeemed
/// (automatic via invoice redemption), or Active/PartiallyRedeemed → Voided (admin action), or
/// Active → Expired (time-based, detected lazily on Index load). Certificate codes use the
/// GC-YYMM-#### format scoped per company, matching the INV- and DEP- numbering conventions
/// across the billing module. Redemptions are recorded separately as child records so the full
/// redemption history survives even if the parent certificate is voided.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInvoices)]
public class GiftCertificatesController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<GiftCertificatesController> _logger;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IPdfService _pdfService;
public GiftCertificatesController(
IUnitOfWork unitOfWork,
IMapper mapper,
ILogger<GiftCertificatesController> logger,
UserManager<ApplicationUser> userManager,
IPdfService pdfService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
_userManager = userManager;
_pdfService = pdfService;
}
/// <summary>
/// Displays the gift certificate list with optional search and status filtering, and lazily
/// transitions any Active certificates that have passed their expiry date to Expired status.
/// Lazy expiration is used rather than a background job or DB trigger because the certificate
/// volume is low and the mutation is idempotent; this ensures the list is always self-consistent
/// without requiring a separate scheduler. Stats show only certificates with spendable balances
/// (Active or PartiallyRedeemed) so the total outstanding liability is immediately visible.
/// </summary>
public async Task<IActionResult> Index(string? searchTerm, string? statusFilter)
{
var certs = await _unitOfWork.GiftCertificates.FindAsync(
gc => true, false,
gc => gc.RecipientCustomer,
gc => gc.PurchasingCustomer);
// Expire any Active certs past their expiry date
var now = DateTime.UtcNow;
foreach (var cert in certs.Where(c => c.Status == GiftCertificateStatus.Active
&& c.ExpiryDate.HasValue && c.ExpiryDate.Value < now))
{
cert.Status = GiftCertificateStatus.Expired;
await _unitOfWork.GiftCertificates.UpdateAsync(cert);
}
await _unitOfWork.CompleteAsync();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var term = searchTerm.Trim().ToLower();
certs = certs.Where(gc =>
gc.CertificateCode.ToLower().Contains(term) ||
(gc.RecipientName?.ToLower().Contains(term) ?? false) ||
(gc.RecipientCustomer?.CompanyName?.ToLower().Contains(term) ?? false) ||
(gc.RecipientEmail?.ToLower().Contains(term) ?? false)
).ToList();
}
if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse<GiftCertificateStatus>(statusFilter, out var parsedStatus))
certs = certs.Where(gc => gc.Status == parsedStatus).ToList();
var dtos = certs
.OrderByDescending(gc => gc.IssueDate)
.Select(gc => new GiftCertificateListDto
{
Id = gc.Id,
CertificateCode = gc.CertificateCode,
OriginalAmount = gc.OriginalAmount,
RedeemedAmount = gc.RedeemedAmount,
RemainingBalance = gc.RemainingBalance,
RecipientName = gc.RecipientCustomer != null
? (gc.RecipientCustomer.CompanyName ?? $"{gc.RecipientCustomer.ContactFirstName} {gc.RecipientCustomer.ContactLastName}".Trim())
: gc.RecipientName,
RecipientEmail = gc.RecipientEmail,
IssuedReason = gc.IssuedReason,
Status = gc.Status,
IssueDate = gc.IssueDate,
ExpiryDate = gc.ExpiryDate
})
.ToList();
ViewBag.SearchTerm = searchTerm;
ViewBag.StatusFilter = statusFilter;
ViewBag.TotalActive = dtos.Count(d => d.Status == GiftCertificateStatus.Active || d.Status == GiftCertificateStatus.PartiallyRedeemed);
ViewBag.TotalValue = dtos.Where(d => d.Status == GiftCertificateStatus.Active || d.Status == GiftCertificateStatus.PartiallyRedeemed)
.Sum(d => d.RemainingBalance);
return View(dtos);
}
/// <summary>
/// Shows full certificate detail including all non-deleted redemption records, each annotated with
/// the human-readable invoice number. Invoice numbers are resolved in a loop rather than a join
/// because the redemption DTO is constructed manually (not via AutoMapper) to avoid over-fetching
/// invoice data; the loop is bounded by the number of redemptions which is typically very small.
/// Recipient name falls back to the free-text <c>RecipientName</c> field when no customer record
/// is linked, supporting gift certificates issued to non-customers (e.g. promotional give-aways).
/// </summary>
public async Task<IActionResult> Details(int id)
{
var cert = await _unitOfWork.GiftCertificates.GetByIdAsync(id, false,
gc => gc.RecipientCustomer,
gc => gc.PurchasingCustomer,
gc => gc.IssuedBy,
gc => gc.Redemptions);
if (cert == null) return NotFound();
var dto = new GiftCertificateDto
{
Id = cert.Id,
CertificateCode = cert.CertificateCode,
OriginalAmount = cert.OriginalAmount,
RedeemedAmount = cert.RedeemedAmount,
RemainingBalance = cert.RemainingBalance,
RecipientCustomerId = cert.RecipientCustomerId,
RecipientName = cert.RecipientCustomer != null
? (cert.RecipientCustomer.CompanyName ?? $"{cert.RecipientCustomer.ContactFirstName} {cert.RecipientCustomer.ContactLastName}".Trim())
: cert.RecipientName,
RecipientEmail = cert.RecipientEmail,
IssuedReason = cert.IssuedReason,
PurchasePrice = cert.PurchasePrice,
PurchasingCustomerId = cert.PurchasingCustomerId,
PurchasingCustomerName = cert.PurchasingCustomer != null
? (cert.PurchasingCustomer.CompanyName ?? $"{cert.PurchasingCustomer.ContactFirstName} {cert.PurchasingCustomer.ContactLastName}".Trim())
: null,
Status = cert.Status,
IssueDate = cert.IssueDate,
ExpiryDate = cert.ExpiryDate,
Notes = cert.Notes,
IssuedByName = cert.IssuedBy != null
? $"{cert.IssuedBy.FirstName} {cert.IssuedBy.LastName}".Trim()
: null
};
dto.Redemptions = cert.Redemptions
.Where(r => !r.IsDeleted)
.OrderByDescending(r => r.RedeemedDate)
.Select(r => new GiftCertificateRedemptionDto
{
Id = r.Id,
GiftCertificateId = r.GiftCertificateId,
InvoiceId = r.InvoiceId,
AmountRedeemed = r.AmountRedeemed,
RedeemedDate = r.RedeemedDate
}).ToList();
foreach (var redemption in dto.Redemptions)
{
var inv = await _unitOfWork.Invoices.GetByIdAsync(redemption.InvoiceId);
if (inv != null) redemption.InvoiceNumber = inv.InvoiceNumber;
}
return View(dto);
}
/// <summary>
/// Renders the Create form with the customer dropdown pre-populated for optional recipient and
/// purchaser selection.
/// </summary>
public async Task<IActionResult> Create()
{
await PopulateCustomersAsync();
return View(new CreateGiftCertificateDto());
}
/// <summary>
/// Issues a new gift certificate with an auto-generated GC-YYMM-#### code and sets its initial
/// status to Active. Purchase price and purchasing customer are only recorded when the issued
/// reason is <c>Sold</c>; for goodwill, promotional, and refund certificates there is no
/// associated sale transaction, so those fields are explicitly nulled to prevent misleading
/// financial data. The certificate is always created with <c>RedeemedAmount = 0</c> — redemptions
/// are recorded separately via invoice payment flow.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateGiftCertificateDto dto)
{
if (!ModelState.IsValid)
{
await PopulateCustomersAsync();
return View(dto);
}
try
{
var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser?.CompanyId ?? 0;
var code = await GenerateCertificateCodeAsync(companyId);
var cert = new GiftCertificate
{
CertificateCode = code,
OriginalAmount = dto.Amount,
RedeemedAmount = 0,
RecipientCustomerId = dto.RecipientCustomerId,
RecipientName = dto.RecipientName,
RecipientEmail = dto.RecipientEmail,
IssuedReason = dto.IssuedReason,
PurchasePrice = dto.IssuedReason == GiftCertificateIssuedReason.Sold ? dto.PurchasePrice : null,
PurchasingCustomerId = dto.IssuedReason == GiftCertificateIssuedReason.Sold ? dto.PurchasingCustomerId : null,
Status = GiftCertificateStatus.Active,
IssueDate = DateTime.UtcNow,
ExpiryDate = dto.ExpiryDate,
Notes = dto.Notes,
IssuedById = currentUser?.Id,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow,
CreatedBy = currentUser?.Email
};
await _unitOfWork.GiftCertificates.AddAsync(cert);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Gift certificate {code} for {dto.Amount:C} created successfully.";
return RedirectToAction(nameof(Details), new { id = cert.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating gift certificate");
this.ToastError("An error occurred creating the gift certificate.");
await PopulateCustomersAsync();
return View(dto);
}
}
/// <summary>
/// Voids a gift certificate, permanently cancelling any remaining balance. Requires
/// <c>CompanyAdminOnly</c> because voiding has revenue implications — it eliminates a liability
/// the company owes to the certificate holder. Voiding a FullyRedeemed certificate is blocked
/// because it would have no practical effect (the balance is already zero) and could create
/// confusion in audit logs.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> Void(int id)
{
var cert = await _unitOfWork.GiftCertificates.GetByIdAsync(id);
if (cert == null) return NotFound();
if (cert.Status == GiftCertificateStatus.FullyRedeemed)
{
TempData["Error"] = "Cannot void a fully redeemed gift certificate.";
return RedirectToAction(nameof(Details), new { id });
}
cert.Status = GiftCertificateStatus.Voided;
cert.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.GiftCertificates.UpdateAsync(cert);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Gift certificate {cert.CertificateCode} has been voided.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Generates and streams a branded gift certificate PDF. The DTO is built manually here (rather
/// than reusing the Details action's DTO) so that the PDF endpoint is self-contained and does not
/// depend on view state from a prior page load. Company logo bytes and content type are passed
/// directly to the PDF service so QuestPDF can embed the logo without a second HTTP request.
/// The file name includes the certificate code so the downloaded file is immediately identifiable
/// in the user's downloads folder.
/// </summary>
public async Task<IActionResult> DownloadPdf(int id)
{
var cert = await _unitOfWork.GiftCertificates.GetByIdAsync(id, false,
gc => gc.RecipientCustomer,
gc => gc.PurchasingCustomer,
gc => gc.IssuedBy,
gc => gc.Redemptions);
if (cert == null) return NotFound();
var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser?.CompanyId ?? 0;
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
var companyInfo = new Application.DTOs.Company.CompanyInfoDto
{
CompanyName = company?.CompanyName ?? string.Empty,
Phone = company?.Phone,
Address = company?.Address,
City = company?.City,
State = company?.State,
ZipCode = company?.ZipCode,
PrimaryContactEmail = company?.PrimaryContactEmail
};
var dto = new GiftCertificateDto
{
Id = cert.Id,
CertificateCode = cert.CertificateCode,
OriginalAmount = cert.OriginalAmount,
RedeemedAmount = cert.RedeemedAmount,
RemainingBalance = cert.RemainingBalance,
RecipientCustomerId = cert.RecipientCustomerId,
RecipientName = cert.RecipientCustomer != null
? (cert.RecipientCustomer.CompanyName ?? $"{cert.RecipientCustomer.ContactFirstName} {cert.RecipientCustomer.ContactLastName}".Trim())
: cert.RecipientName,
RecipientEmail = cert.RecipientEmail,
IssuedReason = cert.IssuedReason,
PurchasePrice = cert.PurchasePrice,
PurchasingCustomerId = cert.PurchasingCustomerId,
PurchasingCustomerName = cert.PurchasingCustomer != null
? (cert.PurchasingCustomer.CompanyName ?? $"{cert.PurchasingCustomer.ContactFirstName} {cert.PurchasingCustomer.ContactLastName}".Trim())
: null,
Status = cert.Status,
IssueDate = cert.IssueDate,
ExpiryDate = cert.ExpiryDate,
Notes = cert.Notes,
IssuedByName = cert.IssuedBy != null
? $"{cert.IssuedBy.FirstName} {cert.IssuedBy.LastName}".Trim()
: null
};
try
{
var pdfBytes = await _pdfService.GenerateGiftCertificatePdfAsync(dto, company?.LogoData, company?.LogoContentType, companyInfo);
return File(pdfBytes, "application/pdf", $"GiftCertificate-{cert.CertificateCode}.pdf");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating gift certificate PDF for {Code}", cert.CertificateCode);
TempData["Error"] = "Could not generate PDF.";
return RedirectToAction(nameof(Details), new { id });
}
}
/// <summary>
/// Generates the next sequential GC-YYMM-#### certificate code for the given company. The
/// sequence resets each calendar month (the YYMM component changes) which keeps codes short and
/// readable. <c>ignoreQueryFilters: true</c> is passed so soft-deleted certificates from the same
/// month are included in the max calculation, preventing number reuse after a certificate is
/// deleted. The zero-padded 4-digit suffix supports up to 9999 certificates per company per month
/// before overflow.
/// </summary>
private async Task<string> GenerateCertificateCodeAsync(int companyId)
{
var prefix = $"GC-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
var companyCerts = await _unitOfWork.GiftCertificates.FindAsync(
c => c.CompanyId == companyId && c.CertificateCode.StartsWith(prefix), true);
var maxNum = companyCerts
.Select(c => { int.TryParse(c.CertificateCode.Replace(prefix, ""), out int n); return n; })
.DefaultIfEmpty(0).Max();
return $"{prefix}{(maxNum + 1):D4}";
}
/// <summary>
/// Populates <c>ViewBag.Customers</c> with active customers for the recipient and purchaser
/// dropdowns. A "— None —" placeholder is prepended because both fields are optional: a gift
/// certificate can be issued to a non-customer recipient (no customer record exists) and a
/// goodwill/promotional certificate has no purchaser at all.
/// </summary>
private async Task PopulateCustomersAsync()
{
var customers = await _unitOfWork.Customers.FindAsync(c => c.IsActive);
var list = customers
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
})
.ToList();
list.Insert(0, new SelectListItem { Value = "", Text = "— None (non-customer recipient) —" });
ViewBag.Customers = list;
}
}
@@ -0,0 +1,129 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace PowderCoating.Web.Controllers
{
[Authorize]
public class HelpController : Controller
{
/// <summary>
/// Renders the Help Center landing page (article index). All help articles are static Razor views stored under Views/Help/ — no database queries are needed.
/// </summary>
public IActionResult Index()
{
return View();
}
/// <summary>
/// Serves the Getting Started help article covering initial onboarding steps and the Setup Wizard.
/// </summary>
public IActionResult GettingStarted()
{
return View();
}
/// <summary>
/// Serves the Customers help article explaining commercial vs non-commercial types, pricing tiers, and credit limits.
/// </summary>
public IActionResult Customers()
{
return View();
}
/// <summary>
/// Serves the Vendors help article covering supplier management and payment terms.
/// </summary>
public IActionResult Vendors()
{
return View();
}
/// <summary>
/// Serves the Shop Workers help article describing roles, assignment to jobs, and maintenance tasks.
/// </summary>
public IActionResult ShopWorkers()
{
return View();
}
/// <summary>
/// Serves the Equipment help article explaining the equipment status lifecycle and maintenance scheduling.
/// </summary>
public IActionResult Equipment()
{
return View();
}
/// <summary>
/// Serves the User Profile help article covering photo upload, appearance preferences, and password/email changes.
/// </summary>
public IActionResult UserProfile()
{
return View();
}
/// <summary>
/// Serves the Jobs help article explaining the 16-status job lifecycle, priorities, worker assignment, and time entries.
/// </summary>
public IActionResult Jobs()
{
return View();
}
/// <summary>
/// Serves the Quotes help article covering the multi-item wizard, AI Photo Quoting, and quote-to-job conversion.
/// </summary>
public IActionResult Quotes()
{
return View();
}
/// <summary>
/// Serves the Invoices help article covering invoice creation from a job, partial payments, voids, and PDF download.
/// </summary>
public IActionResult Invoices()
{
return View();
}
/// <summary>
/// Serves the Inventory help article explaining stock tracking, transaction types, and reorder alerts.
/// </summary>
public IActionResult Inventory()
{
return View();
}
/// <summary>
/// Serves the Purchase Orders help article covering PO creation, submission, receiving, and conversion to vendor bills.
/// </summary>
public IActionResult PurchaseOrders()
{
return View();
}
/// <summary>
/// Serves the Accounts Payable help article describing vendor bills, the AP ledger, and payment tracking.
/// </summary>
public IActionResult AccountsPayable()
{
return View();
}
/// <summary>
/// Serves the Reports help article summarising the 24 available report actions including P&amp;L, AR Aging, and PDF exports.
/// </summary>
public IActionResult Reports()
{
return View();
}
/// <summary>
/// Serves the Settings help article covering company settings, named ovens, pricing tiers, and platform configuration.
/// </summary>
public IActionResult Settings()
{
return View();
}
}
}
@@ -0,0 +1,101 @@
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Handles the application root URL and static informational pages
/// (Privacy, Terms of Service, SLA, DPA, Security, Accessibility).
/// Also provides the global error handler endpoint used by the exception
/// handling middleware pipeline.
/// </summary>
public class HomeController : Controller
{
private readonly ILogger<HomeController> _logger;
public HomeController(ILogger<HomeController> logger)
{
_logger = logger;
}
/// <summary>
/// Handles requests to the application root (<c>/</c>). Authenticated users are
/// immediately redirected to the Dashboard; unauthenticated visitors are sent to
/// the Identity login page. This ensures no content is ever rendered at the root
/// URL — the root is purely a routing decision point.
/// </summary>
public IActionResult Index()
{
if (User.Identity?.IsAuthenticated == true)
{
return RedirectToAction("Index", "Dashboard");
}
// Otherwise redirect to the login page
return Redirect("/Identity/Account/Login");
}
/// <summary>Renders the Privacy Policy static page.</summary>
public IActionResult Privacy()
{
return View();
}
/// <summary>Renders the Terms of Service static page.</summary>
public IActionResult TermsOfService()
{
return View();
}
/// <summary>Renders the Service Level Agreement static page.</summary>
public IActionResult ServiceLevelAgreement()
{
return View();
}
/// <summary>Renders the Data Processing Addendum (DPA) static page.</summary>
public IActionResult DataProcessingAddendum()
{
return View();
}
/// <summary>Renders the Security overview static page.</summary>
public IActionResult Security()
{
return View();
}
/// <summary>Renders the Accessibility statement static page.</summary>
public IActionResult Accessibility()
{
return View();
}
/// <summary>
/// Global error handler endpoint — invoked by the ASP.NET Core exception handling
/// middleware when an unhandled exception propagates out of any controller.
/// Logs the exception with path, user identity, and trace Id for structured
/// log correlation, then renders a user-friendly error view.
/// Response caching is disabled (<c>NoStore</c>) to prevent error pages from
/// being cached and served to subsequent requests without a real error.
/// </summary>
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
// Get the exception details from the exception handler
var exceptionHandlerPathFeature = HttpContext.Features.Get<IExceptionHandlerPathFeature>();
if (exceptionHandlerPathFeature?.Error != null)
{
var exception = exceptionHandlerPathFeature.Error;
var path = exceptionHandlerPathFeature.Path;
_logger.LogError(exception,
"Unhandled exception occurred. Path: {Path}, User: {User}, TraceId: {TraceId}",
path,
User.Identity?.Name ?? "Anonymous",
HttpContext.TraceIdentifier);
}
return View();
}
}
@@ -0,0 +1,189 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Web.Extensions;
namespace PowderCoating.Web.Controllers;
[Authorize]
public class InAppNotificationsController : Controller
{
private readonly ApplicationDbContext _db;
private readonly ITenantContext _tenant;
public InAppNotificationsController(ApplicationDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
/// <summary>
/// Displays the paginated notification history page (read and unread). SuperAdmins see only platform-level notifications (CompanyId 0) via IgnoreQueryFilters; regular users rely on the global query filter for tenant isolation.
/// </summary>
public async Task<IActionResult> Index(int pageNumber = 1, int pageSize = 25)
{
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 ? pageSize : 25;
IQueryable<PowderCoating.Core.Entities.InAppNotification> query;
if (_tenant.IsPlatformAdmin())
{
query = _db.InAppNotifications
.IgnoreQueryFilters()
.Where(n => !n.IsDeleted && n.CompanyId == 0);
}
else
{
query = _db.InAppNotifications.AsQueryable();
}
var totalCount = await query.CountAsync();
var items = await query
.AsNoTracking()
.OrderByDescending(n => n.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(n => new
{
n.Id,
n.Title,
n.Message,
n.Link,
n.NotificationType,
n.IsRead,
n.ReadAt,
CreatedAt = n.CreatedAt
})
.ToListAsync();
ViewBag.TotalCount = totalCount;
ViewBag.PageNumber = pageNumber;
ViewBag.PageSize = pageSize;
ViewBag.TotalPages = (int)Math.Ceiling((double)totalCount / pageSize);
return View(items);
}
/// <summary>
/// AJAX endpoint that returns the 20 most recent notifications (read and unread) for the bell dropdown. The response includes a count of unread items so the badge can be updated without a separate round-trip.
/// </summary>
[HttpGet]
public async Task<IActionResult> Recent()
{
IQueryable<PowderCoating.Core.Entities.InAppNotification> query;
if (_tenant.IsPlatformAdmin())
{
query = _db.InAppNotifications
.IgnoreQueryFilters()
.Where(n => !n.IsDeleted && n.CompanyId == 0);
}
else
{
query = _db.InAppNotifications.AsQueryable();
}
var tz = ViewBag.CompanyTimeZone as string;
var items = await query
.AsNoTracking()
.OrderByDescending(n => n.CreatedAt)
.Take(20)
.Select(n => new { n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.IsRead, n.CreatedAt })
.ToListAsync();
var unreadCount = items.Count(n => !n.IsRead);
return Json(new { count = unreadCount, items = items.Select(n => new {
n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.IsRead,
CreatedAt = n.CreatedAt.Tz(tz).ToString("MMM d, h:mm tt")
}) });
}
/// <summary>
/// AJAX endpoint that returns only unread notifications (up to 20) plus an unread count for the bell badge. Used by the initial page load poll; after that the bell relies on Recent to show history.
/// </summary>
[HttpGet]
public async Task<IActionResult> Unread()
{
IQueryable<PowderCoating.Core.Entities.InAppNotification> query;
if (_tenant.IsPlatformAdmin())
{
// SuperAdmins see only platform-level notifications (CompanyId = 0)
query = _db.InAppNotifications
.IgnoreQueryFilters()
.Where(n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead);
}
else
{
// Regular users see their company's notifications (global filter handles tenant isolation)
query = _db.InAppNotifications.Where(n => !n.IsRead);
}
var tz = ViewBag.CompanyTimeZone as string;
var items = await query
.AsNoTracking()
.OrderByDescending(n => n.CreatedAt)
.Take(20)
.Select(n => new { n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.CreatedAt })
.ToListAsync();
return Json(new { count = items.Count, items = items.Select(n => new {
n.Id, n.Title, n.Message, n.Link, n.NotificationType,
CreatedAt = n.CreatedAt.Tz(tz).ToString("MMM d, h:mm tt")
}) });
}
/// <summary>
/// Marks a single notification as read and records the timestamp. Tenant isolation is enforced manually for SuperAdmins (CompanyId 0 filter) because IgnoreQueryFilters bypasses the global company filter.
/// </summary>
[HttpPost]
public async Task<IActionResult> MarkRead(int id)
{
var notification = _tenant.IsPlatformAdmin()
? await _db.InAppNotifications.IgnoreQueryFilters().FirstOrDefaultAsync(n => n.Id == id && n.CompanyId == 0 && !n.IsDeleted)
: await _db.InAppNotifications.FirstOrDefaultAsync(n => n.Id == id);
if (notification == null) return NotFound();
notification.IsRead = true;
notification.ReadAt = DateTime.UtcNow;
notification.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Json(new { success = true });
}
/// <summary>
/// Marks every unread notification as read for the current user's scope in a single SaveChanges call for efficiency. Returns the count of items marked so the UI can update the badge without refetching.
/// </summary>
[HttpPost]
public async Task<IActionResult> MarkAllRead()
{
var now = DateTime.UtcNow;
List<PowderCoating.Core.Entities.InAppNotification> unread;
if (_tenant.IsPlatformAdmin())
{
unread = await _db.InAppNotifications.IgnoreQueryFilters()
.Where(n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead)
.ToListAsync();
}
else
{
unread = await _db.InAppNotifications.Where(n => !n.IsRead).ToListAsync();
}
foreach (var n in unread)
{
n.IsRead = true;
n.ReadAt = now;
n.UpdatedAt = now;
}
await _db.SaveChangesAsync();
return Json(new { success = true, count = unread.Count });
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Lightweight API endpoints consumed by the client-side item wizard.
/// Intentionally uses <c>CanManageJobs</c> (not <c>CanManageProducts</c>) so that
/// any user who can create quotes or jobs can also save items to the catalog directly
/// from the wizard without needing a separate catalog-management permission.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public class ItemWizardController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly ILogger<ItemWizardController> _logger;
public ItemWizardController(IUnitOfWork unitOfWork, ITenantContext tenantContext,
ILogger<ItemWizardController> logger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_logger = logger;
}
/// <summary>
/// Returns active catalog categories for the current company with their full hierarchy path
/// (e.g. "Automotive > Wheels") so the wizard dropdown can distinguish categories that share
/// the same name but sit under different parents. Paths are built in-memory after a single
/// query — no extra round-trips needed since ParentCategoryId is a scalar field.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetCatalogCategories()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(Array.Empty<object>());
var cats = await _unitOfWork.CatalogCategories.FindAsync(
c => c.CompanyId == companyId.Value && !c.IsDeleted);
var lookup = cats.ToDictionary(c => c.Id);
string BuildPath(PowderCoating.Core.Entities.CatalogCategory cat)
{
var parts = new System.Collections.Generic.List<string> { cat.Name };
var current = cat;
while (current.ParentCategoryId.HasValue &&
lookup.TryGetValue(current.ParentCategoryId.Value, out var parent))
{
parts.Insert(0, parent.Name);
current = parent;
}
return string.Join(" > ", parts);
}
return Json(cats
.Select(c => new { c.Id, Name = BuildPath(c) })
.OrderBy(c => c.Name));
}
/// <summary>
/// Creates a new catalog item from the item wizard's Save-to-Catalog step.
/// Called via AJAX immediately when the user confirms the save — before the quote
/// or job form is submitted — so the catalog item is saved even if the user later
/// abandons the quote.
/// </summary>
[HttpPost]
public async Task<IActionResult> SaveToCatalog([FromBody] SaveToCatalogRequest request)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { ok = false, error = "No company context." });
if (string.IsNullOrWhiteSpace(request.Name))
return Json(new { ok = false, error = "Item name is required." });
if (request.CategoryId <= 0)
return Json(new { ok = false, error = "Please select a category." });
var category = await _unitOfWork.CatalogCategories.GetByIdAsync(request.CategoryId);
if (category == null || category.CompanyId != companyId.Value)
return Json(new { ok = false, error = "Invalid category." });
var item = new CatalogItem
{
CompanyId = companyId.Value,
Name = request.Name.Trim(),
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
CategoryId = request.CategoryId,
DefaultPrice = request.DefaultPrice,
ApproximateArea = request.ApproximateArea > 0 ? request.ApproximateArea : null,
DefaultEstimatedMinutes = request.DefaultEstimatedMinutes > 0 ? request.DefaultEstimatedMinutes : null,
DefaultRequiresSandblasting = request.DefaultRequiresSandblasting,
DefaultRequiresMasking = request.DefaultRequiresMasking,
IsActive = true,
DisplayOrder = 0
};
await _unitOfWork.CatalogItems.AddAsync(item);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Catalog item '{Name}' (id {Id}) created from item wizard by company {CompanyId}",
item.Name, item.Id, companyId.Value);
return Json(new { ok = true, id = item.Id, name = item.Name });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving catalog item from wizard");
return Json(new { ok = false, error = "An error occurred saving the item. Please try again." });
}
}
}
public class SaveToCatalogRequest
{
public string Name { get; set; } = string.Empty;
public int CategoryId { get; set; }
public decimal DefaultPrice { get; set; }
public string? Description { get; set; }
public decimal ApproximateArea { get; set; }
public int DefaultEstimatedMinutes { get; set; }
public bool DefaultRequiresSandblasting { get; set; }
public bool DefaultRequiresMasking { get; set; }
}
@@ -0,0 +1,358 @@
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Manages reusable job templates that pre-populate job items, coats, and prep services when a
/// new job is created. Templates are created either from scratch (Edit form) or by snapshotting an
/// existing job via <see cref="SaveJobAsTemplate"/>. A <c>UsageCount</c> field tracks how often
/// each template is applied, allowing admins to identify the most-used service configurations.
/// Template items store the same coating and prep-service structure as live job items, so the
/// full item wizard state can be replayed client-side on job creation without additional queries.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public class JobTemplatesController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly ApplicationDbContext _context;
public JobTemplatesController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
ApplicationDbContext context)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_context = context;
}
/// <summary>
/// Displays all non-deleted job templates for the current company, ordered by name, with their
/// linked customer and item counts. Uses the direct <c>_context</c> query (bypassing
/// <c>IUnitOfWork</c>) to leverage EF Core's filtered includes (<c>.Include(t => t.Items)</c>)
/// which are not exposed through the generic repository pattern.
/// </summary>
public async Task<IActionResult> Index()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var templates = await _context.JobTemplates
.Include(t => t.Customer)
.Include(t => t.Items)
.Where(t => !t.IsDeleted && t.CompanyId == companyId)
.OrderBy(t => t.Name)
.ToListAsync();
return View(templates);
}
/// <summary>
/// Shows the full template detail including all non-deleted items, each with their coats
/// (including the linked inventory item for color/powder info) and prep services (including the
/// prep service entity for the service name). Soft-deleted items, coats, and prep services are
/// excluded via EF filtered includes so the view reflects the current active configuration.
/// </summary>
public async Task<IActionResult> Details(int id)
{
var template = await _context.JobTemplates
.Include(t => t.Customer)
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.ThenInclude(c => c.InventoryItem)
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
.ThenInclude(p => p.PrepService)
.FirstOrDefaultAsync(t => t.Id == id && !t.IsDeleted);
if (template == null) return NotFound();
return View(template);
}
/// <summary>
/// Renders the Edit form for a template's header fields (name, description, linked customer,
/// special instructions, active flag). Template items are managed separately via the item wizard
/// on the job creation page; only the header metadata is editable here to keep the form simple.
/// </summary>
public async Task<IActionResult> Edit(int id)
{
var template = await _context.JobTemplates
.FirstOrDefaultAsync(t => t.Id == id && !t.IsDeleted);
if (template == null) return NotFound();
await PopulateCustomerDropdown(template.CustomerId);
return View(template);
}
/// <summary>
/// Saves changes to a template's header fields. Uses individual scalar parameters instead of a
/// DTO because only a handful of simple fields are editable here; a dedicated DTO would add
/// boilerplate for minimal benefit given the small scope of this form.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, string name, string? description, int? customerId, string? specialInstructions, bool isActive)
{
var template = await _unitOfWork.JobTemplates.GetByIdAsync(id);
if (template == null) return NotFound();
template.Name = name;
template.Description = description;
template.CustomerId = customerId;
template.SpecialInstructions = specialInstructions;
template.IsActive = isActive;
template.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.JobTemplates.UpdateAsync(template);
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Template updated successfully.";
return RedirectToAction(nameof(Details), new { id });
}
/// <summary>
/// Soft-deletes a job template. Existing jobs created from this template are unaffected because
/// job items are copied at creation time (not linked by FK), so removing the template does not
/// alter any live jobs.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
await _unitOfWork.JobTemplates.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Template deleted.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Snapshots an existing job into a new reusable template by deep-copying its items, coats, and
/// prep services. Each template item is saved and immediately given an ID before its coats and
/// prep services are inserted, because the child records need <c>JobTemplateItemId</c> as a FK.
/// This is why <c>CompleteAsync()</c> is called inside the item loop rather than once at the end.
/// The source job's <c>CustomerId</c> and <c>SpecialInstructions</c> are copied to the template
/// so that the template can pre-fill those fields when used; they can be overridden at job
/// creation time.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveJobAsTemplate(int jobId, string templateName, string? templateDescription)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var job = await _context.Jobs
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
.FirstOrDefaultAsync(j => j.Id == jobId && !j.IsDeleted);
if (job == null) return NotFound();
var template = new JobTemplate
{
Name = templateName.Trim(),
Description = templateDescription?.Trim(),
CustomerId = job.CustomerId,
SpecialInstructions = job.SpecialInstructions,
IsActive = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.JobTemplates.AddAsync(template);
await _unitOfWork.CompleteAsync();
// Copy items
int displayOrder = 1;
foreach (var item in job.JobItems.OrderBy(i => i.Id))
{
var templateItem = new JobTemplateItem
{
JobTemplateId = template.Id,
Description = item.Description,
Quantity = item.Quantity,
SurfaceAreaSqFt = item.SurfaceAreaSqFt,
CatalogItemId = item.CatalogItemId,
IsGenericItem = item.IsGenericItem,
IsLaborItem = item.IsLaborItem,
ManualUnitPrice = item.ManualUnitPrice,
RequiresSandblasting = item.RequiresSandblasting,
RequiresMasking = item.RequiresMasking,
IncludePrepCost = item.IncludePrepCost,
EstimatedMinutes = item.EstimatedMinutes,
Complexity = item.Complexity,
Notes = item.Notes,
DisplayOrder = displayOrder++,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.JobTemplateItems.AddAsync(templateItem);
await _unitOfWork.CompleteAsync();
// Copy coats
foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{
await _unitOfWork.JobTemplateItemCoats.AddAsync(new JobTemplateItemCoat
{
JobTemplateItemId = templateItem.Id,
CoatName = coat.CoatName,
Sequence = coat.Sequence,
InventoryItemId = coat.InventoryItemId,
ColorName = coat.ColorName,
VendorId = coat.VendorId,
ColorCode = coat.ColorCode,
Finish = coat.Finish,
CoverageSqFtPerLb = coat.CoverageSqFtPerLb,
TransferEfficiency = coat.TransferEfficiency,
PowderCostPerLb = coat.PowderCostPerLb,
Notes = coat.Notes,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
});
}
// Copy prep services
foreach (var prep in item.PrepServices)
{
await _unitOfWork.JobTemplateItemPrepServices.AddAsync(new JobTemplateItemPrepService
{
JobTemplateItemId = templateItem.Id,
PrepServiceId = prep.PrepServiceId,
EstimatedMinutes = prep.EstimatedMinutes,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
});
}
}
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Template \"{template.Name}\" saved successfully.";
return RedirectToAction(nameof(Details), new { id = template.Id });
}
/// <summary>
/// AJAX endpoint that returns all active templates for the current company as JSON, fully
/// expanded with items, coats, and prep services. This payload is consumed by the job creation
/// page's JavaScript to hydrate the item wizard when the user selects a template, so it must
/// contain every field the wizard needs to reconstruct the full item state client-side without
/// additional round-trips. Only active templates are returned so deactivated templates are no
/// longer selectable on new jobs.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetTemplatesJson()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var templates = await _context.JobTemplates
.Include(t => t.Customer)
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
.ThenInclude(p => p.PrepService)
.Where(t => !t.IsDeleted && t.CompanyId == companyId && t.IsActive)
.OrderBy(t => t.Name)
.ToListAsync();
var result = templates.Select(t => new
{
id = t.Id,
name = t.Name,
description = t.Description,
customerId = t.CustomerId,
customerName = t.Customer != null
? (t.Customer.CompanyName ?? $"{t.Customer.ContactFirstName} {t.Customer.ContactLastName}".Trim())
: null,
specialInstructions = t.SpecialInstructions,
usageCount = t.UsageCount,
items = t.Items.OrderBy(i => i.DisplayOrder).Select(i => new
{
description = i.Description,
quantity = i.Quantity,
surfaceAreaSqFt = i.SurfaceAreaSqFt,
catalogItemId = i.CatalogItemId,
isGenericItem = i.IsGenericItem,
isLaborItem = i.IsLaborItem,
manualUnitPrice = i.ManualUnitPrice,
requiresSandblasting = i.RequiresSandblasting,
requiresMasking = i.RequiresMasking,
includePrepCost = i.IncludePrepCost,
estimatedMinutes = i.EstimatedMinutes,
complexity = i.Complexity,
coats = i.Coats.OrderBy(c => c.Sequence).Select(c => new
{
coatName = c.CoatName,
sequence = c.Sequence,
inventoryItemId = c.InventoryItemId,
colorName = c.ColorName,
colorCode = c.ColorCode,
finish = c.Finish,
coverageSqFtPerLb = c.CoverageSqFtPerLb,
transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb
}),
prepServices = i.PrepServices.Select(p => new
{
prepServiceId = p.PrepServiceId,
prepServiceName = p.PrepService?.ServiceName,
estimatedMinutes = p.EstimatedMinutes
})
})
});
return Json(result);
}
/// <summary>
/// Increments the <c>UsageCount</c> on a template when a job is created from it. Called via
/// AJAX from the job creation page immediately after the job is saved. Silently succeeds even
/// if the template no longer exists (soft-deleted between page load and job save) to avoid
/// blocking the job creation flow over a non-critical analytics update.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> IncrementUsage(int id)
{
var template = await _unitOfWork.JobTemplates.GetByIdAsync(id);
if (template != null)
{
template.UsageCount++;
template.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.JobTemplates.UpdateAsync(template);
await _unitOfWork.CompleteAsync();
}
return Ok();
}
/// <summary>
/// Populates <c>ViewBag.Customers</c> with a <see cref="SelectList"/> of active customers in the
/// current company, used by the Edit form's optional customer association. The customer link on a
/// template pre-fills the customer field on new jobs created from that template, but it is
/// optional — templates representing generic services (e.g. "Standard Wheel Coat") are not tied
/// to any specific customer.
/// </summary>
private async Task PopulateCustomerDropdown(int? selectedId = null)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = await _unitOfWork.Customers.FindAsync(
c => c.CompanyId == companyId && !c.IsDeleted);
ViewBag.Customers = new SelectList(
customers.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
.Select(c => new { c.Id, Name = c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim() }),
"Id", "Name", selectedId);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,459 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Job;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Hubs;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public class JobsPriorityController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ApplicationDbContext _context;
private readonly ILogger<JobsPriorityController> _logger;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ITenantContext _tenantContext;
private readonly IHubContext<ShopHub> _shopHub;
public JobsPriorityController(
IUnitOfWork unitOfWork,
ApplicationDbContext context,
ILogger<JobsPriorityController> logger,
UserManager<ApplicationUser> userManager,
ITenantContext tenantContext,
IHubContext<ShopHub> shopHub)
{
_unitOfWork = unitOfWork;
_context = context;
_logger = logger;
_userManager = userManager;
_tenantContext = tenantContext;
_shopHub = shopHub;
}
/// <summary>
/// Renders the daily job-scheduling board for the given date (defaults to today).
/// <para>
/// Loads all jobs whose <c>ScheduledDate</c> matches the requested day, then
/// merges them with any existing <c>JobDailyPriority</c> drag-drop order records.
/// Jobs that have not yet been given an explicit display order receive
/// <c>int.MaxValue</c> so they sort to the bottom, after already-ordered jobs.
/// </para>
/// <para>
/// Maintenance records for the same day (status Scheduled or InProgress) are
/// also loaded and passed via <c>ViewBag.MaintenanceItems</c> so the view can
/// render a separate maintenance panel on the same board without a second round-trip.
/// Priority and worker option lists are serialised to JSON ViewBag properties for
/// use by the inline-edit JavaScript modals.
/// </para>
/// </summary>
// GET: JobsPriority (Job Schedule)
public async Task<IActionResult> Index(DateTime? date)
{
var today = date?.Date ?? DateTime.Today;
// Get all jobs scheduled for today with related data
var jobs = await _context.Jobs
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority)
.Include(j => j.AssignedUser)
.Where(j => j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today && !j.IsDeleted)
.ToListAsync();
// Get existing priority records for today
var existingPriorities = await _unitOfWork.JobDailyPriorities
.FindAsync(p => p.ScheduledDate.Date == today);
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
// Map to DTOs with display order
var jobDtos = jobs.Select(j => new JobDailyPriorityDto
{
Id = priorityDict.ContainsKey(j.Id) ? priorityDict[j.Id].Id : 0,
JobId = j.Id,
JobNumber = j.JobNumber,
CustomerName = j.Customer.CompanyName ?? $"{j.Customer.ContactFirstName} {j.Customer.ContactLastName}".Trim(),
StatusDisplayName = j.JobStatus.DisplayName,
StatusColorClass = j.JobStatus.ColorClass,
JobPriorityId = j.JobPriorityId,
PriorityDisplayName = j.JobPriority.DisplayName,
PriorityColorClass = j.JobPriority.ColorClass,
AssignedUserId = j.AssignedUserId,
AssignedWorkerName = j.AssignedUser?.FullName,
ScheduledDate = j.ScheduledDate,
DueDate = j.DueDate,
DisplayOrder = priorityDict.ContainsKey(j.Id) ? priorityDict[j.Id].DisplayOrder : int.MaxValue
})
.OrderBy(j => j.DisplayOrder)
.ThenBy(j => j.JobNumber)
.ToList();
// Get priorities and workers for modal options
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var workers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
.ToListAsync();
// Get maintenance records scheduled for today (Scheduled or InProgress)
var maintenanceItems = await _context.MaintenanceRecords
.Include(m => m.Equipment)
.Include(m => m.AssignedUser)
.Where(m => m.ScheduledDate.Date == today && !m.IsDeleted &&
(m.Status == MaintenanceStatus.Scheduled ||
m.Status == MaintenanceStatus.InProgress))
.OrderByDescending(m => (int)m.Priority)
.ThenBy(m => m.ScheduledDate)
.ToListAsync();
ViewBag.ScheduledDate = today;
ViewBag.MaintenanceItems = maintenanceItems;
ViewBag.PrioritiesJson = priorities.OrderBy(p => p.DisplayOrder)
.Select(p => new { id = p.Id, name = p.DisplayName, colorClass = p.ColorClass })
.ToList();
ViewBag.WorkersJson = workers.OrderBy(w => w.FirstName).ThenBy(w => w.LastName)
.Select(w => new { id = w.Id, name = w.FullName })
.ToList();
return View(jobDtos);
}
/// <summary>
/// Persists the new drag-drop display order for jobs on the daily scheduling board.
/// Called via AJAX after the user reorders cards; returns JSON so the page does not
/// reload.
/// <para>
/// For each job in the payload the action either updates the existing
/// <c>JobDailyPriority</c> row for today or creates a new one if the job has not
/// been ordered before. Using an upsert-style approach (check dictionary → update or
/// insert) avoids a separate EXISTS query per row while keeping the logic clear.
/// After saving, a <c>DailyBoardUpdated</c> SignalR event is pushed to the
/// company's shop-hub group so any connected shop-floor screen refreshes
/// automatically without polling.
/// </para>
/// </summary>
// POST: JobsPriority/UpdateOrder
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateOrder([FromBody] List<UpdateJobOrderDto> updates)
{
try
{
if (updates == null || !updates.Any())
{
return Json(new { success = false, message = "No updates provided" });
}
var today = DateTime.Today;
// Get all existing priority records for today
var existingPriorities = await _unitOfWork.JobDailyPriorities
.FindAsync(p => p.ScheduledDate.Date == today);
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
// Update or create priority records
foreach (var update in updates)
{
if (priorityDict.ContainsKey(update.JobId))
{
// Update existing record
var priority = priorityDict[update.JobId];
priority.DisplayOrder = update.DisplayOrder;
}
else
{
// Create new record
var newPriority = new JobDailyPriority
{
JobId = update.JobId,
ScheduledDate = today,
DisplayOrder = update.DisplayOrder
};
await _unitOfWork.JobDailyPriorities.AddAsync(newPriority);
}
}
await _unitOfWork.CompleteAsync();
var companyId = _tenantContext.GetCurrentCompanyId()?.ToString();
if (!string.IsNullOrEmpty(companyId))
await _shopHub.Clients.Group($"shop-{companyId}").SendAsync("DailyBoardUpdated");
return Json(new { success = true, message = "Job order updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating job order");
return Json(new { success = false, message = "Failed to update job order" });
}
}
/// <summary>
/// Changes the urgency priority of a job directly from the scheduling board via
/// an inline-edit modal, without navigating to the full Job Edit page.
/// <para>
/// <c>JobPriority</c> is a lookup-table entity (<c>JobPriorityLookup</c>), not an
/// enum, so the action validates that <paramref name="priorityId"/> maps to a known
/// lookup row before writing it to the job. The resolved
/// <c>DisplayName</c> and <c>ColorClass</c> are returned in the JSON response so
/// the JavaScript can update the board badge in-place without reloading.
/// A SignalR <c>DailyBoardUpdated</c> push notifies other connected clients.
/// </para>
/// </summary>
// POST: JobsPriority/UpdatePriority
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdatePriority(int jobId, int priorityId)
{
try
{
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId);
if (job == null)
{
return Json(new { success = false, message = "Job not found" });
}
var priority = await _unitOfWork.JobPriorityLookups.GetByIdAsync(priorityId);
if (priority == null)
{
return Json(new { success = false, message = "Priority not found" });
}
job.JobPriorityId = priorityId;
await _unitOfWork.Jobs.UpdateAsync(job);
await _unitOfWork.CompleteAsync();
var companyId = _tenantContext.GetCurrentCompanyId()?.ToString();
if (!string.IsNullOrEmpty(companyId))
await _shopHub.Clients.Group($"shop-{companyId}").SendAsync("DailyBoardUpdated");
return Json(new {
success = true,
message = "Priority updated successfully",
displayName = priority.DisplayName,
colorClass = priority.ColorClass
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating job priority");
return Json(new { success = false, message = "Failed to update priority" });
}
}
/// <summary>
/// Assigns or unassigns a worker (ASP.NET Identity user) to a job from the scheduling board.
/// Passing <c>null</c> or an empty <paramref name="workerId"/> clears the assignment.
/// <para>
/// Worker lookup is done via <c>UserManager</c> (not the repository layer) because
/// workers are stored as Identity users, not as <c>ShopWorker</c> entities.
/// The resolved <c>FullName</c> ("Unassigned" when cleared) is echoed back in JSON
/// so the board card updates immediately without a page reload.
/// A SignalR push keeps other open boards in sync.
/// </para>
/// </summary>
// POST: JobsPriority/UpdateWorker
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateWorker(int jobId, string? workerId)
{
try
{
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId);
if (job == null)
{
return Json(new { success = false, message = "Job not found" });
}
string workerName = "Unassigned";
if (!string.IsNullOrEmpty(workerId))
{
var user = await _userManager.FindByIdAsync(workerId);
if (user == null)
{
return Json(new { success = false, message = "User not found" });
}
workerName = user.FullName;
job.AssignedUserId = workerId;
}
else
{
job.AssignedUserId = null;
}
await _unitOfWork.Jobs.UpdateAsync(job);
await _unitOfWork.CompleteAsync();
var companyId = _tenantContext.GetCurrentCompanyId()?.ToString();
if (!string.IsNullOrEmpty(companyId))
await _shopHub.Clients.Group($"shop-{companyId}").SendAsync("DailyBoardUpdated");
return Json(new {
success = true,
message = "Worker assigned successfully",
workerName = workerName
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating job worker assignment");
return Json(new { success = false, message = "Failed to update worker assignment" });
}
}
/// <summary>
/// Updates the <c>ScheduledDate</c> on a job inline from the scheduling board.
/// Moving a job to a different date will cause it to disappear from the current
/// day's board view on the next render — this is intentional behaviour.
/// <para>
/// Returns both a human-readable formatted date ("Jan 15, 2026") and an ISO raw
/// value ("2026-01-15") in the JSON response so the JavaScript can update the
/// display label and any hidden form inputs simultaneously.
/// This action does NOT push a SignalR notification because rescheduling a job
/// affects a different day's board, not the currently viewed board.
/// </para>
/// </summary>
// POST: JobsPriority/UpdateScheduledDate
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateScheduledDate(int jobId, DateTime? scheduledDate)
{
try
{
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId);
if (job == null)
{
return Json(new { success = false, message = "Job not found" });
}
job.ScheduledDate = scheduledDate;
await _unitOfWork.Jobs.UpdateAsync(job);
await _unitOfWork.CompleteAsync();
return Json(new {
success = true,
message = "Scheduled date updated successfully",
scheduledDate = scheduledDate?.ToString("MMM dd, yyyy"),
scheduledDateRaw = scheduledDate?.ToString("yyyy-MM-dd")
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating job scheduled date");
return Json(new { success = false, message = "Failed to update scheduled date" });
}
}
/// <summary>
/// Assigns or unassigns a worker on a maintenance record directly from the
/// scheduling board's maintenance panel.
/// <para>
/// Because this action uses <c>DbContext.FindAsync</c> (which bypasses EF global
/// query filters), a manual company-ownership check is performed: if the current
/// user is not a SuperAdmin, the record's <c>CompanyId</c> must match the tenant
/// context to prevent cross-tenant data mutation. This guard is intentional and
/// must not be removed.
/// The raw <c>ApplicationDbContext</c> is used instead of <c>IUnitOfWork</c> here
/// because the maintenance repository does not expose <c>FindAsync(int)</c> with
/// the bypass semantics needed for this cross-filter lookup.
/// </para>
/// </summary>
// POST: JobsPriority/UpdateMaintenanceWorker
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateMaintenanceWorker(int maintenanceId, string? workerId)
{
try
{
var record = await _context.MaintenanceRecords.FindAsync(maintenanceId);
if (record == null || record.IsDeleted)
return Json(new { success = false, message = "Maintenance record not found" });
// FindAsync bypasses global query filters — verify company ownership explicitly
if (!_tenantContext.IsSuperAdmin() && record.CompanyId != _tenantContext.GetCurrentCompanyId())
return Json(new { success = false, message = "Access denied." });
string workerName = "Unassigned";
if (!string.IsNullOrEmpty(workerId))
{
var user = await _userManager.FindByIdAsync(workerId);
if (user == null)
return Json(new { success = false, message = "User not found" });
workerName = user.FullName;
record.AssignedUserId = workerId;
}
else
{
record.AssignedUserId = null;
}
record.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return Json(new { success = true, message = "Worker assigned successfully", workerName });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating maintenance worker assignment");
return Json(new { success = false, message = "Failed to update worker assignment" });
}
}
/// <summary>
/// Updates the customer-facing <c>DueDate</c> on a job from the scheduling board.
/// Unlike <c>ScheduledDate</c> (the internal work date), <c>DueDate</c> is the
/// date promised to the customer and is used to calculate overdue status.
/// <para>
/// Returns an <c>isOverdue</c> flag in JSON (true when the new date is in the past)
/// so the JavaScript can immediately toggle the overdue badge colour on the board
/// card without waiting for a reload.
/// </para>
/// </summary>
// POST: JobsPriority/UpdateDueDate
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateDueDate(int jobId, DateTime? dueDate)
{
try
{
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId);
if (job == null)
{
return Json(new { success = false, message = "Job not found" });
}
job.DueDate = dueDate;
await _unitOfWork.Jobs.UpdateAsync(job);
await _unitOfWork.CompleteAsync();
var isOverdue = dueDate.HasValue && dueDate.Value.Date < DateTime.Today;
return Json(new {
success = true,
message = "Due date updated successfully",
dueDate = dueDate?.ToString("MMM dd, yyyy"),
dueDateRaw = dueDate?.ToString("yyyy-MM-dd"),
isOverdue = isOverdue
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating job due date");
return Json(new { success = false, message = "Failed to update due date" });
}
}
}
@@ -0,0 +1,760 @@
using AutoMapper;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Maintenance;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanManageMaintenance)]
public class MaintenanceController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<MaintenanceController> _logger;
public MaintenanceController(
IUnitOfWork unitOfWork,
IMapper mapper,
ILogger<MaintenanceController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
}
/// <summary>
/// Displays the paginated maintenance record list with optional filters for equipment,
/// keyword search, status, pending-only (Scheduled/InProgress/Overdue), and
/// upcoming-only (due within 7 days or already overdue). The filter combinations are
/// built via explicit lambda expressions rather than dynamic LINQ so the EF query
/// can be translated to parameterized SQL without string concatenation risks.
/// Equipment is eagerly loaded so the equipment name is available in the list without
/// a secondary query per row.
/// </summary>
public async Task<IActionResult> Index(
int? equipmentId,
string? searchTerm,
MaintenanceStatus? statusFilter,
string? sortColumn,
string sortDirection = "asc",
bool pendingOnly = false,
bool upcomingOnly = false,
int pageNumber = 1,
int pageSize = 25)
{
try
{
// Create and validate grid request
var gridRequest = new GridRequest
{
PageNumber = pageNumber,
PageSize = pageSize,
SortColumn = sortColumn ?? "ScheduledDate",
SortDirection = sortColumn == null ? "asc" : sortDirection,
SearchTerm = searchTerm
};
gridRequest.Validate();
var today = DateTime.Today;
var lookAhead = today.AddDays(7);
// Build filter expression
System.Linq.Expressions.Expression<Func<MaintenanceRecord, bool>>? filter = null;
if (upcomingOnly)
{
filter = m => (m.Status == MaintenanceStatus.Scheduled
|| m.Status == MaintenanceStatus.InProgress
|| m.Status == MaintenanceStatus.Overdue)
&& (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAhead);
}
else if (pendingOnly)
{
filter = m => m.Status == MaintenanceStatus.Scheduled
|| m.Status == MaintenanceStatus.InProgress
|| m.Status == MaintenanceStatus.Overdue;
}
else if (equipmentId.HasValue && !string.IsNullOrWhiteSpace(searchTerm) && statusFilter.HasValue)
{
var search = searchTerm.ToLower();
var status = statusFilter.Value;
var eqId = equipmentId.Value;
filter = m => m.EquipmentId == eqId
&& (m.MaintenanceType.ToLower().Contains(search)
|| (m.Description != null && m.Description.ToLower().Contains(search))
|| (m.PerformedById != null && m.PerformedById.ToLower().Contains(search)))
&& m.Status == status;
}
else if (equipmentId.HasValue && !string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
var eqId = equipmentId.Value;
filter = m => m.EquipmentId == eqId
&& (m.MaintenanceType.ToLower().Contains(search)
|| (m.Description != null && m.Description.ToLower().Contains(search))
|| (m.PerformedById != null && m.PerformedById.ToLower().Contains(search)));
}
else if (equipmentId.HasValue && statusFilter.HasValue)
{
var eqId = equipmentId.Value;
var status = statusFilter.Value;
filter = m => m.EquipmentId == eqId && m.Status == status;
}
else if (!string.IsNullOrWhiteSpace(searchTerm) && statusFilter.HasValue)
{
var search = searchTerm.ToLower();
var status = statusFilter.Value;
filter = m => (m.MaintenanceType.ToLower().Contains(search)
|| (m.Description != null && m.Description.ToLower().Contains(search))
|| (m.PerformedById != null && m.PerformedById.ToLower().Contains(search)))
&& m.Status == status;
}
else if (equipmentId.HasValue)
{
var eqId = equipmentId.Value;
filter = m => m.EquipmentId == eqId;
}
else if (!string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
filter = m => m.MaintenanceType.ToLower().Contains(search)
|| (m.Description != null && m.Description.ToLower().Contains(search))
|| (m.PerformedById != null && m.PerformedById.ToLower().Contains(search));
}
else if (statusFilter.HasValue)
{
var status = statusFilter.Value;
filter = m => m.Status == status;
}
// Build orderBy function
Func<IQueryable<MaintenanceRecord>, IOrderedQueryable<MaintenanceRecord>> orderBy = gridRequest.SortColumn switch
{
"MaintenanceType" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(m => m.MaintenanceType) : q.OrderByDescending(m => m.MaintenanceType),
"Status" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(m => m.Status) : q.OrderByDescending(m => m.Status),
"Priority" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(m => m.Priority) : q.OrderByDescending(m => m.Priority),
"ScheduledDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(m => m.ScheduledDate) : q.OrderByDescending(m => m.ScheduledDate),
"CompletedDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(m => m.CompletedDate) : q.OrderByDescending(m => m.CompletedDate),
"Cost" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(m => m.TotalCost) : q.OrderByDescending(m => m.TotalCost),
_ => q => q.OrderByDescending(m => m.ScheduledDate)
};
// Get paged data with Equipment eager loading
var (items, totalCount) = await _unitOfWork.MaintenanceRecords.GetPagedAsync(
gridRequest.PageNumber,
gridRequest.PageSize,
filter,
orderBy,
m => m.Equipment);
// Map to DTOs
var maintenanceDtos = _mapper.Map<List<MaintenanceListDto>>(items);
// Create paged result
var pagedResult = new PagedResult<MaintenanceListDto>
{
Items = maintenanceDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
// Get equipment name if filtering by equipment
if (equipmentId.HasValue)
{
var equipment = await _unitOfWork.Equipment.GetByIdAsync(equipmentId.Value);
ViewBag.EquipmentName = equipment?.EquipmentName;
}
// Set ViewBag for sorting and filters
ViewBag.EquipmentId = equipmentId;
ViewBag.SearchTerm = searchTerm;
ViewBag.StatusFilter = statusFilter;
ViewBag.PendingOnly = pendingOnly;
ViewBag.UpcomingOnly = upcomingOnly;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
return View(pagedResult);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving maintenance records");
TempData["Error"] = "An error occurred while loading maintenance records.";
return View(new PagedResult<MaintenanceListDto>());
}
}
/// <summary>
/// Renders the maintenance record detail page. If the record belongs to a recurrence
/// series (identified by RecurrenceGroupId), the total series count is loaded and
/// passed to the view so staff know how many scheduled occurrences exist in the group,
/// helping them decide whether to edit or delete just this record or the whole series.
/// </summary>
public async Task<IActionResult> Details(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var maintenance = await _unitOfWork.MaintenanceRecords.GetByIdAsync(id.Value);
if (maintenance == null)
{
return NotFound();
}
var maintenanceDto = _mapper.Map<MaintenanceRecordDto>(maintenance);
// Count series siblings so Details view can display "X occurrences in this series"
if (!string.IsNullOrEmpty(maintenance.RecurrenceGroupId))
{
var groupId = maintenance.RecurrenceGroupId;
var seriesRecords = await _unitOfWork.MaintenanceRecords.FindAsync(
m => m.RecurrenceGroupId == groupId);
ViewBag.SeriesCount = seriesRecords.Count();
}
return View(maintenanceDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving maintenance record {MaintenanceId}", id);
TempData["Error"] = "An error occurred while loading the maintenance record.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Renders the maintenance record creation form, defaulting status to Scheduled,
/// priority to Normal, and the scheduled date to today. If an equipmentId is supplied
/// (e.g. linked from the Equipment Details page), it pre-selects that equipment in the
/// dropdown so staff do not need to choose it manually.
/// </summary>
public async Task<IActionResult> Create(int? equipmentId)
{
try
{
var dto = new CreateMaintenanceDto
{
Status = MaintenanceStatus.Scheduled.ToString(),
Priority = MaintenancePriority.Normal.ToString(),
ScheduledDate = DateTime.Now.Date
};
if (equipmentId.HasValue)
{
dto.EquipmentId = equipmentId.Value;
}
await PopulateViewBagAsync(equipmentId);
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading create maintenance form");
TempData["Error"] = "An error occurred while loading the form.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Persists a new maintenance record. If the record is marked as recurring, the parent
/// is saved first to obtain an Id, then a RecurrenceGroupId is assigned and
/// <see cref="GenerateRecurringOccurrencesAsync"/> creates all child occurrences in a
/// second save. This two-phase save is necessary because the child records reference
/// the parent's Id as RecurrenceParentId. After creation, redirects back to the
/// Equipment Details page if the record was created from that context.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateMaintenanceDto dto)
{
if (!ModelState.IsValid)
{
await PopulateViewBagAsync(dto.EquipmentId);
return View(dto);
}
try
{
var maintenance = _mapper.Map<MaintenanceRecord>(dto);
maintenance.CreatedAt = DateTime.UtcNow;
await _unitOfWork.MaintenanceRecords.AddAsync(maintenance);
await _unitOfWork.SaveChangesAsync();
// Generate recurring occurrences after parent is saved (so we have its Id)
if (dto.IsRecurring && dto.RecurrenceFrequency.HasValue)
{
maintenance.RecurrenceGroupId = Guid.NewGuid().ToString();
await _unitOfWork.MaintenanceRecords.UpdateAsync(maintenance);
await _unitOfWork.SaveChangesAsync();
await GenerateRecurringOccurrencesAsync(maintenance);
TempData["Success"] = "Recurring maintenance series created successfully.";
}
else
{
TempData["Success"] = "Maintenance record created successfully.";
}
// Redirect back to equipment details if came from there
if (dto.EquipmentId > 0)
{
return RedirectToAction("Details", "Equipment", new { id = dto.EquipmentId });
}
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating maintenance record");
TempData["Error"] = "An error occurred while creating the maintenance record.";
await PopulateViewBagAsync(dto.EquipmentId);
return View(dto);
}
}
/// <summary>
/// Renders the maintenance record edit form. Sets ViewBag.IsRecurringSeries so the
/// view can show a warning when editing a record that is part of a series, letting
/// staff decide between editing just this occurrence or the whole series.
/// </summary>
public async Task<IActionResult> Edit(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var maintenance = await _unitOfWork.MaintenanceRecords.GetByIdAsync(id.Value);
if (maintenance == null)
{
return NotFound();
}
var dto = _mapper.Map<UpdateMaintenanceDto>(maintenance);
ViewBag.IsRecurringSeries = !string.IsNullOrEmpty(maintenance.RecurrenceGroupId);
await PopulateViewBagAsync(dto.EquipmentId);
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving maintenance record {MaintenanceId} for edit", id);
TempData["Error"] = "An error occurred while loading the maintenance record.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Persists edits to a maintenance record. Detects recurrence changes (frequency,
/// end date, or the IsRecurring toggle) by comparing old values captured before
/// AutoMapper overwrites them. When a change is detected, all future unfinished
/// siblings in the old series are soft-deleted via <see cref="DeleteFutureSeriesAsync"/>
/// and the current record becomes the new series parent, triggering a fresh call to
/// <see cref="GenerateRecurringOccurrencesAsync"/>. Completed and Cancelled records
/// in the old series are preserved to maintain historical accuracy.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateMaintenanceDto dto)
{
if (id != dto.Id)
{
return NotFound();
}
if (!ModelState.IsValid)
{
await PopulateViewBagAsync(dto.EquipmentId);
return View(dto);
}
try
{
var maintenance = await _unitOfWork.MaintenanceRecords.GetByIdAsync(id);
if (maintenance == null)
{
return NotFound();
}
// Capture old recurrence settings before overwriting
var oldIsRecurring = maintenance.IsRecurring;
var oldFrequency = maintenance.RecurrenceFrequency;
var oldEndDate = maintenance.RecurrenceEndDate;
var oldGroupId = maintenance.RecurrenceGroupId;
_mapper.Map(dto, maintenance);
maintenance.UpdatedAt = DateTime.UtcNow;
// Detect recurrence changes
bool recurrenceChanged = oldIsRecurring != dto.IsRecurring
|| oldFrequency != dto.RecurrenceFrequency
|| oldEndDate != dto.RecurrenceEndDate;
if (recurrenceChanged)
{
// Delete all future unfinished siblings (not this record)
if (!string.IsNullOrEmpty(oldGroupId))
{
await DeleteFutureSeriesAsync(oldGroupId, excludeId: id);
}
if (dto.IsRecurring && dto.RecurrenceFrequency.HasValue)
{
// This record becomes the new series parent
if (string.IsNullOrEmpty(maintenance.RecurrenceGroupId))
maintenance.RecurrenceGroupId = Guid.NewGuid().ToString();
maintenance.RecurrenceParentId = null;
await _unitOfWork.MaintenanceRecords.UpdateAsync(maintenance);
await _unitOfWork.SaveChangesAsync();
await GenerateRecurringOccurrencesAsync(maintenance);
TempData["Success"] = "Maintenance record updated and recurrence series regenerated.";
}
else
{
// Toggled OFF — clear recurrence fields
maintenance.RecurrenceGroupId = null;
maintenance.RecurrenceParentId = null;
await _unitOfWork.MaintenanceRecords.UpdateAsync(maintenance);
await _unitOfWork.SaveChangesAsync();
TempData["Success"] = "Maintenance record updated. Recurrence has been removed.";
}
}
else
{
await _unitOfWork.MaintenanceRecords.UpdateAsync(maintenance);
await _unitOfWork.SaveChangesAsync();
TempData["Success"] = "Maintenance record updated successfully.";
}
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating maintenance record {MaintenanceId}", id);
TempData["Error"] = "An error occurred while updating the maintenance record.";
await PopulateViewBagAsync(dto.EquipmentId);
return View(dto);
}
}
/// <summary>
/// Renders the delete confirmation page. For recurring series records, counts and
/// passes to the view the number of future Scheduled/Overdue siblings so staff can
/// make an informed choice between deleting just this occurrence or the entire series.
/// </summary>
public async Task<IActionResult> Delete(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var maintenance = await _unitOfWork.MaintenanceRecords.GetByIdAsync(id.Value);
if (maintenance == null)
{
return NotFound();
}
var maintenanceDto = _mapper.Map<MaintenanceRecordDto>(maintenance);
// Count deletable future series records so the view can show the user
if (!string.IsNullOrEmpty(maintenance.RecurrenceGroupId))
{
var groupId = maintenance.RecurrenceGroupId;
var futureScheduled = await _unitOfWork.MaintenanceRecords.FindAsync(
m => m.RecurrenceGroupId == groupId
&& m.Id != id.Value
&& (m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.Overdue));
ViewBag.SeriesDeletableCount = futureScheduled.Count();
}
return View(maintenanceDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving maintenance record {MaintenanceId} for delete", id);
TempData["Error"] = "An error occurred while loading the maintenance record.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Soft-deletes a maintenance record or an entire recurring series depending on
/// deleteMode ("single" or "series"). When "series" is requested,
/// <see cref="DeleteEntireSeriesAsync"/> removes all records sharing the same
/// RecurrenceGroupId, including already-completed ones. After deletion, redirects
/// to the parent equipment's Details page so staff stay in context.
/// </summary>
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id, string deleteMode = "single")
{
try
{
var maintenance = await _unitOfWork.MaintenanceRecords.GetByIdAsync(id);
if (maintenance == null)
{
return NotFound();
}
var equipmentId = maintenance.EquipmentId;
if (deleteMode == "series" && !string.IsNullOrEmpty(maintenance.RecurrenceGroupId))
{
// Delete ALL records in the series (including this one)
await DeleteEntireSeriesAsync(maintenance.RecurrenceGroupId);
TempData["Success"] = "Entire maintenance series deleted successfully.";
}
else
{
// Single record delete
await _unitOfWork.MaintenanceRecords.SoftDeleteAsync(maintenance);
await _unitOfWork.SaveChangesAsync();
TempData["Success"] = "Maintenance record deleted successfully.";
}
return RedirectToAction("Details", "Equipment", new { id = equipmentId });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting maintenance record {MaintenanceId}", id);
TempData["Error"] = "An error occurred while deleting the maintenance record.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Marks a maintenance record as Completed via an AJAX form submission and returns a
/// JSON result so the Details page can update in-place without a full reload. Also
/// updates the parent equipment's LastMaintenanceDate and calculates the next
/// scheduled maintenance by adding RecommendedMaintenanceIntervalDays to the current
/// timestamp — keeping the equipment status current without a separate scheduler job.
/// Returns JSON { success, message } so the JS caller can show the appropriate toast.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Complete(int id, string workPerformed, string partsReplaced, decimal laborCost, decimal partsCost, decimal downtimeHours)
{
try
{
var maintenance = await _unitOfWork.MaintenanceRecords.GetByIdAsync(id);
if (maintenance == null)
{
return Json(new { success = false, message = "Maintenance record not found." });
}
// Update maintenance record
maintenance.Status = MaintenanceStatus.Completed;
maintenance.CompletedDate = DateTime.UtcNow;
maintenance.WorkPerformed = workPerformed;
maintenance.PartsReplaced = partsReplaced;
maintenance.LaborCost = laborCost;
maintenance.PartsCost = partsCost;
maintenance.TotalCost = laborCost + partsCost;
maintenance.DowntimeHours = downtimeHours;
maintenance.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.MaintenanceRecords.UpdateAsync(maintenance);
// Update equipment maintenance dates
var equipment = await _unitOfWork.Equipment.GetByIdAsync(maintenance.EquipmentId);
if (equipment != null)
{
equipment.LastMaintenanceDate = DateTime.UtcNow;
// Calculate next scheduled maintenance
if (equipment.RecommendedMaintenanceIntervalDays > 0)
{
equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays);
}
equipment.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Equipment.UpdateAsync(equipment);
}
await _unitOfWork.SaveChangesAsync();
TempData["Success"] = "Maintenance marked as completed successfully.";
return Json(new { success = true, message = "Maintenance completed successfully." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error completing maintenance record {MaintenanceId}", id);
return Json(new { success = false, message = "An error occurred while completing the maintenance." });
}
}
// ────────────────────────────────────────────────
// Recurrence helpers
// ────────────────────────────────────────────────
/// <summary>
/// Generates child MaintenanceRecord occurrences for a recurring series, starting one
/// interval after the parent's ScheduledDate. The parent must already be persisted so
/// its Id can be stored as RecurrenceParentId on each child. If no explicit end date
/// is set, a sensible default horizon is applied per frequency (e.g. 90 days for daily,
/// 3 years for quarterly) to prevent accidentally creating thousands of records. A hard
/// cap of 365 occurrences is enforced as an additional safeguard.
/// </summary>
private async Task GenerateRecurringOccurrencesAsync(MaintenanceRecord parent)
{
if (!parent.RecurrenceFrequency.HasValue) return;
var frequency = parent.RecurrenceFrequency.Value;
var startDate = parent.ScheduledDate;
// Compute the end date: explicit setting or a sensible default horizon
DateTime endDate = parent.RecurrenceEndDate ?? frequency switch
{
MaintenanceRecurrenceFrequency.Daily => startDate.AddDays(90),
MaintenanceRecurrenceFrequency.Weekly => startDate.AddDays(364),
MaintenanceRecurrenceFrequency.BiWeekly => startDate.AddDays(364),
MaintenanceRecurrenceFrequency.Monthly => startDate.AddMonths(24),
MaintenanceRecurrenceFrequency.Quarterly => startDate.AddYears(3),
MaintenanceRecurrenceFrequency.Annually => startDate.AddYears(3),
MaintenanceRecurrenceFrequency.BiAnnually => startDate.AddMonths(18),
_ => startDate.AddYears(1)
};
var occurrences = new List<DateTime>();
var nextDate = AdvanceDate(startDate, frequency);
int maxOccurrences = 365;
int count = 0;
while (nextDate <= endDate && count < maxOccurrences)
{
occurrences.Add(nextDate);
nextDate = AdvanceDate(nextDate, frequency);
count++;
}
foreach (var date in occurrences)
{
var child = new MaintenanceRecord
{
EquipmentId = parent.EquipmentId,
MaintenanceType = parent.MaintenanceType,
Priority = parent.Priority,
Description = parent.Description,
AssignedUserId = parent.AssignedUserId,
CompanyId = parent.CompanyId,
Notes = parent.Notes,
LaborCost = parent.LaborCost,
PartsCost = parent.PartsCost,
TotalCost = parent.TotalCost,
Status = MaintenanceStatus.Scheduled,
ScheduledDate = date,
// Recurrence linkage
IsRecurring = true,
RecurrenceFrequency = parent.RecurrenceFrequency,
RecurrenceEndDate = parent.RecurrenceEndDate,
RecurrenceGroupId = parent.RecurrenceGroupId,
RecurrenceParentId = parent.Id,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.MaintenanceRecords.AddAsync(child);
}
await _unitOfWork.SaveChangesAsync();
}
/// <summary>
/// Soft-deletes all future unfinished (Scheduled or Overdue) records that share the
/// given RecurrenceGroupId, optionally excluding one record (typically the one being
/// edited). Completed, InProgress, and Cancelled records are intentionally preserved
/// so that historical maintenance data and cost totals remain intact.
/// </summary>
private async Task DeleteFutureSeriesAsync(string recurrenceGroupId, int? excludeId = null)
{
var siblings = await _unitOfWork.MaintenanceRecords.FindAsync(
m => m.RecurrenceGroupId == recurrenceGroupId
&& (m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.Overdue)
&& (excludeId == null || m.Id != excludeId.Value));
foreach (var record in siblings)
{
await _unitOfWork.MaintenanceRecords.SoftDeleteAsync(record);
}
await _unitOfWork.SaveChangesAsync();
}
/// <summary>
/// Soft-deletes every record sharing the given RecurrenceGroupId, including completed
/// and in-progress ones. Used exclusively for the "delete entire series" confirmation
/// path. Unlike <see cref="DeleteFutureSeriesAsync"/>, historical records are not
/// spared because the operator has explicitly chosen to erase the whole series.
/// </summary>
private async Task DeleteEntireSeriesAsync(string recurrenceGroupId)
{
var all = await _unitOfWork.MaintenanceRecords.FindAsync(
m => m.RecurrenceGroupId == recurrenceGroupId);
foreach (var record in all)
{
await _unitOfWork.MaintenanceRecords.SoftDeleteAsync(record);
}
await _unitOfWork.SaveChangesAsync();
}
/// <summary>
/// Returns the next occurrence date by advancing <paramref name="from"/> by one
/// interval for the given frequency. Used iteratively by
/// <see cref="GenerateRecurringOccurrencesAsync"/> to build the full occurrence list.
/// BiAnnually means every 6 months (twice a year), not every 2 years.
/// </summary>
private static DateTime AdvanceDate(DateTime from, MaintenanceRecurrenceFrequency frequency) => frequency switch
{
MaintenanceRecurrenceFrequency.Daily => from.AddDays(1),
MaintenanceRecurrenceFrequency.Weekly => from.AddDays(7),
MaintenanceRecurrenceFrequency.BiWeekly => from.AddDays(14),
MaintenanceRecurrenceFrequency.Monthly => from.AddMonths(1),
MaintenanceRecurrenceFrequency.Quarterly => from.AddMonths(3),
MaintenanceRecurrenceFrequency.Annually => from.AddYears(1),
MaintenanceRecurrenceFrequency.BiAnnually => from.AddMonths(6),
_ => from.AddDays(7)
};
/// <summary>
/// Loads the equipment dropdown and the status/priority enum arrays into ViewBag for
/// use by the Create and Edit forms. Only active equipment is shown in the dropdown
/// because scheduling maintenance for retired or out-of-service equipment is not
/// meaningful and would clutter the list.
/// </summary>
private async Task PopulateViewBagAsync(int? selectedEquipmentId = null)
{
var equipment = await _unitOfWork.Equipment.GetAllAsync();
ViewBag.EquipmentList = new SelectList(
equipment.Where(e => e.IsActive).OrderBy(e => e.EquipmentName),
"Id",
"EquipmentName",
selectedEquipmentId);
ViewBag.StatusList = Enum.GetValues<MaintenanceStatus>();
ViewBag.PriorityList = Enum.GetValues<MaintenancePriority>();
}
}
@@ -0,0 +1,248 @@
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only CRUD interface for managing global ManufacturerLookupPattern records.
/// These records have CompanyId = 0 and are shared across all tenants.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class ManufacturerLookupPatternsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ManufacturerLookupPatternsController> _logger;
public ManufacturerLookupPatternsController(
IUnitOfWork unitOfWork,
ILogger<ManufacturerLookupPatternsController> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Displays all non-deleted manufacturer lookup patterns ordered alphabetically.
/// Uses <c>ignoreQueryFilters: true</c> because these records carry
/// <c>CompanyId = 0</c> and would otherwise be filtered out by the
/// multi-tenancy global query filter.
/// </summary>
public async Task<IActionResult> Index()
{
try
{
var patterns = await _unitOfWork.ManufacturerLookupPatterns.GetAllAsync(ignoreQueryFilters: true);
var ordered = patterns
.Where(p => !p.IsDeleted)
.OrderBy(p => p.ManufacturerName)
.ToList();
return View(ordered);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving manufacturer lookup patterns");
TempData["Error"] = "An error occurred while loading manufacturer lookup patterns.";
return View(new List<ManufacturerLookupPattern>());
}
}
/// <summary>
/// Returns the Create form pre-populated with sensible defaults:
/// <c>SlugTransform = "LowerHyphen"</c> (the most common URL slug format for
/// manufacturer product pages) and <c>IsActive = true</c>.
/// </summary>
// GET: ManufacturerLookupPatterns/Create
public IActionResult Create()
{
return View(new ManufacturerLookupPattern
{
SlugTransform = "LowerHyphen",
IsActive = true
});
}
/// <summary>
/// Persists a new manufacturer lookup pattern. Forces <c>CompanyId = 0</c>
/// regardless of the current user's company because these patterns are global
/// — they are shared by <c>InventoryAiLookupService</c> across all tenants.
/// </summary>
// POST: ManufacturerLookupPatterns/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(ManufacturerLookupPattern item)
{
if (!ModelState.IsValid)
{
return View(item);
}
try
{
item.CompanyId = 0;
item.CreatedAt = DateTime.UtcNow;
await _unitOfWork.ManufacturerLookupPatterns.AddAsync(item);
await _unitOfWork.SaveChangesAsync();
TempData["Success"] = $"Manufacturer pattern for \"{item.ManufacturerName}\" created successfully.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating manufacturer lookup pattern");
TempData["Error"] = "An error occurred while creating the pattern.";
return View(item);
}
}
/// <summary>
/// Returns the Edit form for an existing pattern. Fetches with
/// <c>ignoreQueryFilters: true</c> so the <c>CompanyId = 0</c> records are
/// visible, then checks <c>IsDeleted</c> manually to prevent editing soft-deleted records.
/// </summary>
// GET: ManufacturerLookupPatterns/Edit/5
public async Task<IActionResult> Edit(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var patterns = await _unitOfWork.ManufacturerLookupPatterns.GetAllAsync(ignoreQueryFilters: true);
var item = patterns.FirstOrDefault(p => p.Id == id.Value && !p.IsDeleted);
if (item == null)
{
return NotFound();
}
return View(item);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving manufacturer lookup pattern {Id} for edit", id);
TempData["Error"] = "An error occurred while loading the pattern.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Applies the edited field values to the tracked entity and saves.
/// Only whitelisted fields are copied from the posted model (manual mapping)
/// to prevent over-posting — particularly to keep <c>CompanyId = 0</c> immutable
/// and prevent an attacker from hijacking the pattern to a specific tenant.
/// </summary>
// POST: ManufacturerLookupPatterns/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, ManufacturerLookupPattern item)
{
if (id != item.Id)
{
return NotFound();
}
if (!ModelState.IsValid)
{
return View(item);
}
try
{
var patterns = await _unitOfWork.ManufacturerLookupPatterns.GetAllAsync(ignoreQueryFilters: true);
var existing = patterns.FirstOrDefault(p => p.Id == id && !p.IsDeleted);
if (existing == null)
{
return NotFound();
}
existing.ManufacturerName = item.ManufacturerName;
existing.Domain = item.Domain;
existing.ProductUrlTemplate = item.ProductUrlTemplate;
existing.SlugTransform = item.SlugTransform;
existing.IsActive = item.IsActive;
existing.Notes = item.Notes;
existing.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ManufacturerLookupPatterns.UpdateAsync(existing);
await _unitOfWork.SaveChangesAsync();
TempData["Success"] = $"Pattern for \"{existing.ManufacturerName}\" updated successfully.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating manufacturer lookup pattern {Id}", id);
TempData["Error"] = "An error occurred while updating the pattern.";
return View(item);
}
}
/// <summary>
/// Returns the Delete confirmation view for a pattern, verifying that the record
/// exists and is not already soft-deleted before showing the destructive action.
/// </summary>
// GET: ManufacturerLookupPatterns/Delete/5
public async Task<IActionResult> Delete(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var patterns = await _unitOfWork.ManufacturerLookupPatterns.GetAllAsync(ignoreQueryFilters: true);
var item = patterns.FirstOrDefault(p => p.Id == id.Value && !p.IsDeleted);
if (item == null)
{
return NotFound();
}
return View(item);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving manufacturer lookup pattern {Id} for delete", id);
TempData["Error"] = "An error occurred while loading the pattern.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Soft-deletes the pattern (sets <c>IsDeleted = true</c>) rather than physically
/// removing it. Soft delete is preferred so that historical AI lookup results that
/// referenced this pattern remain traceable even after the record is retired.
/// </summary>
// POST: ManufacturerLookupPatterns/Delete/5
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
try
{
var patterns = await _unitOfWork.ManufacturerLookupPatterns.GetAllAsync(ignoreQueryFilters: true);
var item = patterns.FirstOrDefault(p => p.Id == id && !p.IsDeleted);
if (item == null)
{
return NotFound();
}
await _unitOfWork.ManufacturerLookupPatterns.SoftDeleteAsync(item);
await _unitOfWork.SaveChangesAsync();
TempData["Success"] = $"Pattern for \"{item.ManufacturerName}\" deleted successfully.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting manufacturer lookup pattern {Id}", id);
TempData["Error"] = "An error occurred while deleting the pattern.";
return RedirectToAction(nameof(Index));
}
}
}
@@ -0,0 +1,196 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Notification;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Company-scoped notification log viewer accessible to users with the
/// <c>CanManageJobs</c> policy (Managers and above within a company).
/// The platform-wide equivalent for SuperAdmins lives in
/// <see cref="PlatformNotificationsController"/>.
/// Uses <see cref="ApplicationDbContext"/> directly to enable LINQ projections
/// that avoid loading full message bodies into memory on the list page.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public class NotificationLogsController : Controller
{
private readonly ApplicationDbContext _context;
private readonly ILogger<NotificationLogsController> _logger;
public NotificationLogsController(ApplicationDbContext context, ILogger<NotificationLogsController> logger)
{
_context = context;
_logger = logger;
}
/// <summary>
/// Displays a paginated, filterable list of notification log entries for the
/// current company. Supports filtering by free-text search, channel (Email/SMS),
/// delivery status, notification type, and an optional job context.
/// <para>
/// The <c>pageSize</c> is validated against an allowlist (10/25/50/100) and
/// defaults to 25 to prevent callers from requesting arbitrarily large result sets.
/// Sorting defaults to most-recent-first (<c>SentAt DESC</c>) because operators
/// almost always want to see the latest delivery attempts first.
/// </para>
/// </summary>
// GET: /NotificationLogs
public async Task<IActionResult> Index(
string? searchTerm,
string? channelFilter,
string? statusFilter,
string? typeFilter,
int? jobId,
string? sortColumn,
string sortDirection = "desc",
int pageNumber = 1,
int pageSize = 25)
{
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
var query = _context.NotificationLogs
.AsNoTracking()
.Include(n => n.Customer)
.Include(n => n.Job)
.Include(n => n.Quote)
.AsQueryable();
// Filters
if (jobId.HasValue)
query = query.Where(n => n.JobId == jobId.Value);
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
query = query.Where(n =>
n.RecipientName.ToLower().Contains(search) ||
n.Recipient.ToLower().Contains(search) ||
(n.Subject != null && n.Subject.ToLower().Contains(search)) ||
(n.Job != null && n.Job.JobNumber.ToLower().Contains(search)) ||
(n.Quote != null && n.Quote.QuoteNumber.ToLower().Contains(search)));
}
if (!string.IsNullOrWhiteSpace(channelFilter) && Enum.TryParse<NotificationChannel>(channelFilter, out var channel))
query = query.Where(n => n.Channel == channel);
if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse<NotificationStatus>(statusFilter, out var status))
query = query.Where(n => n.Status == status);
if (!string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<NotificationType>(typeFilter, out var type))
query = query.Where(n => n.NotificationType == type);
// Sorting
query = (sortColumn ?? "SentAt") switch
{
"RecipientName" => sortDirection == "asc" ? query.OrderBy(n => n.RecipientName) : query.OrderByDescending(n => n.RecipientName),
"Channel" => sortDirection == "asc" ? query.OrderBy(n => n.Channel) : query.OrderByDescending(n => n.Channel),
"Status" => sortDirection == "asc" ? query.OrderBy(n => n.Status) : query.OrderByDescending(n => n.Status),
"Type" => sortDirection == "asc" ? query.OrderBy(n => n.NotificationType) : query.OrderByDescending(n => n.NotificationType),
_ => sortDirection == "asc" ? query.OrderBy(n => n.SentAt) : query.OrderByDescending(n => n.SentAt)
};
var totalCount = await query.CountAsync();
var items = await query
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(n => new NotificationLogDto
{
Id = n.Id,
Channel = n.Channel,
NotificationType = n.NotificationType,
Status = n.Status,
RecipientName = n.RecipientName,
Recipient = n.Recipient,
Subject = n.Subject,
Message = n.Message,
ErrorMessage = n.ErrorMessage,
SentAt = n.SentAt,
CustomerId = n.CustomerId,
JobId = n.JobId,
QuoteId = n.QuoteId,
JobNumber = n.Job != null ? n.Job.JobNumber : null,
QuoteNumber = n.Quote != null ? n.Quote.QuoteNumber : null,
CustomerName = n.Customer != null
? (n.Customer.CompanyName ?? $"{n.Customer.ContactFirstName} {n.Customer.ContactLastName}".Trim())
: null
})
.ToListAsync();
var pagedResult = new PagedResult<NotificationLogDto>
{
Items = items,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
ViewBag.SearchTerm = searchTerm;
ViewBag.ChannelFilter = channelFilter;
ViewBag.StatusFilter = statusFilter;
ViewBag.TypeFilter = typeFilter;
ViewBag.JobId = jobId;
ViewBag.SortColumn = sortColumn ?? "SentAt";
ViewBag.SortDirection = sortDirection;
return View(pagedResult);
}
/// <summary>
/// Displays the full details of a single notification log entry including the
/// complete message body and any error message, which are omitted from the list
/// view to keep response sizes small. Loads related Customer, Job, and Quote
/// navigation properties to resolve display names.
/// </summary>
// GET: /NotificationLogs/Details/5
public async Task<IActionResult> Details(int id)
{
try
{
var log = await _context.NotificationLogs
.AsNoTracking()
.Include(n => n.Customer)
.Include(n => n.Job)
.Include(n => n.Quote)
.FirstOrDefaultAsync(n => n.Id == id);
if (log == null) return NotFound();
var dto = new NotificationLogDto
{
Id = log.Id,
Channel = log.Channel,
NotificationType = log.NotificationType,
Status = log.Status,
RecipientName = log.RecipientName,
Recipient = log.Recipient,
Subject = log.Subject,
Message = log.Message,
ErrorMessage = log.ErrorMessage,
SentAt = log.SentAt,
CustomerId = log.CustomerId,
JobId = log.JobId,
QuoteId = log.QuoteId,
JobNumber = log.Job?.JobNumber,
QuoteNumber = log.Quote?.QuoteNumber,
CustomerName = log.Customer != null
? (log.Customer.CompanyName ?? $"{log.Customer.ContactFirstName} {log.Customer.ContactLastName}".Trim())
: null
};
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading notification log {Id}", id);
return RedirectToAction(nameof(Index));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,843 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using Stripe;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Public (unauthenticated) controller for customer-facing invoice payment pages.
/// All actions here are accessible without login — security is enforced via signed tokens.
/// </summary>
[AllowAnonymous]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Public)]
public class PaymentController : Controller
{
private readonly ApplicationDbContext _context;
private readonly IStripeConnectService _stripeConnect;
private readonly INotificationService _notificationService;
private readonly IInAppNotificationService _inApp;
private readonly IConfiguration _configuration;
private readonly ILogger<PaymentController> _logger;
public PaymentController(
ApplicationDbContext context,
IStripeConnectService stripeConnect,
INotificationService notificationService,
IInAppNotificationService inApp,
IConfiguration configuration,
ILogger<PaymentController> logger)
{
_context = context;
_stripeConnect = stripeConnect;
_notificationService = notificationService;
_inApp = inApp;
_configuration = configuration;
_logger = logger;
}
// ─── GET /pay/{token} ────────────────────────────────────────────────────
/// <summary>
/// Renders the customer-facing invoice payment page. Resolves the invoice from the GUID token
/// embedded in the email link, validates that the token is not expired and the invoice is still
/// unpaid, then builds the view model with surcharge details and the Stripe publishable key.
/// Uses <see cref="ResolveInvoiceFromTokenAsync"/> so validation logic is shared with
/// <see cref="CreateIntent"/>.
/// </summary>
[HttpGet("/pay/{token}")]
public async Task<IActionResult> Index(string token)
{
var (invoice, company, error) = await ResolveInvoiceFromTokenAsync(token);
if (error != null) return View("PaymentError", error);
var customer = await _context.Customers.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == invoice!.CustomerId);
var surcharge = CalculateSurcharge(invoice!.BalanceDue, company!);
var vm = new PaymentPageViewModel
{
InvoiceNumber = invoice!.InvoiceNumber,
InvoiceId = invoice.Id,
Token = token,
CustomerName = customer != null
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: "Valued Customer",
CustomerEmail = customer?.Email ?? string.Empty,
CompanyName = company!.CompanyName,
BalanceDue = invoice.BalanceDue,
InvoiceTotal = invoice.Total,
AmountPaid = invoice.AmountPaid, // already includes online payments
SurchargeType = company.OnlinePaymentSurchargeType,
SurchargeValue = company.OnlinePaymentSurchargeValue,
SurchargeAmount = surcharge,
TotalWithSurcharge = invoice.BalanceDue + surcharge,
ExpiresAt = invoice.PaymentLinkExpiresAt!.Value,
IsFullyPaid = invoice.BalanceDue <= 0,
StripePublishableKey = _configuration["Stripe:Connect:PublishableKey"]!,
StripeAccountId = company.StripeAccountId!
};
return View(vm);
}
// ─── GET /pay/{token}/success ────────────────────────────────────────────
// Stripe redirects here after the customer completes payment in the browser.
/// <summary>
/// Renders the post-payment success page after Stripe redirects the customer back to the site.
/// The token is kept in the URL only to maintain URL consistency with the payment page — it is
/// not validated here because the payment result is authoritative via the webhook.
/// </summary>
[HttpGet("/pay/{token}/success")]
public IActionResult Success(string token) => View();
// ─── POST /pay/{token}/intent ─────────────────────────────────────────────
// Creates a PaymentIntent for the amount the customer chose to pay.
/// <summary>
/// Creates a Stripe PaymentIntent for a partial or full invoice payment. Re-validates the token
/// on every call so a shared or replayed link cannot be used after the invoice is fully paid or
/// expired. The surcharge (flat or percentage, configured per company) is added on top of the
/// requested amount and passed through to Stripe metadata so the webhook can strip it out when
/// recording <c>netPayment</c> against the invoice balance.
/// Sets <c>OnlinePaymentStatus = Pending</c> and saves the PaymentIntent ID to support idempotency
/// checks in <see cref="HandlePaymentSucceededAsync"/>.
/// </summary>
[HttpPost("/pay/{token}/intent")]
public async Task<IActionResult> CreateIntent(string token, [FromBody] CreateIntentRequest request)
{
var (invoice, company, error) = await ResolveInvoiceFromTokenAsync(token);
if (error != null) return BadRequest(new { error });
// Validate amount — must be > 0 and <= balance due
if (request.Amount <= 0 || request.Amount > invoice!.BalanceDue)
return BadRequest(new { error = "Invalid payment amount." });
var surcharge = CalculateSurcharge(request.Amount, company!);
var customer = await _context.Customers.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == invoice.CustomerId);
var (success, clientSecret, paymentIntentId, stripeError) =
await _stripeConnect.CreatePaymentIntentAsync(
connectedAccountId: company!.StripeAccountId!,
invoiceTotal: request.Amount,
surchargeAmount: surcharge,
currency: "usd",
customerEmail: customer?.Email ?? string.Empty,
invoiceNumber: invoice.InvoiceNumber,
invoiceId: invoice.Id);
if (!success)
return BadRequest(new { error = stripeError });
// Store the latest PaymentIntent ID on the invoice
invoice.StripePaymentIntentId = paymentIntentId;
if (invoice.OnlinePaymentStatus == OnlinePaymentStatus.NotApplicable)
invoice.OnlinePaymentStatus = OnlinePaymentStatus.Pending;
_context.Update(invoice);
await _context.SaveChangesAsync();
return Ok(new { clientSecret, surchargeAmount = surcharge });
}
// ─── GET /pay/deposit/{token} ────────────────────────────────────────────
/// <summary>
/// Renders the customer-facing deposit payment page for an approved quote. The deposit amount is
/// calculated as <c>Quote.Total × (DepositPercent / 100)</c>; the percentage is set by the company
/// when generating the quote link. Uses <see cref="ResolveDepositQuoteFromTokenAsync"/> to validate
/// the token and confirm the deposit has not already been paid.
/// </summary>
[HttpGet("/pay/deposit/{token}")]
public async Task<IActionResult> Deposit(string token)
{
var (quote, company, error) = await ResolveDepositQuoteFromTokenAsync(token);
if (error != null) return View("PaymentError", error);
var customer = quote!.Customer
?? await _context.Customers.AsNoTracking().FirstOrDefaultAsync(c => c.Id == quote.CustomerId);
var depositAmount = Math.Round(quote.Total * (quote.DepositPercent / 100m), 2);
var surcharge = CalculateSurcharge(depositAmount, company!);
var vm = new DepositPaymentPageViewModel
{
QuoteNumber = quote.QuoteNumber,
QuoteId = quote.Id,
Token = token,
CustomerName = customer != null
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: quote.ProspectContactName ?? "Valued Customer",
CustomerEmail = customer?.Email ?? quote.ProspectEmail ?? string.Empty,
CompanyName = company!.CompanyName,
DepositAmount = depositAmount,
QuoteTotal = quote.Total,
DepositPercent = quote.DepositPercent,
SurchargeType = company.OnlinePaymentSurchargeType,
SurchargeValue = company.OnlinePaymentSurchargeValue,
SurchargeAmount = surcharge,
TotalWithSurcharge = depositAmount + surcharge,
ExpiresAt = quote.DepositPaymentLinkExpiresAt!.Value,
StripePublishableKey = _configuration["Stripe:Connect:PublishableKey"]!,
StripeAccountId = company.StripeAccountId!
};
return View(vm);
}
// ─── POST /pay/deposit/{token}/intent ────────────────────────────────────
/// <summary>
/// Creates a Stripe PaymentIntent for a quote deposit payment. The deposit amount is always the
/// full computed deposit (not a partial amount chosen by the customer), so the <c>request.Amount</c>
/// parameter from the client is intentionally ignored in favour of the server-calculated value.
/// This prevents a malicious actor from sending an artificially low amount. The PaymentIntent ID
/// is stored on the quote immediately so <see cref="HandleDepositPaymentSucceededAsync"/> can
/// perform idempotency checks.
/// </summary>
[HttpPost("/pay/deposit/{token}/intent")]
public async Task<IActionResult> CreateDepositIntent(string token, [FromBody] CreateIntentRequest request)
{
var (quote, company, error) = await ResolveDepositQuoteFromTokenAsync(token);
if (error != null) return BadRequest(new { error });
var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2);
var surcharge = CalculateSurcharge(depositAmount, company!);
var customerEmail = quote.Customer?.Email ?? quote.ProspectEmail ?? string.Empty;
var (success, clientSecret, paymentIntentId, stripeError) =
await _stripeConnect.CreateDepositPaymentIntentAsync(
connectedAccountId: company!.StripeAccountId!,
depositAmount: depositAmount,
surchargeAmount: surcharge,
currency: "usd",
customerEmail: customerEmail,
quoteNumber: quote.QuoteNumber,
quoteId: quote.Id);
if (!success)
return BadRequest(new { error = stripeError });
quote.DepositPaymentIntentId = paymentIntentId;
_context.Update(quote);
await _context.SaveChangesAsync();
return Ok(new { clientSecret, surchargeAmount = surcharge });
}
// ─── GET /pay/deposit/{token}/success ────────────────────────────────────
/// <summary>
/// Renders the post-deposit success page shown to the customer after Stripe redirects back.
/// Actual deposit recording happens in <see cref="HandleDepositPaymentSucceededAsync"/> via webhook,
/// not here — this is purely a user-facing confirmation screen.
/// </summary>
[HttpGet("/pay/deposit/{token}/success")]
public IActionResult DepositSuccess(string token) => View();
// ─── POST /stripe/connect-webhook ────────────────────────────────────────
// Stripe calls this when a payment on a connected account succeeds.
/// <summary>
/// Receives Stripe Connect account-level webhook events for payments made on connected
/// (tenant) Stripe accounts. This is distinct from the platform-level webhook handled by
/// <c>StripeWebhookController</c> which covers subscription billing events.
/// Verifies the <c>Stripe-Signature</c> header against the Connect webhook secret before
/// processing. Routes <c>payment_intent.succeeded</c> to either
/// <see cref="HandlePaymentSucceededAsync"/> (invoice) or
/// <see cref="HandleDepositPaymentSucceededAsync"/> (deposit) based on the <c>type</c> metadata
/// key set when the PaymentIntent was created. Handles refund and chargeback events too.
/// Returns HTTP 200 for all valid events — returning a non-2xx would cause Stripe to retry.
/// </summary>
[HttpPost("/stripe/connect-webhook")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> ConnectWebhook()
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
var webhookSecret = _configuration["Stripe:Connect:ConnectWebhookSecret"]!;
Event stripeEvent;
try
{
stripeEvent = EventUtility.ConstructEvent(
json, Request.Headers["Stripe-Signature"]!, webhookSecret);
}
catch (StripeException ex)
{
_logger.LogWarning("Invalid Stripe Connect webhook signature: {Message}", ex.Message);
return BadRequest();
}
if (stripeEvent.Type == "payment_intent.succeeded")
{
var intent = stripeEvent.Data.Object as PaymentIntent;
if (intent == null) return Ok();
// Route deposit vs invoice payments by metadata discriminator
intent.Metadata.TryGetValue("type", out var intentType);
if (intentType == "deposit")
await HandleDepositPaymentSucceededAsync(intent);
else
await HandlePaymentSucceededAsync(intent);
}
else if (stripeEvent.Type == "charge.refunded")
{
var charge = stripeEvent.Data.Object as Charge;
if (charge != null) await HandleChargeRefundedAsync(charge);
}
else if (stripeEvent.Type == "charge.dispute.created")
{
var dispute = stripeEvent.Data.Object as Dispute;
if (dispute != null) await HandleDisputeCreatedAsync(dispute);
}
else if (stripeEvent.Type == "charge.dispute.closed")
{
var dispute = stripeEvent.Data.Object as Dispute;
if (dispute != null) await HandleDisputeClosedAsync(dispute);
}
return Ok();
}
// ─── Private helpers ─────────────────────────────────────────────────────
/// <summary>
/// Processes a successful <c>payment_intent.succeeded</c> Stripe Connect event for an invoice
/// payment. Strips the surcharge out of the total (surcharge was stored in PI metadata by
/// <see cref="CreateIntent"/>) so only the net amount is applied to <c>Invoice.AmountPaid</c>.
/// Idempotency: skips if the PaymentIntent ID is already recorded with a paid/partial status to
/// prevent double-recording on webhook retries. Uses <c>IgnoreQueryFilters</c> because the
/// invoice belongs to a tenant company and this handler has no HTTP context / tenant claim.
/// Fires email + in-app notifications after persisting, wrapped in separate try/catch blocks so a
/// failed notification never rolls back the payment record.
/// </summary>
private async Task HandlePaymentSucceededAsync(PaymentIntent intent)
{
_logger.LogInformation("HandlePaymentSucceeded: PI={Id} Amount={Amount} Metadata={@Metadata}",
intent.Id, intent.Amount, intent.Metadata);
if (!intent.Metadata.TryGetValue("invoice_id", out var invoiceIdStr)
|| !int.TryParse(invoiceIdStr, out var invoiceId))
{
_logger.LogWarning("PaymentIntent {Id} has no invoice_id metadata", intent.Id);
return;
}
_logger.LogInformation("HandlePaymentSucceeded: looking up invoice {InvoiceId}", invoiceId);
var invoice = await _context.Invoices
.IgnoreQueryFilters()
.Include(i => i.Customer)
.FirstOrDefaultAsync(i => i.Id == invoiceId && !i.IsDeleted);
if (invoice == null)
{
_logger.LogWarning("PaymentIntent {Id} references unknown invoice {InvoiceId}", intent.Id, invoiceId);
return;
}
_logger.LogInformation("HandlePaymentSucceeded: found invoice {InvoiceId} Status={Status} OnlineStatus={OnlineStatus} ExistingPI={ExistingPI}",
invoiceId, invoice.Status, invoice.OnlinePaymentStatus, invoice.StripePaymentIntentId);
// Idempotency guard — skip if this payment was already recorded
if (invoice.StripePaymentIntentId == intent.Id && invoice.OnlinePaymentStatus is OnlinePaymentStatus.Paid or OnlinePaymentStatus.PartiallyPaid)
{
_logger.LogInformation("Duplicate webhook for PaymentIntent {Id} on invoice {InvoiceId} — skipping", intent.Id, invoiceId);
return;
}
var amountPaidDollars = intent.Amount / 100m;
decimal.TryParse(intent.Metadata.GetValueOrDefault("surcharge_amount", "0"), out var surcharge);
var netPayment = amountPaidDollars - surcharge;
invoice.OnlineAmountPaid += netPayment;
invoice.OnlineSurchargeCollected += surcharge;
invoice.AmountPaid += netPayment;
invoice.StripePaymentIntentId = intent.Id;
if (invoice.BalanceDue <= 0)
{
invoice.OnlinePaymentStatus = OnlinePaymentStatus.Paid;
invoice.Status = InvoiceStatus.Paid;
invoice.PaidDate = DateTime.UtcNow;
}
else
{
invoice.OnlinePaymentStatus = OnlinePaymentStatus.PartiallyPaid;
invoice.Status = InvoiceStatus.PartiallyPaid;
}
invoice.UpdatedAt = DateTime.UtcNow;
_context.Update(invoice);
await _context.SaveChangesAsync();
_logger.LogInformation("Online payment of {Amount:C} received for invoice {InvoiceId}", amountPaidDollars, invoiceId);
await _notificationService.NotifyOnlinePaymentReceivedAsync(invoice, netPayment, surcharge, intent.Id);
try
{
var customerName = invoice.Customer?.CompanyName
?? $"{invoice.Customer?.ContactFirstName} {invoice.Customer?.ContactLastName}".Trim();
await _inApp.CreateAsync(
companyId: invoice.CompanyId,
title: "Online Payment Received",
message: $"{customerName} paid {netPayment:C} online for invoice {invoice.InvoiceNumber}.",
notificationType: "InvoicePaid",
link: $"/Invoices/Details/{invoice.Id}",
invoiceId: invoice.Id,
customerId: invoice.CustomerId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "In-app notification failed for online payment on invoice {InvoiceId}", invoiceId);
}
}
/// <summary>
/// Processes a successful <c>payment_intent.succeeded</c> event for a quote deposit. Creates a
/// <c>Deposit</c> ledger record so the deposit appears in the customer's deposit history and can
/// be auto-applied when an invoice is later created from the job. Deposit records are only created
/// for quotes with a <c>CustomerId</c> (i.e. existing customers); prospect quotes skip the ledger
/// entry because there is no Customer row to link to. The <c>DepositPaymentIntentId</c> check
/// provides idempotency against webhook retries.
/// </summary>
private async Task HandleDepositPaymentSucceededAsync(PaymentIntent intent)
{
if (!intent.Metadata.TryGetValue("quote_id", out var quoteIdStr)
|| !int.TryParse(quoteIdStr, out var quoteId))
{
_logger.LogWarning("Deposit PaymentIntent {Id} has no quote_id metadata", intent.Id);
return;
}
var quote = await _context.Quotes
.IgnoreQueryFilters()
.Include(q => q.Customer)
.FirstOrDefaultAsync(q => q.Id == quoteId && !q.IsDeleted);
if (quote == null)
{
_logger.LogWarning("Deposit PaymentIntent {Id} references unknown quote {QuoteId}", intent.Id, quoteId);
return;
}
// Idempotency guard
if (quote.DepositPaymentIntentId == intent.Id)
{
_logger.LogInformation("Duplicate deposit webhook for PaymentIntent {Id} on quote {QuoteId} — skipping", intent.Id, quoteId);
return;
}
var amountPaidDollars = intent.Amount / 100m;
decimal.TryParse(intent.Metadata.GetValueOrDefault("surcharge_amount", "0"), out var surcharge);
var netDeposit = amountPaidDollars - surcharge;
quote.DepositAmountPaid += netDeposit;
quote.DepositPaymentIntentId = intent.Id;
quote.UpdatedAt = DateTime.UtcNow;
// Create a Deposit record so it shows up in the deposits ledger (customer quotes only)
if (quote.CustomerId.HasValue)
{
var deposit = new Core.Entities.Deposit
{
CompanyId = quote.CompanyId,
CustomerId = quote.CustomerId.Value,
QuoteId = quote.Id,
Amount = netDeposit,
PaymentMethod = Core.Enums.PaymentMethod.CreditDebitCard,
ReceivedDate = DateTime.UtcNow,
Reference = intent.Id,
Notes = $"Online deposit via Stripe. Surcharge: {surcharge:C}",
ReceiptNumber = $"DEP-{quote.QuoteNumber}-{DateTime.UtcNow:yyyyMMddHHmm}",
CreatedAt = DateTime.UtcNow
};
_context.Deposits.Add(deposit);
}
else
{
_logger.LogWarning("Deposit PaymentIntent {Id} on prospect quote {QuoteId} — Deposit record skipped (no CustomerId)", intent.Id, quoteId);
}
_context.Update(quote);
await _context.SaveChangesAsync();
_logger.LogInformation("Deposit of {Amount:C} received for quote {QuoteId}", amountPaidDollars, quoteId);
try
{
await _notificationService.NotifyDepositReceivedAsync(quote, netDeposit, surcharge, intent.Id);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send deposit notification for quote {QuoteId}", quoteId);
}
try
{
var customerName = quote.Customer?.CompanyName
?? $"{quote.Customer?.ContactFirstName} {quote.Customer?.ContactLastName}".Trim();
if (string.IsNullOrWhiteSpace(customerName))
customerName = quote.ProspectCompanyName ?? quote.ProspectContactName ?? "Customer";
await _inApp.CreateAsync(
companyId: quote.CompanyId,
title: "Online Deposit Received",
message: $"{customerName} paid a {netDeposit:C} deposit online for quote {quote.QuoteNumber}.",
notificationType: "InvoicePaid",
link: $"/Quotes/Details/{quote.Id}",
quoteId: quote.Id,
customerId: quote.CustomerId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "In-app notification failed for online deposit on quote {QuoteId}", quoteId);
}
}
/// <summary>
/// Processes a <c>charge.refunded</c> Stripe event by creating a <c>Refund</c> ledger record and
/// reversing the corresponding amount from <c>Invoice.AmountPaid</c>. Matches the invoice by
/// <c>StripePaymentIntentId</c>, with a metadata fallback for cases where a later payment
/// overwrote the PI on the invoice (e.g. partial pay → refund → partial pay again). Only the
/// latest refund on the charge is processed per call; Stripe sends a separate event per refund
/// so this is safe. The <c>Refund.Reference</c> column holds the Stripe refund ID and is used
/// for idempotency checks.
/// </summary>
private async Task HandleChargeRefundedAsync(Charge charge)
{
if (string.IsNullOrEmpty(charge.PaymentIntentId))
{
_logger.LogWarning("Charge {ChargeId} refunded but has no PaymentIntentId", charge.Id);
return;
}
var invoice = await _context.Invoices
.IgnoreQueryFilters()
.Include(i => i.Customer)
.FirstOrDefaultAsync(i => i.StripePaymentIntentId == charge.PaymentIntentId && !i.IsDeleted);
// Fallback: look up by invoice_id in charge metadata if the PaymentIntent was overwritten by a later payment
if (invoice == null && charge.Metadata.TryGetValue("invoice_id", out var metaInvoiceIdStr)
&& int.TryParse(metaInvoiceIdStr, out var metaInvoiceId))
{
invoice = await _context.Invoices
.IgnoreQueryFilters()
.Include(i => i.Customer)
.FirstOrDefaultAsync(i => i.Id == metaInvoiceId && !i.IsDeleted);
}
if (invoice == null)
{
_logger.LogWarning("charge.refunded for charge {ChargeId} (PI: {PI}) — invoice not found", charge.Id, charge.PaymentIntentId);
return;
}
// Only process the latest refund on this charge
var latestRefund = charge.Refunds?.Data?.OrderByDescending(r => r.Created).FirstOrDefault();
if (latestRefund == null) return;
// Idempotency: skip if this Stripe refund ID was already recorded
var alreadyRecorded = await _context.Refunds
.AnyAsync(r => r.InvoiceId == invoice.Id && r.Reference == latestRefund.Id && !r.IsDeleted);
if (alreadyRecorded) return;
var refundAmountDollars = latestRefund.Amount / 100m;
var refund = new Core.Entities.Refund
{
CompanyId = invoice.CompanyId,
InvoiceId = invoice.Id,
Amount = refundAmountDollars,
RefundDate = latestRefund.Created,
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
Reason = latestRefund.Reason ?? "Stripe refund",
Reference = latestRefund.Id,
Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}",
Status = Core.Enums.RefundStatus.Issued,
IssuedDate = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow
};
_context.Refunds.Add(refund);
invoice.OnlineAmountPaid = Math.Max(0, invoice.OnlineAmountPaid - refundAmountDollars);
invoice.AmountPaid = Math.Max(0, invoice.AmountPaid - refundAmountDollars);
if (invoice.AmountPaid <= 0)
{
invoice.OnlinePaymentStatus = OnlinePaymentStatus.Refunded;
invoice.Status = InvoiceStatus.Sent;
invoice.PaidDate = null;
}
else if (invoice.BalanceDue > 0)
{
invoice.OnlinePaymentStatus = OnlinePaymentStatus.PartiallyPaid;
invoice.Status = InvoiceStatus.PartiallyPaid;
}
invoice.UpdatedAt = DateTime.UtcNow;
_context.Update(invoice);
await _context.SaveChangesAsync();
_logger.LogInformation("Refund of {Amount:C} recorded for invoice {InvoiceId} (Stripe refund {RefundId})",
refundAmountDollars, invoice.Id, latestRefund.Id);
}
/// <summary>
/// Processes a <c>charge.dispute.created</c> Stripe event. At this stage Stripe has not yet
/// adjudicated the dispute, so no funds are reversed — only a chargeback alert notification is
/// sent to the company so they can gather evidence. Reversal (if lost) is handled by
/// <see cref="HandleDisputeClosedAsync"/>. Notification failure is caught and logged so a broken
/// email config never causes Stripe to see a failed webhook and retry.
/// </summary>
private async Task HandleDisputeCreatedAsync(Dispute dispute)
{
var invoice = await FindInvoiceByPaymentIntentAsync(dispute.PaymentIntentId);
if (invoice == null)
{
_logger.LogWarning("charge.dispute.created for dispute {DisputeId} — invoice not found (PI: {PI})",
dispute.Id, dispute.PaymentIntentId);
return;
}
var amount = dispute.Amount / 100m;
_logger.LogWarning("Stripe dispute {DisputeId} opened on invoice {InvoiceId}, amount {Amount:C}, reason: {Reason}",
dispute.Id, invoice.Id, amount, dispute.Reason);
try
{
await _notificationService.NotifyChargebackAlertAsync(invoice, dispute.Id, amount, dispute.Reason ?? "unspecified");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send chargeback alert for invoice {InvoiceId}", invoice.Id);
}
}
/// <summary>
/// Processes a <c>charge.dispute.closed</c> Stripe event. Only acts when <c>dispute.Status ==
/// "lost"</c> — won disputes require no accounting adjustment. On a lost dispute, the funds were
/// already returned to the cardholder by Stripe, so a <c>Refund</c> ledger record is created and
/// the invoice <c>AmountPaid</c> is reversed to reflect the true collected amount. The Stripe
/// dispute ID is stored in <c>Refund.Reference</c> for idempotency — won disputes and duplicate
/// webhook retries are both handled by the <c>alreadyRecorded</c> guard.
/// </summary>
private async Task HandleDisputeClosedAsync(Dispute dispute)
{
var invoice = await FindInvoiceByPaymentIntentAsync(dispute.PaymentIntentId);
if (invoice == null)
{
_logger.LogWarning("charge.dispute.closed for dispute {DisputeId} — invoice not found (PI: {PI})",
dispute.Id, dispute.PaymentIntentId);
return;
}
_logger.LogInformation("Stripe dispute {DisputeId} closed with status '{Status}' on invoice {InvoiceId}",
dispute.Id, dispute.Status, invoice.Id);
if (dispute.Status == "lost")
{
// Idempotency: skip if already recorded
var alreadyRecorded = await _context.Refunds
.AnyAsync(r => r.InvoiceId == invoice.Id && r.Reference == dispute.Id && !r.IsDeleted);
if (alreadyRecorded) return;
var amount = dispute.Amount / 100m;
var refund = new Core.Entities.Refund
{
CompanyId = invoice.CompanyId,
InvoiceId = invoice.Id,
Amount = amount,
RefundDate = DateTime.UtcNow,
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
Reason = "Chargeback lost — funds returned to customer",
Reference = dispute.Id,
Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}",
Status = Core.Enums.RefundStatus.Issued,
IssuedDate = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow
};
_context.Refunds.Add(refund);
invoice.OnlineAmountPaid = Math.Max(0, invoice.OnlineAmountPaid - amount);
invoice.AmountPaid = Math.Max(0, invoice.AmountPaid - amount);
if (invoice.AmountPaid <= 0)
{
invoice.OnlinePaymentStatus = OnlinePaymentStatus.Refunded;
invoice.Status = InvoiceStatus.Sent;
invoice.PaidDate = null;
}
else if (invoice.BalanceDue > 0)
{
invoice.OnlinePaymentStatus = OnlinePaymentStatus.PartiallyPaid;
invoice.Status = InvoiceStatus.PartiallyPaid;
}
invoice.UpdatedAt = DateTime.UtcNow;
_context.Update(invoice);
await _context.SaveChangesAsync();
_logger.LogWarning("Chargeback lost for invoice {InvoiceId}, {Amount:C} reversed", invoice.Id, amount);
}
}
/// <summary>
/// Looks up an invoice by its stored <c>StripePaymentIntentId</c>. Used by dispute handlers
/// where the invoice ID is not in the Stripe metadata. <c>IgnoreQueryFilters</c> is required
/// because there is no authenticated tenant context in webhook handlers.
/// </summary>
private async Task<Core.Entities.Invoice?> FindInvoiceByPaymentIntentAsync(string? paymentIntentId)
{
if (string.IsNullOrEmpty(paymentIntentId)) return null;
return await _context.Invoices
.IgnoreQueryFilters()
.Include(i => i.Customer)
.FirstOrDefaultAsync(i => i.StripePaymentIntentId == paymentIntentId && !i.IsDeleted);
}
/// <summary>
/// Validates an invoice payment token and returns the associated invoice and company, or an
/// error message. This is the central guard for all invoice payment actions — token expiry,
/// zero balance, voided status, and inactive Stripe Connect are all checked here. The method uses
/// <c>IgnoreQueryFilters</c> because the token is accessed by unauthenticated customers and the
/// normal multi-tenancy filter would block the query. Callers short-circuit on non-null error.
/// </summary>
private async Task<(Core.Entities.Invoice? Invoice, Core.Entities.Company? Company, string? Error)>
ResolveInvoiceFromTokenAsync(string token)
{
var invoice = await _context.Invoices
.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.PaymentLinkToken == token && !i.IsDeleted);
if (invoice == null)
return (null, null, "This payment link is not valid.");
if (invoice.PaymentLinkExpiresAt < DateTime.UtcNow)
return (null, null, "This payment link has expired. Please contact the shop for a new link.");
if (invoice.BalanceDue <= 0)
return (null, null, "This invoice has already been paid in full.");
if (invoice.Status == InvoiceStatus.Voided)
return (null, null, "This invoice has been voided and is no longer payable.");
var company = await _context.Companies.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == invoice.CompanyId && !c.IsDeleted);
if (company == null || company.StripeConnectStatus != Core.Enums.StripeConnectStatus.Active)
return (null, null, "Online payments are not available for this invoice.");
return (invoice, company, null);
}
/// <summary>
/// Validates a deposit payment token and returns the associated quote and company, or an error
/// message. Mirrors <see cref="ResolveInvoiceFromTokenAsync"/> for the deposit flow. Checks that
/// the quote deposit has not already been fully paid (compares <c>DepositAmountPaid</c> against
/// the computed deposit amount) so the link cannot be reused after a successful payment.
/// </summary>
private async Task<(Core.Entities.Quote? Quote, Core.Entities.Company? Company, string? Error)>
ResolveDepositQuoteFromTokenAsync(string token)
{
var quote = await _context.Quotes
.IgnoreQueryFilters()
.Include(q => q.Customer)
.FirstOrDefaultAsync(q => q.DepositPaymentLinkToken == token && !q.IsDeleted);
if (quote == null)
return (null, null, "This deposit payment link is not valid.");
if (quote.DepositPaymentLinkExpiresAt < DateTime.UtcNow)
return (null, null, "This deposit payment link has expired. Please contact the shop for a new link.");
var depositRequired = Math.Round(quote.Total * (quote.DepositPercent / 100m), 2);
if (quote.DepositAmountPaid >= depositRequired)
return (null, null, "This deposit has already been paid.");
var company = await _context.Companies.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId && !c.IsDeleted);
if (company == null || company.StripeConnectStatus != Core.Enums.StripeConnectStatus.Active)
return (null, null, "Online payments are not available for this deposit.");
return (quote, company, null);
}
/// <summary>
/// Calculates the online payment surcharge to add to the customer-facing total. Companies can
/// configure either a percentage surcharge (e.g. 3% credit card fee pass-through) or a flat
/// surcharge (e.g. $2.00 handling fee) via <c>OnlinePaymentSurchargeType</c> and
/// <c>OnlinePaymentSurchargeValue</c>. Returns 0 for any other surcharge type (including
/// <c>None</c>). The surcharge amount is passed in Stripe metadata so the webhook can strip it
/// back out when crediting the net amount to the invoice.
/// </summary>
private static decimal CalculateSurcharge(decimal amount, Core.Entities.Company company)
{
return company.OnlinePaymentSurchargeType switch
{
OnlinePaymentSurchargeType.Percent =>
Math.Round(amount * (company.OnlinePaymentSurchargeValue / 100m), 2),
OnlinePaymentSurchargeType.Flat =>
company.OnlinePaymentSurchargeValue,
_ => 0m
};
}
}
// ─── View models & request DTOs ──────────────────────────────────────────────
public class PaymentPageViewModel
{
public string InvoiceNumber { get; set; } = string.Empty;
public int InvoiceId { get; set; }
public string Token { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public decimal BalanceDue { get; set; }
public decimal InvoiceTotal { get; set; }
public decimal AmountPaid { get; set; }
public OnlinePaymentSurchargeType SurchargeType { get; set; }
public decimal SurchargeValue { get; set; }
public decimal SurchargeAmount { get; set; }
public decimal TotalWithSurcharge { get; set; }
public DateTime ExpiresAt { get; set; }
public bool IsFullyPaid { get; set; }
public string StripePublishableKey { get; set; } = string.Empty;
public string StripeAccountId { get; set; } = string.Empty;
}
public class DepositPaymentPageViewModel
{
public string QuoteNumber { get; set; } = string.Empty;
public int QuoteId { get; set; }
public string Token { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public decimal DepositAmount { get; set; }
public decimal QuoteTotal { get; set; }
public decimal DepositPercent { get; set; }
public OnlinePaymentSurchargeType SurchargeType { get; set; }
public decimal SurchargeValue { get; set; }
public decimal SurchargeAmount { get; set; }
public decimal TotalWithSurcharge { get; set; }
public DateTime ExpiresAt { get; set; }
public string StripePublishableKey { get; set; } = string.Empty;
public string StripeAccountId { get; set; } = string.Empty;
}
public class CreateIntentRequest
{
public decimal Amount { get; set; }
}
@@ -0,0 +1,178 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only cross-company notification log viewer.
/// The company-scoped version lives in NotificationLogsController (CanManageJobs policy).
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class PlatformNotificationsController : Controller
{
private readonly ApplicationDbContext _db;
public PlatformNotificationsController(ApplicationDbContext db) => _db = db;
/// <summary>
/// Renders a paginated, filterable cross-company notification log view visible
/// only to SuperAdmins. Unlike the tenant-scoped
/// <see cref="NotificationLogsController.Index"/>, this action uses
/// <c>IgnoreQueryFilters()</c> to bypass the multi-tenancy global filter and
/// see logs from all companies simultaneously.
/// <para>
/// Company names are resolved in a single follow-up query after paging (not via
/// a JOIN) because company data lives outside the notification-log table's usual
/// query scope, and a separate query keeps the main sort/filter logic clean.
/// Summary counts (total, failed, last 24 h) are computed as independent queries
/// against the full unfiltered dataset so the summary reflects platform-wide
/// health regardless of the active filters.
/// </para>
/// </summary>
public async Task<IActionResult> Index(
int? companyId,
string? type,
string? status,
string? channel,
string? search,
DateTime? from,
DateTime? to,
int page = 1,
int pageSize = 50)
{
pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50;
page = Math.Max(1, page);
var query = _db.NotificationLogs
.AsNoTracking()
.IgnoreQueryFilters()
.Where(n => !n.IsDeleted)
.AsQueryable();
if (companyId.HasValue)
query = query.Where(n => n.CompanyId == companyId);
if (!string.IsNullOrWhiteSpace(type) && Enum.TryParse<NotificationType>(type, out var typeEnum))
query = query.Where(n => n.NotificationType == typeEnum);
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<NotificationStatus>(status, out var statusEnum))
query = query.Where(n => n.Status == statusEnum);
if (!string.IsNullOrWhiteSpace(channel) && Enum.TryParse<NotificationChannel>(channel, out var channelEnum))
query = query.Where(n => n.Channel == channelEnum);
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(n =>
n.RecipientName.Contains(search) ||
n.Recipient.Contains(search) ||
(n.Subject != null && n.Subject.Contains(search)));
if (from.HasValue)
query = query.Where(n => n.SentAt >= from.Value.Date);
if (to.HasValue)
query = query.Where(n => n.SentAt < to.Value.Date.AddDays(1));
query = query.OrderByDescending(n => n.SentAt);
var totalCount = await query.CountAsync();
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(n => new PlatformNotificationRow
{
Id = n.Id,
CompanyId = (int)n.CompanyId,
Channel = n.Channel,
NotificationType = n.NotificationType,
Status = n.Status,
RecipientName = n.RecipientName,
Recipient = n.Recipient,
Subject = n.Subject,
ErrorMessage = n.ErrorMessage,
SentAt = n.SentAt
})
.ToListAsync();
// Resolve company names for the rows in one query
var cids = items.Select(i => i.CompanyId).Distinct().ToList();
var companyNames = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => cids.Contains(c.Id))
.ToDictionaryAsync(c => c.Id, c => c.CompanyName);
foreach (var item in items)
item.CompanyName = companyNames.GetValueOrDefault(item.CompanyId);
// Sidebar company list for filter dropdown
var companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.OrderBy(c => c.CompanyName)
.Select(c => new { c.Id, c.CompanyName })
.ToListAsync();
// Summary counts
ViewBag.TotalCount = await _db.NotificationLogs.IgnoreQueryFilters().CountAsync(n => !n.IsDeleted);
ViewBag.FailedCount = await _db.NotificationLogs.IgnoreQueryFilters()
.CountAsync(n => !n.IsDeleted && n.Status == NotificationStatus.Failed);
ViewBag.Last24hCount = await _db.NotificationLogs.IgnoreQueryFilters()
.CountAsync(n => !n.IsDeleted && n.SentAt >= DateTime.UtcNow.AddHours(-24));
ViewBag.Companies = companies;
ViewBag.CompanyIdFilter = companyId;
ViewBag.TypeFilter = type;
ViewBag.StatusFilter = status;
ViewBag.ChannelFilter = channel;
ViewBag.Search = search;
ViewBag.From = from?.ToString("yyyy-MM-dd");
ViewBag.To = to?.ToString("yyyy-MM-dd");
ViewBag.Page = page;
ViewBag.PageSize = pageSize;
ViewBag.FilteredCount = totalCount;
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
return View(items);
}
/// <summary>
/// Returns the full notification log entry for the given <paramref name="id"/>,
/// including the complete message body and error details. The owning company name
/// is resolved separately (rather than via navigation property) because company
/// records are in a different query-filter context.
/// </summary>
public async Task<IActionResult> Details(int id)
{
var log = await _db.NotificationLogs.AsNoTracking().IgnoreQueryFilters()
.FirstOrDefaultAsync(n => n.Id == id);
if (log == null) return NotFound();
string? companyName = null;
if (log.CompanyId > 0)
companyName = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => c.Id == log.CompanyId)
.Select(c => c.CompanyName)
.FirstOrDefaultAsync();
ViewBag.CompanyName = companyName;
return View(log);
}
}
public class PlatformNotificationRow
{
public int Id { get; set; }
public int CompanyId { get; set; }
public string? CompanyName { get; set; }
public NotificationChannel Channel { get; set; }
public NotificationType NotificationType { get; set; }
public NotificationStatus Status { get; set; }
public string RecipientName { get; set; } = string.Empty;
public string Recipient { get; set; } = string.Empty;
public string? Subject { get; set; }
public string? ErrorMessage { get; set; }
public DateTime SentAt { get; set; }
}
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class PlatformSettingsController : Controller
{
private readonly IPlatformSettingsService _settings;
private readonly ILogger<PlatformSettingsController> _logger;
public PlatformSettingsController(
IPlatformSettingsService settings,
ILogger<PlatformSettingsController> logger)
{
_settings = settings;
_logger = logger;
}
/// <summary>
/// Displays all platform-level key/value settings stored in the database. These settings are DB-backed (not appsettings.json) so they can be changed at runtime by a SuperAdmin without a deployment; they are scoped to the platform, not per company.
/// </summary>
public async Task<IActionResult> Index()
{
var settings = await _settings.GetAllAsync();
return View(settings);
}
/// <summary>
/// Upserts a single platform setting by key. Value is trimmed before storage to prevent accidental whitespace-only values. The acting user's identity is recorded by IPlatformSettingsService for audit purposes.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Save(string key, string? value)
{
if (string.IsNullOrWhiteSpace(key))
return BadRequest();
await _settings.SetAsync(key, value?.Trim(), User.Identity?.Name);
_logger.LogInformation("Platform setting '{Key}' updated by {User}", key, User.Identity?.Name);
TempData["SuccessMessage"] = "Setting saved.";
return RedirectToAction(nameof(Index));
}
}
@@ -0,0 +1,158 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Subscription;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only interface for managing subscription plan configurations
/// (limits, pricing, feature flags, and Stripe price IDs). Changes here
/// affect every new tenant assignment of the plan and any quota checks that
/// read plan limits at runtime. Plan <c>DisplayName</c> and <c>Plan</c> enum
/// value are intentionally not editable here to prevent breaking existing company
/// subscription records that reference them by integer value.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class PlatformSubscriptionController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<PlatformSubscriptionController> _logger;
public PlatformSubscriptionController(IUnitOfWork unitOfWork, ILogger<PlatformSubscriptionController> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Lists all subscription plan configurations ordered by display sort order.
/// Uses <c>ignoreQueryFilters: true</c> because plan configs carry
/// <c>CompanyId = 0</c> and are otherwise hidden by the multi-tenancy filter.
/// Projects to <see cref="SubscriptionPlanConfigDto"/> to avoid exposing the
/// full entity (including internal Stripe secrets) to the view layer.
/// </summary>
[HttpGet]
public async Task<IActionResult> Index()
{
var configs = (await _unitOfWork.SubscriptionPlanConfigs.GetAllAsync(ignoreQueryFilters: true))
.OrderBy(c => c.SortOrder)
.ToList();
var dtos = configs.Select(c => new SubscriptionPlanConfigDto
{
Id = c.Id,
Plan = c.Plan,
DisplayName = c.DisplayName,
Description = c.Description,
MaxUsers = c.MaxUsers,
MaxActiveJobs = c.MaxActiveJobs,
MaxCustomers = c.MaxCustomers,
MaxQuotes = c.MaxQuotes,
MaxCatalogItems = c.MaxCatalogItems,
MaxJobPhotos = c.MaxJobPhotos,
MaxQuotePhotos = c.MaxQuotePhotos,
MaxAiPhotoQuotesPerMonth = c.MaxAiPhotoQuotesPerMonth,
MonthlyPrice = c.MonthlyPrice,
AnnualPrice = c.AnnualPrice,
StripePriceIdMonthly = c.StripePriceIdMonthly,
StripePriceIdAnnual = c.StripePriceIdAnnual,
AllowOnlinePayments = c.AllowOnlinePayments,
AllowAccounting = c.AllowAccounting,
AllowAiPhotoQuotes = c.AllowAiPhotoQuotes,
AllowAiInventoryAssist = c.AllowAiInventoryAssist,
IsActive = c.IsActive,
SortOrder = c.SortOrder
}).ToList();
return View(dtos);
}
/// <summary>
/// Returns the Edit form for a plan config, loaded into an
/// <see cref="UpdateSubscriptionPlanConfigDto"/> to prevent over-posting of
/// immutable fields such as <c>Plan</c>, <c>DisplayName</c>, and <c>SortOrder</c>.
/// The plan display name is placed in ViewBag for the page heading.
/// </summary>
[HttpGet]
public async Task<IActionResult> Edit(int id)
{
var config = await _unitOfWork.SubscriptionPlanConfigs.GetByIdAsync(id, ignoreQueryFilters: true);
if (config == null) return NotFound();
var dto = new UpdateSubscriptionPlanConfigDto
{
Id = config.Id,
Description = config.Description,
MaxUsers = config.MaxUsers,
MaxActiveJobs = config.MaxActiveJobs,
MaxCustomers = config.MaxCustomers,
MaxQuotes = config.MaxQuotes,
MaxCatalogItems = config.MaxCatalogItems,
MaxJobPhotos = config.MaxJobPhotos,
MaxQuotePhotos = config.MaxQuotePhotos,
MaxAiPhotoQuotesPerMonth = config.MaxAiPhotoQuotesPerMonth,
MonthlyPrice = config.MonthlyPrice,
AnnualPrice = config.AnnualPrice,
StripePriceIdMonthly = config.StripePriceIdMonthly,
StripePriceIdAnnual = config.StripePriceIdAnnual,
AllowOnlinePayments = config.AllowOnlinePayments,
AllowAccounting = config.AllowAccounting,
AllowAiPhotoQuotes = config.AllowAiPhotoQuotes,
AllowAiInventoryAssist = config.AllowAiInventoryAssist,
IsActive = config.IsActive
};
ViewBag.PlanName = config.DisplayName;
return View(dto);
}
/// <summary>
/// Applies the updated plan configuration values and saves. Uses explicit field
/// mapping from <paramref name="dto"/> to the tracked entity so that immutable
/// identity fields (<c>Plan</c>, <c>DisplayName</c>, <c>SortOrder</c>) are never
/// overwritten. Logs the change at Information level for the audit trail.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateSubscriptionPlanConfigDto dto)
{
if (!ModelState.IsValid)
{
var configForView = await _unitOfWork.SubscriptionPlanConfigs.GetByIdAsync(id, ignoreQueryFilters: true);
ViewBag.PlanName = configForView?.DisplayName ?? "Unknown";
return View(dto);
}
var config = await _unitOfWork.SubscriptionPlanConfigs.GetByIdAsync(id, ignoreQueryFilters: true);
if (config == null) return NotFound();
config.Description = dto.Description;
config.MaxUsers = dto.MaxUsers;
config.MaxActiveJobs = dto.MaxActiveJobs;
config.MaxCustomers = dto.MaxCustomers;
config.MaxQuotes = dto.MaxQuotes;
config.MaxCatalogItems = dto.MaxCatalogItems;
config.MaxJobPhotos = dto.MaxJobPhotos;
config.MaxQuotePhotos = dto.MaxQuotePhotos;
config.MaxAiPhotoQuotesPerMonth = dto.MaxAiPhotoQuotesPerMonth;
config.MonthlyPrice = dto.MonthlyPrice;
config.AnnualPrice = dto.AnnualPrice;
config.StripePriceIdMonthly = dto.StripePriceIdMonthly;
config.StripePriceIdAnnual = dto.StripePriceIdAnnual;
config.AllowOnlinePayments = dto.AllowOnlinePayments;
config.AllowAccounting = dto.AllowAccounting;
config.AllowAiPhotoQuotes = dto.AllowAiPhotoQuotes;
config.AllowAiInventoryAssist = dto.AllowAiInventoryAssist;
config.IsActive = dto.IsActive;
await _unitOfWork.SubscriptionPlanConfigs.UpdateAsync(config);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("SuperAdmin updated subscription plan config: {Plan}", config.DisplayName);
TempData["Success"] = $"{config.DisplayName} plan configuration updated successfully.";
return RedirectToAction(nameof(Index));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
namespace PowderCoating.Web.Controllers;
[Authorize]
public class PowderInsightsController : Controller
{
private readonly IPowderInsightsService _powderInsights;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<PowderInsightsController> _logger;
public PowderInsightsController(
IPowderInsightsService powderInsights,
UserManager<ApplicationUser> userManager,
ILogger<PowderInsightsController> logger)
{
_powderInsights = powderInsights;
_userManager = userManager;
_logger = logger;
}
/// <summary>
/// Renders the Powder Insights dashboard showing AI coverage predictions, efficiency metrics, and color suggestions for the current company. Redirects to login when the user session cannot be resolved rather than returning 401, consistent with the MVC cookie-auth flow.
/// </summary>
public async Task<IActionResult> Index()
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return RedirectToAction("Login", "Account", new { area = "Identity" });
var dashboard = await _powderInsights.GetDashboardAsync(user.CompanyId);
return View(dashboard);
}
/// <summary>
/// AJAX endpoint to record the actual powder weight used for a single coat against a JobItemCoat. The user ID and company ID are taken from the authenticated session rather than the request body to prevent cross-tenant data writes.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RecordUsage([FromBody] RecordUsageRequest request)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var result = await _powderInsights.RecordActualUsageAsync(
request.JobItemCoatId,
request.ActualLbs,
user.Id,
user.CompanyId,
request.Notes);
return Json(result);
}
/// <summary>
/// Returns the _JobPowderSummary partial view for embedding in the Job Details sidebar. CompanyId is passed from the session to enforce tenant isolation — a user cannot request powder data for a job in another company by guessing a jobId.
/// </summary>
public async Task<IActionResult> JobSummary(int jobId)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var summary = await _powderInsights.GetJobPowderSummaryAsync(jobId, user.CompanyId);
if (summary == null) return NotFound();
return PartialView("_JobPowderSummary", summary);
}
}
public class RecordUsageRequest
{
public int JobItemCoatId { get; set; }
public decimal ActualLbs { get; set; }
public string? Notes { get; set; }
}
@@ -0,0 +1,169 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Customer;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class PricingTiersController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<PricingTiersController> _logger;
public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger<PricingTiersController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
}
/// <summary>
/// Lists all pricing tiers with a live customer-count badge per tier. Sorted active-first then by discount ascending so the most relevant tiers appear at the top. Customer counts are computed in memory after a single query to avoid N+1 round-trips.
/// </summary>
public async Task<IActionResult> Index()
{
var tiers = await _unitOfWork.PricingTiers.GetAllAsync();
var customers = await _unitOfWork.Customers.GetAllAsync();
var customerCountByTier = customers
.Where(c => c.PricingTierId.HasValue)
.GroupBy(c => c.PricingTierId!.Value)
.ToDictionary(g => g.Key, g => g.Count());
var dtos = _mapper.Map<List<PricingTierDto>>(tiers);
foreach (var dto in dtos)
dto.CustomerCount = customerCountByTier.GetValueOrDefault(dto.Id, 0);
// Sort: active first, then by discount ascending
dtos = dtos.OrderByDescending(t => t.IsActive).ThenBy(t => t.DiscountPercent).ToList();
return View(dtos);
}
/// <summary>
/// Shows the create form, pre-populating IsActive to true so new tiers are immediately usable without an extra toggle.
/// </summary>
[HttpGet]
public IActionResult Create()
{
return View(new CreatePricingTierDto { IsActive = true });
}
/// <summary>
/// Persists a new pricing tier after validating that the tier name is unique within the company. Duplicate-name checks are done at the application layer because the DB unique index alone would produce a cryptic SQL error.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreatePricingTierDto dto)
{
if (!ModelState.IsValid)
return View(dto);
// Check for duplicate name
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.TierName == dto.TierName);
if (existing.Any())
{
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
return View(dto);
}
var entity = _mapper.Map<PricingTier>(dto);
await _unitOfWork.PricingTiers.AddAsync(entity);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Pricing tier '{TierName}' created (ID: {Id})", entity.TierName, entity.Id);
TempData["SuccessMessage"] = $"Pricing tier '{entity.TierName}' created successfully.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Shows the edit form for an existing pricing tier, mapping the entity to an UpdatePricingTierDto so the view is decoupled from the entity.
/// </summary>
[HttpGet]
public async Task<IActionResult> Edit(int id)
{
var entity = await _unitOfWork.PricingTiers.GetByIdAsync(id);
if (entity == null) return NotFound();
var dto = _mapper.Map<UpdatePricingTierDto>(entity);
return View(dto);
}
/// <summary>
/// Saves edits to an existing pricing tier. Duplicate-name check excludes the current record (t.Id != dto.Id) so a tier can be saved without changing its name.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(UpdatePricingTierDto dto)
{
if (!ModelState.IsValid)
return View(dto);
var entity = await _unitOfWork.PricingTiers.GetByIdAsync(dto.Id);
if (entity == null) return NotFound();
// Check for duplicate name (excluding this record)
var duplicate = await _unitOfWork.PricingTiers.FindAsync(
t => t.TierName == dto.TierName && t.Id != dto.Id);
if (duplicate.Any())
{
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
return View(dto);
}
_mapper.Map(dto, entity);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Pricing tier '{TierName}' updated (ID: {Id})", entity.TierName, entity.Id);
TempData["SuccessMessage"] = $"Pricing tier '{entity.TierName}' updated successfully.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Soft-deletes a pricing tier. Deletion is blocked when customers are still assigned to the tier to prevent orphaned pricing references; the admin must reassign those customers first.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var entity = await _unitOfWork.PricingTiers.GetByIdAsync(id);
if (entity == null) return NotFound();
// Block delete if customers are assigned to this tier
var assignedCustomers = await _unitOfWork.Customers.FindAsync(c => c.PricingTierId == id);
if (assignedCustomers.Any())
{
TempData["ErrorMessage"] = $"Cannot delete '{entity.TierName}' — {assignedCustomers.Count()} customer(s) are assigned to it. Reassign them first.";
return RedirectToAction(nameof(Index));
}
await _unitOfWork.PricingTiers.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Pricing tier '{TierName}' deleted (ID: {Id})", entity.TierName, id);
TempData["SuccessMessage"] = $"Pricing tier '{entity.TierName}' deleted.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Toggles the IsActive flag on a pricing tier without a dedicated edit form round-trip. Inactive tiers remain in the database and on existing customer records but are hidden from new-customer assignment dropdowns.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleActive(int id)
{
var entity = await _unitOfWork.PricingTiers.GetByIdAsync(id);
if (entity == null) return NotFound();
entity.IsActive = !entity.IsActive;
await _unitOfWork.CompleteAsync();
TempData["SuccessMessage"] = $"'{entity.TierName}' marked as {(entity.IsActive ? "active" : "inactive")}.";
return RedirectToAction(nameof(Index));
}
}
@@ -0,0 +1,313 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.User;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
namespace PowderCoating.Web.Controllers;
[Authorize]
public class ProfileController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IProfilePhotoService _profilePhotoService;
private readonly ILogger<ProfileController> _logger;
public ProfileController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
IProfilePhotoService profilePhotoService,
ILogger<ProfileController> logger)
{
_userManager = userManager;
_signInManager = signInManager;
_profilePhotoService = profilePhotoService;
_logger = logger;
}
/// <summary>
/// Displays the current user's profile page. All profile sections (personal info, email, password, appearance, photo) are rendered from a single DTO to keep the view self-contained.
/// </summary>
public async Task<IActionResult> Index()
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return NotFound();
var dto = MapToDto(user);
return View(dto);
}
/// <summary>
/// Serves a user's profile photo binary directly from the filesystem. Cached client-side for 1 hour to reduce disk I/O.
/// When no ID is supplied the current user's photo is returned. Cross-user access is restricted: only the same user, a SuperAdmin, or a same-company user may view another user's photo (prevents enumeration of photos across tenants).
/// </summary>
[HttpGet]
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Client)]
public async Task<IActionResult> Photo(string? id = null)
{
ApplicationUser? user;
var currentUser = await _userManager.GetUserAsync(User);
if (string.IsNullOrEmpty(id))
{
// No ID provided - use current user's photo
user = currentUser;
}
else
{
// SECURITY: Only allow access if same user, SuperAdmin, or same company
if (currentUser?.Id != id && !User.IsInRole("SuperAdmin"))
{
var requestedUser = await _userManager.FindByIdAsync(id);
// Deny access if user not found or different company
if (requestedUser == null || requestedUser.CompanyId != currentUser?.CompanyId)
{
_logger.LogWarning("SECURITY: Unauthorized photo access attempt. User {CurrentUserId} tried to access photo for {RequestedUserId}",
currentUser?.Id, id);
return Forbid();
}
}
// ID provided - load that user's photo (for quote PreparedBy thumbnail, etc.)
user = await _userManager.FindByIdAsync(id);
}
if (user == null)
return NotFound();
// Try filesystem first (new method)
if (!string.IsNullOrEmpty(user.ProfilePictureFilePath))
{
var (success, fileContent, contentType, errorMessage) = await _profilePhotoService.GetProfilePhotoAsync(user.ProfilePictureFilePath);
if (success)
{
return File(fileContent, contentType);
}
_logger.LogWarning("Failed to load profile photo from filesystem for user {UserId}: {Error}", user.Id, errorMessage);
}
return NotFound();
}
/// <summary>
/// AJAX endpoint to update the current user's name and phone number. Calls RefreshSignInAsync so any claims that embed display name are updated immediately without requiring re-login.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Please correct the validation errors." });
var user = await _userManager.GetUserAsync(User);
if (user == null)
return Json(new { success = false, message = "User not found." });
user.FirstName = dto.FirstName.Trim();
user.LastName = dto.LastName.Trim();
user.PhoneNumber = dto.Phone?.Trim();
user.UpdatedAt = DateTime.UtcNow;
var result = await _userManager.UpdateAsync(user);
if (!result.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
return Json(new { success = false, message = errors });
}
await _signInManager.RefreshSignInAsync(user);
return Json(new { success = true, message = "Profile updated successfully." });
}
/// <summary>
/// AJAX endpoint to change the current user's email address, which also serves as the username in ASP.NET Identity.
/// Requires the current password as confirmation to prevent account takeover if a session is left unattended. Checks for duplicate addresses system-wide before applying the change.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateEmail([FromBody] UpdateEmailDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Please correct the validation errors." });
var user = await _userManager.GetUserAsync(User);
if (user == null)
return Json(new { success = false, message = "User not found." });
if (!await _userManager.CheckPasswordAsync(user, dto.CurrentPassword))
return Json(new { success = false, message = "Current password is incorrect." });
if (string.Equals(dto.NewEmail, user.Email, StringComparison.OrdinalIgnoreCase))
return Json(new { success = false, message = "New email is the same as the current email." });
var existing = await _userManager.FindByEmailAsync(dto.NewEmail);
if (existing != null)
return Json(new { success = false, message = "That email address is already in use." });
var emailResult = await _userManager.SetEmailAsync(user, dto.NewEmail);
if (!emailResult.Succeeded)
{
var errors = string.Join(", ", emailResult.Errors.Select(e => e.Description));
return Json(new { success = false, message = errors });
}
await _userManager.SetUserNameAsync(user, dto.NewEmail);
await _signInManager.RefreshSignInAsync(user);
_logger.LogInformation("User {UserId} changed email to {NewEmail}", user.Id, dto.NewEmail);
return Json(new { success = true, message = "Email updated successfully." });
}
/// <summary>
/// AJAX endpoint to change the current user's password via the Identity ChangePasswordAsync flow. RefreshSignInAsync is called after success so the existing cookie remains valid; the user is not forced to log in again.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Please correct the validation errors." });
var user = await _userManager.GetUserAsync(User);
if (user == null)
return Json(new { success = false, message = "User not found." });
var result = await _userManager.ChangePasswordAsync(user, dto.CurrentPassword, dto.NewPassword);
if (!result.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
return Json(new { success = false, message = errors });
}
await _signInManager.RefreshSignInAsync(user);
return Json(new { success = true, message = "Password changed successfully." });
}
/// <summary>
/// Replaces the current user's profile photo with a new upload. The old file is deleted from the filesystem first; if the Identity update subsequently fails the newly uploaded file is rolled back (deleted) to avoid orphaned files.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadPhoto(IFormFile photo)
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
return Json(new { success = false, message = "User not found." });
// Delete old filesystem photo if exists
if (!string.IsNullOrEmpty(user.ProfilePictureFilePath))
{
await _profilePhotoService.DeleteProfilePhotoAsync(user.ProfilePictureFilePath);
}
// Save new photo to filesystem
var (success, filePath, errorMessage) = await _profilePhotoService.SaveProfilePhotoAsync(photo, user.Id, user.CompanyId);
if (!success)
return Json(new { success = false, message = errorMessage });
// Update user record
user.ProfilePictureFilePath = filePath;
user.UpdatedAt = DateTime.UtcNow;
var result = await _userManager.UpdateAsync(user);
if (!result.Succeeded)
{
// Rollback: delete the uploaded file
await _profilePhotoService.DeleteProfilePhotoAsync(filePath);
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
return Json(new { success = false, message = errors });
}
await _signInManager.RefreshSignInAsync(user);
return Json(new { success = true, message = "Photo uploaded successfully." });
}
/// <summary>
/// Removes the current user's profile photo from both the filesystem and the user record. Setting ProfilePictureFilePath to null causes the UI to fall back to the default avatar initials.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeletePhoto()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
return Json(new { success = false, message = "User not found." });
// Delete from filesystem if exists
if (!string.IsNullOrEmpty(user.ProfilePictureFilePath))
{
await _profilePhotoService.DeleteProfilePhotoAsync(user.ProfilePictureFilePath);
}
// Update user record
user.ProfilePictureFilePath = null;
user.UpdatedAt = DateTime.UtcNow;
var result = await _userManager.UpdateAsync(user);
if (!result.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
return Json(new { success = false, message = errors });
}
await _signInManager.RefreshSignInAsync(user);
return Json(new { success = true, message = "Photo removed." });
}
/// <summary>
/// Saves the user's UI preferences (theme, sidebar color, date format, time zone). Theme is validated against the allowed values "light"/"dark" and defaults to "light" if an unexpected value is submitted.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateAppearance([FromBody] UpdateAppearanceDto dto)
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
return Json(new { success = false, message = "User not found." });
user.Theme = dto.Theme is "light" or "dark" ? dto.Theme : "light";
user.SidebarColor = dto.SidebarColor;
user.DateFormat = dto.DateFormat;
user.TimeZone = dto.TimeZone;
user.UpdatedAt = DateTime.UtcNow;
var result = await _userManager.UpdateAsync(user);
if (!result.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
return Json(new { success = false, message = errors });
}
await _signInManager.RefreshSignInAsync(user);
return Json(new { success = true, message = "Appearance saved." });
}
/// <summary>
/// Maps an ApplicationUser entity to the view DTO used by the profile page. Kept static and side-effect-free so it can be called without async overhead; defaults are applied here rather than in the view to centralise fallback logic.
/// </summary>
private static UserProfileDto MapToDto(ApplicationUser user) => new()
{
Id = user.Id,
Email = user.Email ?? string.Empty,
FirstName = user.FirstName,
LastName = user.LastName,
FullName = user.FullName,
Phone = user.PhoneNumber,
Department = user.Department,
Position = user.Position,
EmployeeNumber = user.EmployeeNumber,
CompanyRole = user.CompanyRole,
Theme = user.Theme ?? "light",
SidebarColor = user.SidebarColor ?? "ocean",
DateFormat = user.DateFormat ?? "MM/dd/yyyy",
TimeZone = user.TimeZone,
HasProfilePicture = !string.IsNullOrEmpty(user.ProfilePictureFilePath),
HireDate = user.HireDate,
CreatedAt = user.CreatedAt,
LastLoginDate = user.LastLoginDate
};
}
@@ -0,0 +1,946 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.PurchaseOrder;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanManagePurchaseOrders)]
public class PurchaseOrdersController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<PurchaseOrdersController> _logger;
private readonly ApplicationDbContext _context;
private readonly IPdfService _pdfService;
public PurchaseOrdersController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<PurchaseOrdersController> logger,
ApplicationDbContext context,
IPdfService pdfService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_context = context;
_pdfService = pdfService;
}
// -----------------------------------------------------------------------
// GET: /PurchaseOrders
// -----------------------------------------------------------------------
/// <summary>
/// Lists purchase orders with server-side filtering, sorting, and pagination. Summary KPI
/// cards (total count, open count, committed value, overdue count) are computed from a
/// lightweight projection of all non-deleted POs for the company — independent of the current
/// filter — so the cards always reflect the global state rather than the filtered subset.
/// Cancelled POs are excluded from committed value because they carry no financial obligation.
/// </summary>
public async Task<IActionResult> Index(
string? searchTerm,
PurchaseOrderStatus? statusFilter,
int? vendorId,
DateTime? dateFrom,
DateTime? dateTo,
string? sortColumn,
string sortDirection = "desc",
int pageNumber = 1,
int pageSize = 25)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
pageSize = Math.Clamp(pageSize, 10, 100);
pageNumber = Math.Max(1, pageNumber);
var query = _context.Set<PurchaseOrder>()
.Include(po => po.Vendor)
.Include(po => po.Items.Where(i => !i.IsDeleted))
.Where(po => !po.IsDeleted && po.CompanyId == currentUser.CompanyId)
.AsQueryable();
if (statusFilter.HasValue)
query = query.Where(po => po.Status == statusFilter.Value);
if (vendorId.HasValue)
query = query.Where(po => po.VendorId == vendorId.Value);
if (dateFrom.HasValue)
query = query.Where(po => po.OrderDate >= dateFrom.Value);
if (dateTo.HasValue)
query = query.Where(po => po.OrderDate <= dateTo.Value.AddDays(1));
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var term = searchTerm.Trim().ToLower();
query = query.Where(po =>
po.PoNumber.ToLower().Contains(term) ||
po.Vendor.CompanyName.ToLower().Contains(term) ||
(po.Notes != null && po.Notes.ToLower().Contains(term)));
}
query = (sortColumn?.ToLower(), sortDirection?.ToLower()) switch
{
("ponumber", "asc") => query.OrderBy(po => po.PoNumber),
("ponumber", _) => query.OrderByDescending(po => po.PoNumber),
("vendor", "asc") => query.OrderBy(po => po.Vendor.CompanyName),
("vendor", _) => query.OrderByDescending(po => po.Vendor.CompanyName),
("status", "asc") => query.OrderBy(po => po.Status),
("status", _) => query.OrderByDescending(po => po.Status),
("orderdate", "asc") => query.OrderBy(po => po.OrderDate),
("orderdate", _) => query.OrderByDescending(po => po.OrderDate),
("expected", "asc") => query.OrderBy(po => po.ExpectedDeliveryDate),
("expected", _) => query.OrderByDescending(po => po.ExpectedDeliveryDate),
("total", "asc") => query.OrderBy(po => po.TotalAmount),
("total", _) => query.OrderByDescending(po => po.TotalAmount),
_ => query.OrderByDescending(po => po.OrderDate)
};
var totalCount = await query.CountAsync();
var items = await query
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var dtos = _mapper.Map<List<PurchaseOrderListDto>>(items);
var result = new PagedResult<PurchaseOrderListDto>
{
Items = dtos,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
// Stats
var allForStats = await _context.Set<PurchaseOrder>()
.Where(po => !po.IsDeleted && po.CompanyId == currentUser.CompanyId)
.Select(po => new { po.Status, po.TotalAmount, po.ExpectedDeliveryDate })
.ToListAsync();
ViewBag.TotalCount = allForStats.Count;
ViewBag.OpenCount = allForStats.Count(p =>
p.Status == PurchaseOrderStatus.Draft ||
p.Status == PurchaseOrderStatus.Submitted ||
p.Status == PurchaseOrderStatus.PartiallyReceived);
ViewBag.CommittedValue = allForStats
.Where(p => p.Status != PurchaseOrderStatus.Cancelled)
.Sum(p => p.TotalAmount);
ViewBag.OverdueCount = allForStats.Count(p =>
(p.Status == PurchaseOrderStatus.Draft || p.Status == PurchaseOrderStatus.Submitted || p.Status == PurchaseOrderStatus.PartiallyReceived)
&& p.ExpectedDeliveryDate.HasValue
&& p.ExpectedDeliveryDate.Value.Date < DateTime.UtcNow.Date);
await PopulateVendorFilterDropdownAsync(currentUser.CompanyId);
ViewBag.SearchTerm = searchTerm;
ViewBag.StatusFilter = statusFilter;
ViewBag.VendorId = vendorId;
ViewBag.DateFrom = dateFrom?.ToString("yyyy-MM-dd");
ViewBag.DateTo = dateTo?.ToString("yyyy-MM-dd");
ViewBag.SortColumn = sortColumn ?? "orderdate";
ViewBag.SortDirection = sortDirection;
return View(result);
}
// -----------------------------------------------------------------------
// GET: /PurchaseOrders/Details/5
// -----------------------------------------------------------------------
/// <summary>
/// Displays full PO detail including vendor info, linked bill (if one has been created), and
/// all non-deleted line items with their associated inventory items.
/// </summary>
public async Task<IActionResult> Details(int id)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var po = await _context.Set<PurchaseOrder>()
.Include(p => p.Vendor)
.Include(p => p.Bill)
.Include(p => p.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.InventoryItem)
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
if (po == null) return NotFound();
var dto = _mapper.Map<PurchaseOrderDto>(po);
return View(dto);
}
// -----------------------------------------------------------------------
// GET: /PurchaseOrders/Create
// -----------------------------------------------------------------------
/// <summary>
/// Returns the blank PO creation form with today's date pre-filled. Vendors and inventory
/// items are loaded via <see cref="PopulateCreateViewBagAsync"/> and serialised to JSON so
/// the dynamic line-item UI can look up unit-of-measure and last purchase price without
/// additional round trips.
/// </summary>
public async Task<IActionResult> Create()
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
await PopulateCreateViewBagAsync(currentUser.CompanyId);
return View(new CreatePurchaseOrderDto { OrderDate = DateTime.Today });
}
// -----------------------------------------------------------------------
// POST: /PurchaseOrders/Create
// -----------------------------------------------------------------------
/// <summary>
/// Persists a new purchase order in <c>Draft</c> status. POs start as drafts so purchasing
/// staff can review before formally submitting to the vendor via <see cref="Submit"/>.
/// Custom line items (those without a linked inventory item) must have an explicit description
/// because the item name cannot be inferred from inventory. LineTotal and PO totals are
/// computed server-side from quantity × unit cost to prevent client-side manipulation.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreatePurchaseOrderDto dto)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
if (!dto.Items.Any())
ModelState.AddModelError("", "At least one line item is required.");
foreach (var item in dto.Items.Where(i => !i.InventoryItemId.HasValue))
{
if (string.IsNullOrWhiteSpace(item.Description))
ModelState.AddModelError("", "Custom line items require a description.");
}
if (!ModelState.IsValid)
{
await PopulateCreateViewBagAsync(currentUser.CompanyId);
return View(dto);
}
try
{
var poNumber = await GeneratePoNumberAsync(currentUser.CompanyId);
var po = _mapper.Map<PurchaseOrder>(dto);
po.PoNumber = poNumber;
po.Status = PurchaseOrderStatus.Draft;
po.CompanyId = currentUser.CompanyId;
foreach (var itemDto in dto.Items)
{
var item = _mapper.Map<PurchaseOrderItem>(itemDto);
item.LineTotal = item.QuantityOrdered * item.UnitCost;
item.CompanyId = currentUser.CompanyId;
po.Items.Add(item);
}
po.SubTotal = po.Items.Sum(i => i.LineTotal);
po.TotalAmount = po.SubTotal + po.ShippingCost;
await _unitOfWork.PurchaseOrders.AddAsync(po);
await _unitOfWork.CompleteAsync();
this.ToastSuccess($"Purchase Order {po.PoNumber} created.");
return RedirectToAction(nameof(Details), new { id = po.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating purchase order");
ModelState.AddModelError("", "An error occurred while creating the purchase order.");
await PopulateCreateViewBagAsync(currentUser.CompanyId);
return View(dto);
}
}
// -----------------------------------------------------------------------
// GET: /PurchaseOrders/Edit/5
// -----------------------------------------------------------------------
/// <summary>
/// Returns the edit form for a draft PO. Only <c>Draft</c> POs are editable; once submitted
/// the vendor may have already received and acted on the order so changes would cause
/// discrepancies between what the vendor was told and what is recorded internally.
/// </summary>
public async Task<IActionResult> Edit(int id)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var po = await _context.Set<PurchaseOrder>()
.Include(p => p.Items.Where(i => !i.IsDeleted))
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
if (po == null) return NotFound();
if (po.Status != PurchaseOrderStatus.Draft)
{
TempData["Error"] = "Only Draft purchase orders can be edited.";
return RedirectToAction(nameof(Details), new { id });
}
var dto = new UpdatePurchaseOrderDto
{
VendorId = po.VendorId,
OrderDate = po.OrderDate,
ExpectedDeliveryDate = po.ExpectedDeliveryDate,
ShippingCost = po.ShippingCost,
Notes = po.Notes,
InternalNotes = po.InternalNotes,
Items = po.Items.Select(i => new CreatePurchaseOrderItemDto
{
InventoryItemId = i.InventoryItemId,
Description = i.Description,
UnitOfMeasure = i.UnitOfMeasure,
QuantityOrdered = i.QuantityOrdered,
UnitCost = i.UnitCost,
Notes = i.Notes
}).ToList()
};
await PopulateCreateViewBagAsync(currentUser.CompanyId);
ViewBag.PoId = id;
ViewBag.PoNumber = po.PoNumber;
return View(dto);
}
// -----------------------------------------------------------------------
// POST: /PurchaseOrders/Edit/5
// -----------------------------------------------------------------------
/// <summary>
/// Saves PO edits using a soft-delete + re-insert pattern for line items so that previously
/// submitted item records are preserved in the database for audit purposes. Active item
/// subtotals and shipping are recomputed after the new items are added to keep
/// <c>SubTotal</c> and <c>TotalAmount</c> consistent.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdatePurchaseOrderDto dto)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var po = await _context.Set<PurchaseOrder>()
.Include(p => p.Items.Where(i => !i.IsDeleted))
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
if (po == null) return NotFound();
if (po.Status != PurchaseOrderStatus.Draft)
{
TempData["Error"] = "Only Draft purchase orders can be edited.";
return RedirectToAction(nameof(Details), new { id });
}
if (!dto.Items.Any())
ModelState.AddModelError("", "At least one line item is required.");
foreach (var item in dto.Items.Where(i => !i.InventoryItemId.HasValue))
{
if (string.IsNullOrWhiteSpace(item.Description))
ModelState.AddModelError("", "Custom line items require a description.");
}
if (!ModelState.IsValid)
{
await PopulateCreateViewBagAsync(currentUser.CompanyId);
ViewBag.PoId = id;
ViewBag.PoNumber = po.PoNumber;
return View(dto);
}
try
{
_mapper.Map(dto, po);
// Soft-delete existing items then add fresh ones
foreach (var existing in po.Items)
{
existing.IsDeleted = true;
existing.UpdatedAt = DateTime.UtcNow;
}
foreach (var itemDto in dto.Items)
{
var item = _mapper.Map<PurchaseOrderItem>(itemDto);
item.LineTotal = item.QuantityOrdered * item.UnitCost;
item.CompanyId = currentUser.CompanyId;
po.Items.Add(item);
}
var activeItems = po.Items.Where(i => !i.IsDeleted).ToList();
po.SubTotal = activeItems.Sum(i => i.LineTotal);
po.TotalAmount = po.SubTotal + po.ShippingCost;
po.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
this.ToastSuccess($"Purchase Order {po.PoNumber} updated.");
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating purchase order {PoId}", id);
ModelState.AddModelError("", "An error occurred while updating the purchase order.");
await PopulateCreateViewBagAsync(currentUser.CompanyId);
ViewBag.PoId = id;
ViewBag.PoNumber = po.PoNumber;
return View(dto);
}
}
// -----------------------------------------------------------------------
// GET: /PurchaseOrders/Delete/5
// -----------------------------------------------------------------------
/// <summary>
/// Returns the delete confirmation page for a PO. Only <c>Draft</c> or <c>Cancelled</c> POs
/// may be deleted; submitted/received POs have inventory and accounting implications that
/// prevent safe removal.
/// </summary>
public async Task<IActionResult> Delete(int id)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var po = await _context.Set<PurchaseOrder>()
.Include(p => p.Vendor)
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
if (po == null) return NotFound();
if (po.Status != PurchaseOrderStatus.Draft && po.Status != PurchaseOrderStatus.Cancelled)
{
TempData["Error"] = "Only Draft or Cancelled purchase orders can be deleted.";
return RedirectToAction(nameof(Details), new { id });
}
return View(_mapper.Map<PurchaseOrderListDto>(po));
}
// -----------------------------------------------------------------------
// POST: /PurchaseOrders/Delete/5
// -----------------------------------------------------------------------
/// <summary>
/// Soft-deletes a draft or cancelled PO. The PO number is preserved in the database (via
/// soft delete rather than physical removal) so the sequence cannot be reused and the number
/// gap is explainable in audit logs.
/// </summary>
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(id);
if (po == null || po.CompanyId != currentUser.CompanyId) return NotFound();
if (po.Status != PurchaseOrderStatus.Draft && po.Status != PurchaseOrderStatus.Cancelled)
{
TempData["Error"] = "Only Draft or Cancelled purchase orders can be deleted.";
return RedirectToAction(nameof(Details), new { id });
}
try
{
await _unitOfWork.PurchaseOrders.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
this.ToastSuccess($"Purchase Order {po.PoNumber} deleted.");
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting purchase order {PoId}", id);
TempData["Error"] = "An error occurred while deleting the purchase order.";
return RedirectToAction(nameof(Details), new { id });
}
}
// -----------------------------------------------------------------------
// POST: /PurchaseOrders/Submit/5
// -----------------------------------------------------------------------
/// <summary>
/// Advances a PO from <c>Draft</c> to <c>Submitted</c>, signalling that the order has been
/// formally sent to the vendor. A PO with no line items cannot be submitted because the
/// vendor would have nothing to fulfil. Submitted POs become read-only; any changes require
/// cancelling and creating a new PO (or discussing an amendment with the vendor off-system).
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Submit(int id)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var po = await _context.Set<PurchaseOrder>()
.Include(p => p.Items.Where(i => !i.IsDeleted))
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
if (po == null) return NotFound();
if (po.Status != PurchaseOrderStatus.Draft)
{
TempData["Error"] = "Only Draft purchase orders can be submitted.";
return RedirectToAction(nameof(Details), new { id });
}
if (!po.Items.Any())
{
TempData["Error"] = "Cannot submit a purchase order with no line items.";
return RedirectToAction(nameof(Details), new { id });
}
po.Status = PurchaseOrderStatus.Submitted;
po.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
this.ToastSuccess($"Purchase Order {po.PoNumber} submitted.");
return RedirectToAction(nameof(Details), new { id });
}
// -----------------------------------------------------------------------
// POST: /PurchaseOrders/Cancel/5
// -----------------------------------------------------------------------
/// <summary>
/// Cancels a PO at any status except <c>Received</c>. Received POs cannot be cancelled
/// because the goods have already entered inventory and a vendor bill may exist; undoing
/// receipt would require separate inventory adjustments and bill voids. Cancelled POs are
/// excluded from committed-spend reporting but remain in the database for audit purposes.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Cancel(int id)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(id);
if (po == null || po.CompanyId != currentUser.CompanyId) return NotFound();
if (po.Status == PurchaseOrderStatus.Received)
{
TempData["Error"] = "Received purchase orders cannot be cancelled.";
return RedirectToAction(nameof(Details), new { id });
}
po.Status = PurchaseOrderStatus.Cancelled;
po.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
this.ToastSuccess($"Purchase Order {po.PoNumber} cancelled.");
return RedirectToAction(nameof(Details), new { id });
}
// -----------------------------------------------------------------------
// GET: /PurchaseOrders/Receive/5
// -----------------------------------------------------------------------
/// <summary>
/// Returns the goods-receipt form pre-filled with each line item's remaining-to-receive
/// quantity (ordered minus already received). Only <c>Submitted</c> or
/// <c>PartiallyReceived</c> POs can accept receipts — <c>Draft</c> POs have not been sent
/// and <c>Received</c> POs are already complete.
/// </summary>
public async Task<IActionResult> Receive(int id)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var po = await _context.Set<PurchaseOrder>()
.Include(p => p.Vendor)
.Include(p => p.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.InventoryItem)
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
if (po == null) return NotFound();
if (po.Status != PurchaseOrderStatus.Submitted && po.Status != PurchaseOrderStatus.PartiallyReceived)
{
TempData["Error"] = "Only Submitted or Partially Received purchase orders can receive goods.";
return RedirectToAction(nameof(Details), new { id });
}
var dto = new ReceivePurchaseOrderDto
{
ReceivedDate = DateTime.Today,
Items = po.Items.Select(i => new ReceiveItemDto
{
PurchaseOrderItemId = i.Id,
InventoryItemId = i.InventoryItemId,
ItemName = i.InventoryItem?.Name ?? i.Description ?? string.Empty,
ItemSKU = i.InventoryItem?.SKU ?? string.Empty,
UnitOfMeasure = i.InventoryItem?.UnitOfMeasure ?? i.UnitOfMeasure ?? string.Empty,
QuantityOrdered = i.QuantityOrdered,
QuantityAlreadyReceived = i.QuantityReceived,
QuantityRemaining = Math.Max(0, i.QuantityOrdered - i.QuantityReceived),
QuantityToReceive = Math.Max(0, i.QuantityOrdered - i.QuantityReceived)
}).ToList()
};
ViewBag.PoNumber = po.PoNumber;
ViewBag.VendorName = po.Vendor?.CompanyName;
ViewBag.OrderDate = po.OrderDate;
ViewBag.PoId = id;
return View(dto);
}
// -----------------------------------------------------------------------
// POST: /PurchaseOrders/Receive/5
// -----------------------------------------------------------------------
/// <summary>
/// Records the physical receipt of goods and updates inventory stock levels atomically inside
/// a database transaction. For each linked inventory item the weighted-average cost is
/// recalculated as <c>(existingQty * existingAvgCost + receivedQty * poUnitCost) / newTotalQty</c>
/// so that the running average reflects the blended acquisition cost over multiple purchases.
/// An <see cref="InventoryTransaction"/> of type <c>Purchase</c> is written for each item
/// received to provide a complete stock ledger. Quantities are clamped to the remaining
/// receivable amount to prevent over-receipt. PO status advances to <c>PartiallyReceived</c>
/// or <c>Received</c> based on whether all items have been fully received.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Receive(int id, ReceivePurchaseOrderDto dto)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var po = await _context.Set<PurchaseOrder>()
.Include(p => p.Vendor)
.Include(p => p.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.InventoryItem)
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
if (po == null) return NotFound();
if (po.Status != PurchaseOrderStatus.Submitted && po.Status != PurchaseOrderStatus.PartiallyReceived)
{
TempData["Error"] = "Only Submitted or Partially Received purchase orders can receive goods.";
return RedirectToAction(nameof(Details), new { id });
}
var hasAnyQuantity = dto.Items.Any(i => i.QuantityToReceive > 0);
if (!hasAnyQuantity)
{
TempData["Error"] = "Enter a quantity greater than 0 for at least one item.";
ViewBag.PoNumber = po.PoNumber;
ViewBag.VendorName = po.Vendor?.CompanyName;
ViewBag.OrderDate = po.OrderDate;
ViewBag.PoId = id;
return View(dto);
}
var allReceived = false;
try
{
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
foreach (var receiveDto in dto.Items.Where(i => i.QuantityToReceive > 0))
{
var poItem = po.Items.FirstOrDefault(i => i.Id == receiveDto.PurchaseOrderItemId);
if (poItem == null) continue;
// Clamp to remaining quantity
var maxReceivable = poItem.QuantityOrdered - poItem.QuantityReceived;
var qtyToReceive = Math.Min(receiveDto.QuantityToReceive, maxReceivable);
if (qtyToReceive <= 0) continue;
// Only update inventory for linked inventory items
if (poItem.InventoryItemId.HasValue)
{
var inventoryItem = poItem.InventoryItem;
if (inventoryItem != null)
{
// Recalculate weighted average cost
var newTotalQty = inventoryItem.QuantityOnHand + qtyToReceive;
if (newTotalQty > 0)
{
inventoryItem.AverageCost = Math.Round(
(inventoryItem.QuantityOnHand * inventoryItem.AverageCost + qtyToReceive * poItem.UnitCost)
/ newTotalQty, 4);
}
inventoryItem.QuantityOnHand += qtyToReceive;
inventoryItem.LastPurchasePrice = poItem.UnitCost;
inventoryItem.LastPurchaseDate = dto.ReceivedDate;
var transaction = new InventoryTransaction
{
InventoryItemId = poItem.InventoryItemId.Value,
TransactionType = InventoryTransactionType.Purchase,
Quantity = qtyToReceive,
UnitCost = poItem.UnitCost,
TotalCost = qtyToReceive * poItem.UnitCost,
TransactionDate = dto.ReceivedDate,
Reference = po.PoNumber,
Notes = dto.Notes,
BalanceAfter = inventoryItem.QuantityOnHand,
PurchaseOrderId = po.Id,
CompanyId = po.CompanyId
};
await _context.Set<InventoryTransaction>().AddAsync(transaction);
}
}
poItem.QuantityReceived += qtyToReceive;
poItem.LineTotal = poItem.QuantityOrdered * poItem.UnitCost;
poItem.UpdatedAt = DateTime.UtcNow;
}
// Recalculate PO totals and status
var activeItems = po.Items.Where(i => !i.IsDeleted).ToList();
po.SubTotal = activeItems.Sum(i => i.LineTotal);
po.TotalAmount = po.SubTotal + po.ShippingCost;
allReceived = activeItems.All(i => i.QuantityReceived >= i.QuantityOrdered);
var anyReceived = activeItems.Any(i => i.QuantityReceived > 0);
if (allReceived)
{
po.Status = PurchaseOrderStatus.Received;
po.ReceivedDate = dto.ReceivedDate;
}
else if (anyReceived)
{
po.Status = PurchaseOrderStatus.PartiallyReceived;
}
po.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}); // end ExecuteInTransactionAsync
this.ToastSuccess(allReceived
? $"All items received for {po.PoNumber}."
: $"Partial receipt recorded for {po.PoNumber}.");
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error receiving goods for PO {PoId}", id);
TempData["Error"] = "An error occurred while recording the receipt.";
return RedirectToAction(nameof(Receive), new { id });
}
}
// -----------------------------------------------------------------------
// GET: /PurchaseOrders/DownloadPdf/5
// -----------------------------------------------------------------------
/// <summary>
/// Generates and streams a PDF version of the PO suitable for emailing to the vendor.
/// Company logo and contact details are embedded in the PDF header using the tenant's
/// stored logo bytes and <c>CompanyInfoDto</c>. PDF generation is delegated to
/// <see cref="IPdfService.GeneratePurchaseOrderPdfAsync"/>.
/// </summary>
public async Task<IActionResult> DownloadPdf(int id)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
try
{
var po = await _context.Set<PurchaseOrder>()
.Include(p => p.Vendor)
.Include(p => p.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.InventoryItem)
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
if (po == null) return NotFound();
var dto = _mapper.Map<PurchaseOrderDto>(po);
var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
var companyInfo = new Application.DTOs.Company.CompanyInfoDto
{
CompanyName = company?.CompanyName ?? string.Empty,
Phone = company?.Phone,
Address = company?.Address,
City = company?.City,
State = company?.State,
ZipCode = company?.ZipCode,
PrimaryContactEmail = company?.PrimaryContactEmail
};
var pdfBytes = await _pdfService.GeneratePurchaseOrderPdfAsync(
dto, company?.LogoData, company?.LogoContentType, companyInfo);
return File(pdfBytes, "application/pdf", $"{po.PoNumber}.pdf");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating PDF for PO {Id}", id);
TempData["Error"] = "An error occurred while generating the PDF.";
return RedirectToAction(nameof(Details), new { id });
}
}
// -----------------------------------------------------------------------
// GET: /PurchaseOrders/CreateFromLowStock
// -----------------------------------------------------------------------
/// <summary>
/// Shortcut that pre-populates a new PO from the low-stock inventory alert list. Only items
/// with a <c>PrimaryVendorId</c> are eligible because the vendor field is required on a PO.
/// Items are grouped by vendor so the user can choose which vendor's low-stock items to order
/// in a single PO (you cannot mix vendors on one PO). When a vendor is selected the DTO is
/// pre-filled with each low-stock item's <c>ReorderQuantity</c> (or 1 if not set) and the
/// last purchase price as the default unit cost, saving manual data entry.
/// </summary>
public async Task<IActionResult> CreateFromLowStock(int? vendorId)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
// Find low-stock items that have a primary vendor
var lowStockItems = await _context.Set<InventoryItem>()
.Include(i => i.PrimaryVendor)
.Where(i => !i.IsDeleted
&& i.IsActive
&& i.CompanyId == currentUser.CompanyId
&& i.PrimaryVendorId != null
&& i.QuantityOnHand <= i.ReorderPoint)
.ToListAsync();
if (!lowStockItems.Any())
{
TempData["Info"] = "No low-stock items with a primary vendor were found.";
return RedirectToAction(nameof(Create));
}
// Group by vendor for the selection step
var vendorGroups = lowStockItems
.GroupBy(i => new { i.PrimaryVendorId, VendorName = i.PrimaryVendor?.CompanyName ?? "Unknown" })
.Select(g => new
{
VendorId = g.Key.PrimaryVendorId!.Value,
VendorName = g.Key.VendorName,
ItemCount = g.Count()
})
.OrderBy(v => v.VendorName)
.ToList();
// If no vendor selected yet, show selection page
if (!vendorId.HasValue)
{
ViewBag.VendorGroups = vendorGroups;
return View("SelectLowStockVendor");
}
// Pre-populate a Create form for the selected vendor
var vendorItems = lowStockItems.Where(i => i.PrimaryVendorId == vendorId.Value).ToList();
if (!vendorItems.Any())
{
TempData["Info"] = "No low-stock items found for that vendor.";
return RedirectToAction(nameof(CreateFromLowStock));
}
var dto = new CreatePurchaseOrderDto
{
VendorId = vendorId.Value,
OrderDate = DateTime.Today,
Items = vendorItems.Select(i => new CreatePurchaseOrderItemDto
{
InventoryItemId = i.Id,
QuantityOrdered = i.ReorderQuantity > 0 ? i.ReorderQuantity : 1,
UnitCost = i.LastPurchasePrice > 0 ? i.LastPurchasePrice : i.UnitCost
}).ToList()
};
await PopulateCreateViewBagAsync(currentUser.CompanyId);
ViewBag.FromLowStock = true;
return View("Create", dto);
}
// -----------------------------------------------------------------------
// Private helpers
// -----------------------------------------------------------------------
/// <summary>
/// Generates a sequential PO number in the format <c>PO-YYMM-####</c>. Uses
/// <c>IgnoreQueryFilters()</c> to include soft-deleted POs in the scan so numbers are never
/// reused. Scoped to the current company so each tenant has its own independent sequence.
/// </summary>
private async Task<string> GeneratePoNumberAsync(int companyId)
{
var prefix = $"PO-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
var existing = await _context.Set<PurchaseOrder>()
.IgnoreQueryFilters()
.Where(po => po.CompanyId == companyId && po.PoNumber.StartsWith(prefix))
.Select(po => po.PoNumber)
.ToListAsync();
var maxNum = 0;
foreach (var num in existing)
{
var suffix = num.Length >= prefix.Length + 4 ? num.Substring(prefix.Length) : "";
if (int.TryParse(suffix, out int n) && n > maxNum)
maxNum = n;
}
return $"{prefix}{(maxNum + 1):D4}";
}
/// <summary>
/// Loads Create/Edit form dropdowns: active vendors and active inventory items. Inventory
/// items are serialised to a JSON blob in <c>ViewBag.InventoryItemsJson</c> so the dynamic
/// line-item UI can look up unit-of-measure and cost on the client side without extra AJAX
/// calls. The cost preference order is last purchase price then catalog unit cost.
/// </summary>
private async Task PopulateCreateViewBagAsync(int companyId)
{
var vendors = await _context.Set<Vendor>()
.Where(v => !v.IsDeleted && v.CompanyId == companyId && v.IsActive)
.OrderBy(v => v.CompanyName)
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString()))
.ToListAsync();
vendors.Insert(0, new SelectListItem("— Select Vendor —", ""));
ViewBag.Vendors = vendors;
var inventoryItems = await _context.Set<InventoryItem>()
.Where(i => !i.IsDeleted && i.CompanyId == companyId && i.IsActive)
.OrderBy(i => i.Name)
.Select(i => new
{
value = i.Id,
text = i.Name + (!string.IsNullOrEmpty(i.SKU) ? $" ({i.SKU})" : ""),
uom = i.UnitOfMeasure ?? "units",
cost = i.LastPurchasePrice > 0 ? i.LastPurchasePrice : i.UnitCost
})
.ToListAsync();
ViewBag.InventoryItemsJson = System.Text.Json.JsonSerializer.Serialize(inventoryItems);
}
/// <summary>
/// Loads the vendor filter dropdown for the Index view. Unlike
/// <see cref="PopulateCreateViewBagAsync"/> this includes inactive vendors so that historical
/// POs for deactivated vendors remain searchable.
/// </summary>
private async Task PopulateVendorFilterDropdownAsync(int companyId)
{
var vendors = await _context.Set<Vendor>()
.Where(v => !v.IsDeleted && v.CompanyId == companyId)
.OrderBy(v => v.CompanyName)
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString()))
.ToListAsync();
vendors.Insert(0, new SelectListItem("All Vendors", ""));
ViewBag.VendorList = vendors;
}
}
@@ -0,0 +1,560 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Hubs;
using PowderCoating.Web.ViewModels;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Customer-facing, unauthenticated quote approval portal. Customers reach these pages via a signed
/// link emailed to them; security is enforced entirely by the opaque <c>ApprovalToken</c> GUID
/// rather than ASP.NET Core Identity. All mutating actions write a <c>QuoteChangeHistory</c> audit
/// record and push real-time notifications to logged-in staff via SignalR <c>NotificationHub</c>.
/// </summary>
[Route("quote-approval")]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Public)]
public class QuoteApprovalController : Controller
{
private readonly ApplicationDbContext _db;
private readonly INotificationService _notifications;
private readonly IInAppNotificationService _inApp;
private readonly IStripeConnectService _stripeConnect;
private readonly ILogger<QuoteApprovalController> _logger;
private readonly IConfiguration _configuration;
private readonly IHubContext<NotificationHub> _hub;
public QuoteApprovalController(
ApplicationDbContext db,
INotificationService notifications,
IInAppNotificationService inApp,
IStripeConnectService stripeConnect,
ILogger<QuoteApprovalController> logger,
IConfiguration configuration,
IHubContext<NotificationHub> hub)
{
_db = db;
_notifications = notifications;
_inApp = inApp;
_stripeConnect = stripeConnect;
_logger = logger;
_configuration = configuration;
_hub = hub;
}
/// <summary>
/// Renders the main customer-facing approval page showing quote line items, totals, and
/// Approve/Decline buttons. The <c>[ActionName("View")]</c> attribute overrides the method name
/// so the route is <c>/quote-approval/{token}</c> without exposing the internal method name.
/// All token validation (expiry, already-acted) is centralised in <see cref="ValidateTokenAsync"/>.
/// </summary>
// GET /quote-approval/{token}
[HttpGet("{token}")]
[ActionName("View")]
public async Task<IActionResult> ShowApprovalPage(string token)
{
var (quote, errorResult) = await ValidateTokenAsync(token);
if (errorResult != null) return errorResult;
var model = await BuildViewModelAsync(quote!, token);
return base.View("ApprovalPage", model);
}
/// <summary>
/// Shows the contact-details confirmation step for prospect (non-customer) quotes. Prospects
/// have no <c>CustomerId</c> on the quote, so we collect their contact information before
/// finalising the approval. Quotes already linked to a customer skip this step and go directly
/// to <see cref="ApproveInternal"/> — a customer's details are already on file.
/// </summary>
// GET /quote-approval/{token}/confirm-details
[HttpGet("{token}/confirm-details")]
public async Task<IActionResult> ConfirmDetails(string token)
{
var (quote, errorResult) = await ValidateTokenAsync(token);
if (errorResult != null) return errorResult;
// Only prospects need to fill in details; linked customers go straight through.
if (quote!.CustomerId.HasValue)
return await ApproveInternal(token, quote);
var model = await BuildViewModelAsync(quote, token);
return base.View("ConfirmDetails", model);
}
/// <summary>
/// Handles the contact-detail form submission from prospects. Requires at minimum a name and
/// either an email or phone number — this minimal validation mirrors the fields required to
/// later convert the quote to a customer record. On success, persists the prospect contact
/// details to the quote and delegates to <see cref="ApproveInternal"/> so the approval and
/// audit trail are written in a single path shared with the customer flow.
/// </summary>
// POST /quote-approval/{token}/confirm-details
[HttpPost("{token}/confirm-details")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SubmitDetails(string token,
[FromForm] string? contactName,
[FromForm] string? email,
[FromForm] string? phone,
[FromForm] string? companyName,
[FromForm] string? address,
[FromForm] string? city,
[FromForm] string? state,
[FromForm] string? zipCode)
{
var (quote, errorResult) = await ValidateTokenAsync(token);
if (errorResult != null) return errorResult;
// Require at minimum a name and either email or phone
if (string.IsNullOrWhiteSpace(contactName) ||
(string.IsNullOrWhiteSpace(email) && string.IsNullOrWhiteSpace(phone)))
{
var model = await BuildViewModelAsync(quote!, token);
model.ProspectContactName = contactName;
model.ProspectEmail = email;
model.ProspectPhone = phone;
model.ProspectCompanyName = companyName;
model.ProspectAddress = address;
model.ProspectCity = city;
model.ProspectState = state;
model.ProspectZipCode = zipCode;
model.DeclineError = "Please enter your name and at least one contact method (email or phone).";
return base.View("ConfirmDetails", model);
}
// Update prospect fields on the quote
quote!.ProspectContactName = contactName?.Trim();
quote.ProspectEmail = email?.Trim();
quote.ProspectPhone = phone?.Trim();
quote.ProspectCompanyName = companyName?.Trim();
quote.ProspectAddress = address?.Trim();
quote.ProspectCity = city?.Trim();
quote.ProspectState = state?.Trim();
quote.ProspectZipCode = zipCode?.Trim();
quote.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return await ApproveInternal(token, quote);
}
/// <summary>
/// Entry point for the Approve button on the approval page. For prospect quotes, redirects to
/// the contact-details form rather than approving immediately. For customer-linked quotes,
/// delegates directly to <see cref="ApproveInternal"/>. This two-path design keeps the main
/// approval page simple (one Approve button) while still collecting required prospect data.
/// </summary>
// POST /quote-approval/{token}/approve
[HttpPost("{token}/approve")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Approve(string token)
{
var (quote, errorResult) = await ValidateTokenAsync(token);
if (errorResult != null) return errorResult;
// Prospect quotes collect contact details first
if (!quote!.CustomerId.HasValue)
{
var model = await BuildViewModelAsync(quote, token);
return base.View("ConfirmDetails", model);
}
return await ApproveInternal(token, quote!);
}
/// <summary>
/// Core approval logic shared by both the customer path (<see cref="Approve"/>) and the prospect
/// path (<see cref="SubmitDetails"/>). Sets the quote status to the company's designated
/// <c>IsApprovedStatus</c> lookup entry, records <c>ApprovalTokenUsedAt</c> to prevent token
/// reuse, clears any prior decline reason (customers can re-approve after declining), and writes
/// a <c>QuoteChangeHistory</c> audit entry with <c>ChangedByUserId = null</c> to indicate the
/// action was performed by the customer rather than a staff member. After persisting, a SignalR
/// push notifies logged-in staff in real time. If the quote requires a deposit and the company
/// has Stripe Connect active, a time-limited deposit payment link token (7 days) is generated
/// and saved so the confirmation page can surface a "Pay deposit online" button. Prospect quotes
/// skip the deposit link because there is no <c>Customer</c> row to attach a <c>Deposit</c> to.
/// </summary>
private async Task<IActionResult> ApproveInternal(string token, Quote quote)
{
var approvedStatus = await _db.QuoteStatusLookups
.IgnoreQueryFilters()
.FirstOrDefaultAsync(s => s.CompanyId == quote.CompanyId && s.IsApprovedStatus && !s.IsDeleted);
var oldStatusName = quote.QuoteStatus?.DisplayName ?? "Unknown";
if (approvedStatus != null)
quote.QuoteStatusId = approvedStatus.Id;
quote.ApprovedDate = DateTime.UtcNow;
quote.ApprovalTokenUsedAt = DateTime.UtcNow;
string? priorDeclineReason = null;
if (!string.IsNullOrWhiteSpace(quote.DeclineReason))
{
priorDeclineReason = quote.DeclineReason;
quote.DeclineReason = null;
}
await _db.SaveChangesAsync();
var approveEntry = new PowderCoating.Core.Entities.QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = null,
ChangedAt = DateTime.UtcNow,
FieldName = "Status",
OldValue = oldStatusName,
NewValue = approvedStatus?.DisplayName ?? "Approved",
ChangeDescription = priorDeclineReason != null
? $"Quote approved by customer (previously declined: \"{priorDeclineReason}\")"
: "Quote approved by customer",
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow
};
_db.QuoteChangeHistories.Add(approveEntry);
await _db.SaveChangesAsync();
await _hub.Clients.Group($"company-{quote.CompanyId}").SendAsync("QuoteActedByCustomer", new
{
approved = true,
quoteNumber = quote.QuoteNumber,
customerName = GetCustomerName(quote),
quoteId = quote.Id,
isProspect = !quote.CustomerId.HasValue
});
var customerName = GetCustomerName(quote);
await _inApp.CreateAsync(
companyId: quote.CompanyId,
title: "Quote Approved",
message: $"{customerName} approved quote {quote.QuoteNumber}.",
notificationType: "QuoteApproved",
link: $"/Quotes/Details/{quote.Id}",
quoteId: quote.Id,
customerId: quote.CustomerId);
try
{
await _notifications.NotifyQuoteActedByCustomerAsync(quote, approved: true, declineReason: null);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send approval notification for quote {QuoteId}", quote.Id);
}
// Generate deposit payment link if this quote requires a deposit and the company has Stripe Connect
if (quote.RequiresDeposit
&& quote.DepositPercent > 0
&& quote.CustomerId.HasValue // prospects don't have a Customer row to attach a Deposit to
&& quote.DepositAmountPaid <= 0)
{
var company = await _db.Companies.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId && !c.IsDeleted);
if (company?.StripeConnectStatus == StripeConnectStatus.Active)
{
quote.DepositPaymentLinkToken = Guid.NewGuid().ToString("N");
quote.DepositPaymentLinkExpiresAt = DateTime.UtcNow.AddDays(7);
await _db.SaveChangesAsync();
}
}
return Redirect($"/quote-approval/{token}/confirmation?action=approved");
}
/// <summary>
/// Handles the customer declining a quote. Requires a non-empty reason (enforced with a
/// field-level error, not ModelState) so staff always know why a quote was rejected. The
/// decline reason is truncated to 1000 characters before persistence to avoid oversized inputs.
/// The customer's IP is recorded (<c>DeclinedByIp</c>) for audit purposes. A SignalR event and
/// in-app notification are pushed to staff. The token is marked used (<c>ApprovalTokenUsedAt</c>)
/// so the customer cannot approve after declining via the same link — they must request a new
/// link from the shop.
/// </summary>
// POST /quote-approval/{token}/decline
[HttpPost("{token}/decline")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Decline(string token, [FromForm] string reason)
{
var (quote, errorResult) = await ValidateTokenAsync(token);
if (errorResult != null) return errorResult;
if (string.IsNullOrWhiteSpace(reason))
{
var model = await BuildViewModelAsync(quote!, token);
model.DeclineError = "Please enter a reason for declining.";
return base.View("ApprovalPage", model);
}
// Find the rejected status for this company (by flag, or fall back to StatusCode)
var rejectedStatus = await _db.QuoteStatusLookups
.IgnoreQueryFilters()
.FirstOrDefaultAsync(s => s.CompanyId == quote!.CompanyId && s.IsRejectedStatus && !s.IsDeleted)
?? await _db.QuoteStatusLookups
.IgnoreQueryFilters()
.FirstOrDefaultAsync(s => s.CompanyId == quote!.CompanyId && s.StatusCode == "REJECTED" && !s.IsDeleted);
var oldDeclineStatusName = quote!.QuoteStatus?.DisplayName ?? "Unknown";
if (rejectedStatus != null)
quote!.QuoteStatusId = rejectedStatus.Id;
var trimmedReason = reason.Trim();
quote!.DeclineReason = trimmedReason[..Math.Min(1000, trimmedReason.Length)];
quote.DeclinedByIp = HttpContext.Connection.RemoteIpAddress?.ToString();
quote.ApprovalTokenUsedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
// Audit log — decline
var declineEntry = new PowderCoating.Core.Entities.QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = null,
ChangedAt = DateTime.UtcNow,
FieldName = "Status",
OldValue = oldDeclineStatusName,
NewValue = rejectedStatus?.DisplayName ?? "Rejected",
ChangeDescription = $"Quote declined by customer. Reason: \"{trimmedReason}\"",
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow
};
_db.QuoteChangeHistories.Add(declineEntry);
await _db.SaveChangesAsync();
// Push real-time toast to any logged-in company users
await _hub.Clients.Group($"company-{quote.CompanyId}").SendAsync("QuoteActedByCustomer", new
{
approved = false,
quoteNumber = quote.QuoteNumber,
customerName = GetCustomerName(quote),
declineReason = trimmedReason,
quoteId = quote.Id
});
var declineCustomerName = GetCustomerName(quote);
await _inApp.CreateAsync(
companyId: quote.CompanyId,
title: "Quote Declined",
message: $"{declineCustomerName} declined quote {quote.QuoteNumber}. Reason: {trimmedReason[..Math.Min(100, trimmedReason.Length)]}",
notificationType: "QuoteDeclined",
link: $"/Quotes/Details/{quote.Id}",
quoteId: quote.Id,
customerId: quote.CustomerId);
try
{
await _notifications.NotifyQuoteActedByCustomerAsync(quote, approved: false, declineReason: trimmedReason);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send decline notification for quote {QuoteId}", quote.Id);
}
return Redirect($"/quote-approval/{token}/confirmation?action=declined");
}
/// <summary>
/// Renders the post-action confirmation page shown after the customer approves or declines.
/// Does NOT re-validate the token against the used/expired guards in
/// <see cref="ValidateTokenAsync"/> — by this point the token is already marked used, so those
/// guards would incorrectly redirect to AlreadyActed. Instead, it only checks that the quote
/// exists. The <c>action</c> query-string value ("approved" / "declined") is set by the
/// redirect from <see cref="ApproveInternal"/> or <see cref="Decline"/> and drives the
/// confirmation message in the view. The deposit link token is only surfaced if it is present
/// and not yet expired.
/// </summary>
// GET /quote-approval/{token}/confirmation
[HttpGet("{token}/confirmation")]
public async Task<IActionResult> Confirmation(string token, [FromQuery] string action)
{
var quote = await _db.Quotes
.IgnoreQueryFilters()
.Include(q => q.Customer)
.FirstOrDefaultAsync(q => q.ApprovalToken == token && !q.IsDeleted);
if (quote == null)
return base.View("InvalidToken");
var company = await _db.Companies
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId);
var prefs = await _db.CompanyPreferences
.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.CompanyId == quote.CompanyId && !p.IsDeleted);
var depositAmount = quote.RequiresDeposit && quote.DepositPercent > 0
? Math.Round(quote.Total * (quote.DepositPercent / 100m), 2)
: 0m;
// Only surface the deposit link if it's valid and not expired
var depositToken = (!string.IsNullOrEmpty(quote.DepositPaymentLinkToken)
&& quote.DepositPaymentLinkExpiresAt > DateTime.UtcNow)
? quote.DepositPaymentLinkToken
: null;
var model = new QuoteApprovalViewModel
{
Token = token,
QuoteNumber = quote.QuoteNumber,
CompanyName = company?.CompanyName ?? "Powder Coating",
CompanyPhone = company?.Phone,
CompanyEmail = prefs?.EmailFromAddress,
CustomerName = GetCustomerName(quote),
RequiresDeposit = quote.RequiresDeposit,
DepositPercent = quote.DepositPercent,
DepositAmount = depositAmount,
DepositPaymentLinkToken = depositToken,
DepositAmountPaid = quote.DepositAmountPaid
};
ViewBag.CompanyName = model.CompanyName;
ViewBag.Action = action?.ToLowerInvariant() ?? "approved";
return base.View("Confirmation", model);
}
// -----------------------------------------------------------------------
// Private helpers
// -----------------------------------------------------------------------
/// <summary>
/// Validates the approval token and returns the quote if it is still actionable, or an
/// <c>IActionResult</c> error view if not. Checks in order: token exists, not expired, not
/// already used (<c>ApprovalTokenUsedAt != null</c>), and not already in a terminal status
/// (approved, rejected, or converted). The terminal-status check is a belt-and-suspenders guard
/// for cases where the status was changed by staff inside the app after the token was issued but
/// before the customer clicked. Uses <c>IgnoreQueryFilters</c> because the customer has no
/// tenant context. Loads <c>QuoteItems</c>, <c>QuoteStatus</c>, and <c>Customer</c> eagerly to
/// avoid N+1 queries in <see cref="BuildViewModelAsync"/>.
/// </summary>
private async Task<(Quote? quote, IActionResult? errorResult)> ValidateTokenAsync(string token)
{
if (string.IsNullOrWhiteSpace(token))
return (null, base.View("InvalidToken"));
var quote = await _db.Quotes
.IgnoreQueryFilters()
.Include(q => q.QuoteItems)
.Include(q => q.QuoteStatus)
.Include(q => q.Customer)
.FirstOrDefaultAsync(q => q.ApprovalToken == token && !q.IsDeleted);
if (quote == null)
return (null, base.View("InvalidToken"));
if (quote.ApprovalTokenExpiresAt < DateTime.UtcNow)
{
var expiredModel = await BuildViewModelAsync(quote, token);
return (null, base.View("TokenExpired", expiredModel));
}
if (quote.ApprovalTokenUsedAt != null)
{
var actedModel = await BuildViewModelAsync(quote, token);
actedModel.CurrentStatus = quote.QuoteStatus?.DisplayName;
actedModel.DeclineReason = quote.DeclineReason;
return (null, base.View("AlreadyActed", actedModel));
}
// Also check terminal status
if (quote.QuoteStatus != null &&
(quote.QuoteStatus.IsApprovedStatus || quote.QuoteStatus.IsRejectedStatus || quote.QuoteStatus.IsConvertedStatus))
{
var actedModel = await BuildViewModelAsync(quote, token);
actedModel.CurrentStatus = quote.QuoteStatus.DisplayName;
actedModel.DeclineReason = quote.DeclineReason;
return (null, base.View("AlreadyActed", actedModel));
}
return (quote, null);
}
/// <summary>
/// Builds the <c>QuoteApprovalViewModel</c> from a validated quote. Fetches company details and
/// preferences (e.g. from-email address for the contact section) separately because they are
/// not eagerly loaded by <see cref="ValidateTokenAsync"/>. Soft-deleted line items are filtered
/// out so the customer only sees active items. Also maps all prospect contact fields so the
/// <c>ConfirmDetails</c> view can pre-populate them if the prospect has previously started and
/// returned to the approval page.
/// </summary>
private async Task<QuoteApprovalViewModel> BuildViewModelAsync(Quote quote, string token)
{
var company = await _db.Companies
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId);
var prefs = await _db.CompanyPreferences
.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.CompanyId == quote.CompanyId && !p.IsDeleted);
var items = (quote.QuoteItems ?? new List<QuoteItem>())
.Where(i => !i.IsDeleted)
.Select(i => new QuoteApprovalItemViewModel
{
Description = i.Description,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
TotalPrice = i.TotalPrice
})
.ToList();
return new QuoteApprovalViewModel
{
Token = token,
QuoteNumber = quote.QuoteNumber,
CompanyName = company?.CompanyName ?? "Powder Coating",
CompanyPhone = company?.Phone,
CompanyEmail = prefs?.EmailFromAddress,
CustomerName = GetCustomerName(quote),
CustomerEmail = quote.Customer?.Email ?? quote.ProspectEmail,
ExpirationDate = quote.ExpirationDate,
ApprovalTokenExpiresAt = quote.ApprovalTokenExpiresAt,
SubTotal = quote.SubTotal,
DiscountAmount = quote.DiscountAmount,
HideDiscountFromCustomer = quote.HideDiscountFromCustomer,
RushFee = quote.RushFee,
TaxAmount = quote.TaxAmount,
Total = quote.Total,
Terms = quote.Terms,
Description = quote.Description,
Items = items,
IsProspect = !quote.CustomerId.HasValue,
ProspectContactName = quote.ProspectContactName,
ProspectEmail = quote.ProspectEmail,
ProspectPhone = quote.ProspectPhone,
ProspectCompanyName = quote.ProspectCompanyName,
ProspectAddress = quote.ProspectAddress,
ProspectCity = quote.ProspectCity,
ProspectState = quote.ProspectState,
ProspectZipCode = quote.ProspectZipCode,
};
}
/// <summary>
/// Resolves the display name for the customer or prospect on a quote. Prefers the company name
/// for commercial customers, falls back to contact first/last name, then to prospect fields, and
/// finally to the generic "Valued Customer" sentinel so the view always has something to show.
/// </summary>
private static string GetCustomerName(Quote quote)
{
if (quote.Customer != null)
{
if (!string.IsNullOrWhiteSpace(quote.Customer.CompanyName))
return quote.Customer.CompanyName;
var fullName = $"{quote.Customer.ContactFirstName} {quote.Customer.ContactLastName}".Trim();
if (!string.IsNullOrWhiteSpace(fullName)) return fullName;
}
if (!string.IsNullOrWhiteSpace(quote.ProspectContactName)) return quote.ProspectContactName;
if (!string.IsNullOrWhiteSpace(quote.ProspectCompanyName)) return quote.ProspectCompanyName;
return "Valued Customer";
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,884 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Registration;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Handles new tenant company sign-up via two paths: a free-trial path (immediate account creation)
/// and a paid path (Stripe Checkout session → payment → account creation on return). A
/// <c>PendingRegistrationSession</c> persisted to the database bridges the Stripe redirect gap so
/// registration data survives even if the user's browser session is lost between leaving for
/// Stripe and returning.
/// </summary>
[AllowAnonymous]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Registration)]
public class RegistrationController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ApplicationDbContext _db;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly ISeedDataService _seedDataService;
private readonly IAdminNotificationService _adminNotification;
private readonly IInAppNotificationService _inApp;
private readonly IPlatformSettingsService _platformSettings;
private readonly IStripeService _stripeService;
private readonly IEmailService _emailService;
private readonly ILogger<RegistrationController> _logger;
public RegistrationController(
IUnitOfWork unitOfWork,
ApplicationDbContext db,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
ISeedDataService seedDataService,
IAdminNotificationService adminNotification,
IInAppNotificationService inApp,
IPlatformSettingsService platformSettings,
IStripeService stripeService,
IEmailService emailService,
ILogger<RegistrationController> logger)
{
_unitOfWork = unitOfWork;
_db = db;
_userManager = userManager;
_signInManager = signInManager;
_seedDataService = seedDataService;
_adminNotification = adminNotification;
_inApp = inApp;
_platformSettings = platformSettings;
_stripeService = stripeService;
_emailService = emailService;
_logger = logger;
}
/// <summary>
/// Renders the public registration page. Redirects already-authenticated users to the dashboard.
/// Reads platform settings at render time to gate whether registration is open (max-tenant cap)
/// and whether trials are enabled (controls which CTA the view shows). Re-populates the form
/// from <c>TempData["PendingRegistrationJson"]</c> if the user is returning after cancelling a
/// Stripe Checkout session so they don't have to retype their details.
/// </summary>
[HttpGet]
public async Task<IActionResult> Index()
{
if (User.Identity?.IsAuthenticated == true)
return RedirectToAction("Index", "Dashboard");
ViewBag.RegistrationOpen = await IsRegistrationOpenAsync();
await PopulateRegistrationViewBagAsync();
var defaultPlan = (ViewBag.PlanConfigs as List<SubscriptionPlanConfig>)
?.OrderBy(c => c.SortOrder).FirstOrDefault()?.Plan ?? 0;
// Re-populate form if returning from a cancelled payment
var prefillJson = TempData["PendingRegistrationJson"] as string;
var model = prefillJson != null
? JsonSerializer.Deserialize<RegisterCompanyDto>(prefillJson) ?? new RegisterCompanyDto { Plan = defaultPlan }
: new RegisterCompanyDto { Plan = defaultPlan };
return View(model);
}
/// <summary>
/// Processes the registration form submission. Validates the model, checks that registration is
/// still open (in case the tenant cap was hit after the page was loaded), and verifies the email
/// is not already in use. Then branches to <see cref="CreateWithTrialAsync"/> (trials enabled) or
/// <see cref="RedirectToStripeCheckoutAsync"/> (trials disabled / credit card required).
/// Duplicate email is checked here rather than relying on a DB unique constraint so we can show
/// a friendly field-level error message instead of a 500.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(RegisterCompanyDto model)
{
if (!ModelState.IsValid)
{
ViewBag.RegistrationOpen = await IsRegistrationOpenAsync();
await PopulateRegistrationViewBagAsync();
return View("Index", model);
}
if (!await IsRegistrationOpenAsync())
{
TempData["Error"] = "Registration is currently closed. Please contact us for more information.";
ViewBag.RegistrationOpen = false;
await PopulateRegistrationViewBagAsync();
return View("Index", model);
}
var existing = await _userManager.FindByEmailAsync(model.Email);
if (existing != null)
{
ModelState.AddModelError("Email", "An account with this email address already exists.");
ViewBag.RegistrationOpen = true;
await PopulateRegistrationViewBagAsync();
return View("Index", model);
}
var trialsEnabled = await IsTrialsEnabledAsync();
if (trialsEnabled)
{
return await CreateWithTrialAsync(model);
}
else
{
return await RedirectToStripeCheckoutAsync(model);
}
}
// ─── Trial path ───────────────────────────────────────────────────────────
/// <summary>
/// Creates the company and user immediately (no payment required). The trial length is read from
/// the <c>TrialPeriodDays</c> platform setting (default 7) so SuperAdmins can adjust it without
/// a deploy. A temporary password is generated and emailed; the user is flagged with
/// <c>MustChangePassword</c> claim so the first login redirects to the change-password flow.
/// Company and user creation are NOT in a DB transaction — if user creation fails, the company
/// row is manually deleted to avoid orphaned tenant records.
/// </summary>
private async Task<IActionResult> CreateWithTrialAsync(RegisterCompanyDto model)
{
var rawTrialDays = await _platformSettings.GetAsync(PlatformSettingKeys.TrialPeriodDays);
var trialPeriodDays = int.TryParse(rawTrialDays, out var td) ? td : 7;
var companyCode = await GenerateUniqueCompanyCodeAsync(model.CompanyName);
var company = new Company
{
CompanyName = model.CompanyName,
CompanyCode = companyCode,
Phone = model.CompanyPhone,
PrimaryContactEmail = model.Email,
PrimaryContactName = $"{model.FirstName} {model.LastName}",
SubscriptionPlan = model.Plan,
SubscriptionStatus = SubscriptionStatus.Active,
SubscriptionStartDate = DateTime.UtcNow,
SubscriptionEndDate = DateTime.UtcNow.AddDays(trialPeriodDays),
IsAnnualBilling = model.IsAnnual,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.Companies.AddAsync(company);
await _unitOfWork.CompleteAsync();
var tempPassword = GenerateTemporaryPassword();
var user = BuildUser(model.Email, model.FirstName, model.LastName, company.Id);
var createResult = await _userManager.CreateAsync(user, tempPassword);
if (!createResult.Succeeded)
{
await _unitOfWork.Companies.DeleteAsync(company);
await _unitOfWork.CompleteAsync();
foreach (var error in createResult.Errors)
ModelState.AddModelError(string.Empty, error.Description);
await PopulateRegistrationViewBagAsync();
return View("Index", model);
}
await _userManager.AddClaimAsync(user, new Claim("MustChangePassword", "true"));
await FinalizeRegistrationAsync(user, company, model.Plan);
_ = SendWelcomeEmailAsync(model.Email, model.FirstName, tempPassword, model.Plan,
DateTime.UtcNow.AddDays(trialPeriodDays), $"{Request.Scheme}://{Request.Host}");
return RedirectToAction(nameof(Welcome));
}
// ─── Credit-card-required path ────────────────────────────────────────────
/// <summary>
/// Starts the paid-path registration by creating a Stripe Checkout session and redirecting the
/// user to Stripe's hosted payment page. A URL-safe GUID token is generated and embedded in both
/// the success and cancel return URLs. The <c>PendingRegistrationSession</c> is persisted to the
/// database AFTER Stripe confirms the session is valid — this prevents orphaned DB records when
/// Stripe rejects the request (e.g. invalid plan price ID). Account creation does not happen
/// here; it happens in <see cref="PaymentSuccess"/> after Stripe redirects back with the
/// Checkout session ID.
/// </summary>
private async Task<IActionResult> RedirectToStripeCheckoutAsync(RegisterCompanyDto model)
{
// Generate a token that travels in the URL — no session cookie needed to survive the Stripe redirect.
var token = Guid.NewGuid().ToString("N");
var successUrl = Url.Action(nameof(PaymentSuccess), "Registration", new { reg_token = token }, Request.Scheme)!;
var cancelUrl = Url.Action(nameof(PaymentCancelled), "Registration", new { reg_token = token }, Request.Scheme)!;
try
{
var checkoutUrl = await _stripeService.CreateRegistrationCheckoutSessionAsync(
model.Plan, model.IsAnnual, model.Email, model.CompanyName, successUrl, cancelUrl);
// Persist pending data AFTER confirming Stripe accepted the session.
_db.PendingRegistrationSessions.Add(new PendingRegistrationSession
{
Token = token,
CompanyName = model.CompanyName,
CompanyPhone = model.CompanyPhone,
FirstName = model.FirstName,
LastName = model.LastName,
Email = model.Email,
Plan = model.Plan,
IsAnnual = model.IsAnnual,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
return Redirect(checkoutUrl);
}
catch (InvalidOperationException ex)
{
_logger.LogError(ex, "Stripe config error during registration checkout for plan {Plan}", model.Plan);
ModelState.AddModelError(string.Empty, ex.Message);
await PopulateRegistrationViewBagAsync();
return View("Index", model);
}
catch (Stripe.StripeException ex)
{
_logger.LogError(ex, "Stripe API error during registration checkout");
ModelState.AddModelError(string.Empty,
"A payment processor error occurred. Please try again or contact support.");
await PopulateRegistrationViewBagAsync();
return View("Index", model);
}
}
/// <summary>
/// Stripe return URL handler called after the customer completes payment in Stripe Checkout.
/// Looks up the <c>PendingRegistrationSession</c> by the opaque <c>reg_token</c> to recover the
/// registration data that could not be stored in a session cookie (which may not survive the
/// Stripe redirect on some browsers/devices). Marks the session as completed before creating the
/// account to prevent duplicate submissions if the user hits reload. Contains a second open-cap
/// check in case the tenant limit was reached between the user starting and completing checkout.
/// Calls <c>FulfillRegistrationCheckoutAsync</c> to link the Stripe subscription (sets the real
/// <c>SubscriptionEndDate</c>); failure is non-fatal — dates can be corrected manually.
/// </summary>
[HttpGet]
public async Task<IActionResult> PaymentSuccess(string? session_id, string? reg_token)
{
if (string.IsNullOrEmpty(session_id))
return RedirectToAction(nameof(Index));
if (string.IsNullOrEmpty(reg_token))
{
TempData["Error"] = "Your registration session has expired. Please fill in your details again.";
return RedirectToAction(nameof(Index));
}
var pendingSession = await _db.PendingRegistrationSessions
.FirstOrDefaultAsync(p => p.Token == reg_token && !p.IsCompleted);
if (pendingSession == null)
{
TempData["Error"] = "Your registration session was not found or has already been completed. Please fill in your details again.";
return RedirectToAction(nameof(Index));
}
// Map DB record to the internal record used below
var pending = new PendingRegistration(
pendingSession.CompanyName, pendingSession.CompanyPhone,
pendingSession.FirstName, pendingSession.LastName,
pendingSession.Email, pendingSession.Plan, pendingSession.IsAnnual);
// Mark completed to prevent duplicate submissions
pendingSession.IsCompleted = true;
await _db.SaveChangesAsync();
// Guard against race condition: re-check capacity after Stripe redirect
if (!await IsRegistrationOpenAsync())
{
TempData["Error"] = "Registration is currently closed. Your payment has been received but no account was created. Please contact support.";
return RedirectToAction(nameof(Index));
}
// Guard against race condition (duplicate submission)
if (await _userManager.FindByEmailAsync(pending.Email) != null)
{
// Account already exists — just sign them in and go to dashboard
var existingUser = await _userManager.FindByEmailAsync(pending.Email);
if (existingUser != null)
{
existingUser.LastLoginDate = DateTime.UtcNow;
await _userManager.UpdateAsync(existingUser);
await _signInManager.SignInAsync(existingUser, isPersistent: false);
}
return RedirectToAction("Index", "Dashboard");
}
var companyCode = await GenerateUniqueCompanyCodeAsync(pending.CompanyName);
var company = new Company
{
CompanyName = pending.CompanyName,
CompanyCode = companyCode,
Phone = pending.CompanyPhone,
PrimaryContactEmail = pending.Email,
PrimaryContactName = $"{pending.FirstName} {pending.LastName}",
SubscriptionPlan = pending.Plan,
SubscriptionStatus = SubscriptionStatus.Active,
SubscriptionStartDate = DateTime.UtcNow,
SubscriptionEndDate = DateTime.UtcNow.AddDays(1), // Stripe fulfillment sets real date below
IsActive = true,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.Companies.AddAsync(company);
await _unitOfWork.CompleteAsync();
var tempPassword = GenerateTemporaryPassword();
var user = BuildUser(pending.Email, pending.FirstName, pending.LastName, company.Id);
var createResult = await _userManager.CreateAsync(user, tempPassword);
if (!createResult.Succeeded)
{
await _unitOfWork.Companies.DeleteAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogError("Failed to create user after payment for {Email}: {Errors}",
pending.Email, string.Join(", ", createResult.Errors.Select(e => e.Description)));
TempData["Error"] = "Your payment was received but we encountered an error creating your account. " +
"Please contact support with reference: " + session_id;
return RedirectToAction(nameof(Index));
}
// Link the Stripe subscription (sets real SubscriptionEndDate)
try
{
await _stripeService.FulfillRegistrationCheckoutAsync(session_id, company.Id, pending.Plan);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fulfill registration checkout {SessionId} for company {CompanyId}",
session_id, company.Id);
// Non-fatal — subscription dates can be synced manually later
}
await _userManager.AddClaimAsync(user, new Claim("MustChangePassword", "true"));
await FinalizeRegistrationAsync(user, company, pending.Plan);
_ = SendWelcomeEmailAsync(pending.Email, pending.FirstName, tempPassword, pending.Plan,
null, $"{Request.Scheme}://{Request.Host}");
return RedirectToAction(nameof(Welcome));
}
/// <summary>
/// Stripe cancel URL handler called when the user clicks "Back" or otherwise abandons the Stripe
/// Checkout page. Recovers the pending registration data from the DB and serialises it into
/// <c>TempData</c> so the registration form is pre-populated when <see cref="Index"/> renders,
/// then removes the <c>PendingRegistrationSession</c> record — a new one is created if the user
/// tries again. No account is created at this point; the user is shown a friendly error message.
/// </summary>
[HttpGet]
public async Task<IActionResult> PaymentCancelled(string? reg_token)
{
if (!string.IsNullOrEmpty(reg_token))
{
var pendingSession = await _db.PendingRegistrationSessions
.FirstOrDefaultAsync(p => p.Token == reg_token && !p.IsCompleted);
if (pendingSession != null)
{
// Pre-populate the form so the user doesn't have to retype everything
TempData["PendingRegistrationJson"] = JsonSerializer.Serialize(new RegisterCompanyDto
{
CompanyName = pendingSession.CompanyName,
CompanyPhone = pendingSession.CompanyPhone,
FirstName = pendingSession.FirstName,
LastName = pendingSession.LastName,
Email = pendingSession.Email,
Plan = pendingSession.Plan,
IsAnnual = pendingSession.IsAnnual
});
_db.PendingRegistrationSessions.Remove(pendingSession);
await _db.SaveChangesAsync();
}
}
TempData["Error"] = "Payment was cancelled — your account has not been created. Please try again whenever you're ready.";
return RedirectToAction(nameof(Index));
}
// ─── Shared post-creation steps ───────────────────────────────────────────
/// <summary>
/// Shared post-creation step executed for both the trial and paid paths. Assigns the
/// Administrator role, seeds company lookup tables, records Terms-of-Service acceptance, sends
/// admin notifications, and finally signs the user in.
/// Sign-in is intentionally the LAST step — once <c>SignInAsync</c> runs the HTTP context user
/// becomes the new company admin, which changes the tenant context for any subsequent
/// <c>_db</c> writes. Notifications are therefore awaited synchronously (not fire-and-forget)
/// before sign-in to prevent two specific bugs: (1) in-app notifications written for SuperAdmins
/// would have their <c>CompanyId=0</c> sentinel overwritten by the tenant context after sign-in;
/// (2) <c>IPlatformSettingsService</c> reads use a scoped <c>DbContext</c> that can be disposed
/// if the caller fire-and-forgets.
/// </summary>
private async Task FinalizeRegistrationAsync(ApplicationUser user, Company company, int plan)
{
await _userManager.AddToRoleAsync(user, "Administrator");
try { await _seedDataService.SeedCompanyLookupsAsync(company.Id); }
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to seed lookups for company {CompanyId}", company.Id);
}
// Record ToS acceptance while still anonymous — CompanyId is set explicitly on the entity
// so UpdateTimestampsAndTenancy won't overwrite it.
_db.TermsAcceptances.Add(new TermsAcceptance
{
UserId = user.Id,
CompanyId = company.Id,
TosVersion = AppConstants.Legal.CurrentTosVersion,
AcceptedAt = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = Request.Headers["User-Agent"].FirstOrDefault()
});
await _db.SaveChangesAsync();
_logger.LogInformation("New company registered: {CompanyName} (ID {CompanyId}) by {Email}",
company.CompanyName, company.Id, user.Email);
// Await both notifications BEFORE SignInAsync for two reasons:
// 1. InAppNotification: CreateForSuperAdminsAsync writes CompanyId=0 (SuperAdmin sentinel).
// After SignIn, UpdateTimestampsAndTenancy would overwrite that 0 with the new company's ID,
// routing the bell notification to the wrong recipient.
// 2. AdminEmail: NotifyNewCompanyRegisteredAsync uses IPlatformSettingsService (→ DbContext).
// Fire-and-forget tasks can outlive the scoped DbContext, causing silent failures where
// GetAdminEmailsAsync() throws ObjectDisposedException and no email is sent at all.
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(
c => c.Plan == plan && c.IsActive, ignoreQueryFilters: true);
var planName = planConfig?.DisplayName ?? plan.ToString();
await _adminNotification.NotifyNewCompanyRegisteredAsync(
company.Id, company.CompanyName, planName,
$"{user.FirstName} {user.LastName}", user.Email!);
await _inApp.CreateForSuperAdminsAsync(
"New Company Registered",
$"{company.CompanyName} signed up on the {planName} plan.",
"NewCompany",
$"/Companies/Details/{company.Id}");
// Sign in last — after this point the HTTP context user becomes the new company admin,
// which would change the tenant context for any subsequent _db writes.
user.LastLoginDate = DateTime.UtcNow;
await _userManager.UpdateAsync(user);
await _signInManager.SignInAsync(user, isPersistent: false);
}
/// <summary>
/// Renders the post-registration welcome page. Requires authentication (the user was just signed
/// in by <see cref="FinalizeRegistrationAsync"/>). Determines whether the account is on a free
/// trial by checking for the absence of a <c>StripeSubscriptionId</c> — trial accounts are
/// created without a subscription ID, which the welcome view uses to display an upgrade prompt
/// or hide the trial countdown pill.
/// </summary>
[HttpGet]
[Authorize]
public async Task<IActionResult> Welcome()
{
ViewData["Title"] = "Welcome to Powder Coating Logix";
var user = await _userManager.GetUserAsync(User);
ViewBag.FirstName = user?.FirstName ?? User.Identity?.Name?.Split('@')[0] ?? "there";
// Determine if this is a trial or a paid subscription so the view can hide the trial pill accordingly
if (user != null && user.CompanyId > 0)
{
var company = await _unitOfWork.Companies.GetByIdAsync(user.CompanyId, ignoreQueryFilters: true);
var isOnTrial = company != null && string.IsNullOrEmpty(company.StripeSubscriptionId);
ViewBag.IsOnTrial = isOnTrial;
ViewBag.TrialEndDate = company?.SubscriptionEndDate;
}
else
{
ViewBag.IsOnTrial = false;
ViewBag.TrialEndDate = null;
}
return View();
}
// ─── Helpers ──────────────────────────────────────────────────────────────
/// <summary>
/// Returns whether the free-trial sign-up path is active. Reads the <c>TrialsEnabled</c>
/// platform setting; a missing or null value is treated as enabled (safe default so a freshly
/// deployed instance with no platform settings can still onboard users).
/// </summary>
private async Task<bool> IsTrialsEnabledAsync()
{
var raw = await _platformSettings.GetAsync(PlatformSettingKeys.TrialsEnabled);
// Null/missing = treat as enabled (safe default)
return raw == null || !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Checks whether the platform has capacity for a new tenant. Reads the <c>MaxTenants</c>
/// platform setting; a blank value or 0 means unlimited. Uses <c>IgnoreQueryFilters</c> on the
/// company count so soft-deleted companies still count against the cap (preventing circumvention
/// by deleting and re-registering). Marked <c>internal</c> to allow unit-testing without
/// mocking the full HTTP pipeline.
/// </summary>
internal async Task<bool> IsRegistrationOpenAsync()
{
var raw = await _platformSettings.GetAsync(PlatformSettingKeys.MaxTenants);
// Blank or 0 = unlimited
if (!int.TryParse(raw, out var max) || max <= 0)
return true;
var count = await _unitOfWork.Companies.CountAsync(ignoreQueryFilters: true);
return count < max;
}
/// <summary>
/// Populates shared ViewBag properties required by the registration view: active subscription
/// plan configs (sorted by <c>SortOrder</c>), the trials-enabled flag, and the trial period
/// length in days. Called by both the GET and failed POST paths so the view always has the data
/// it needs without the caller repeating these lookups.
/// </summary>
private async Task PopulateRegistrationViewBagAsync()
{
var planConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true))
.OrderBy(c => c.SortOrder)
.ToList();
ViewBag.PlanConfigs = planConfigs;
ViewBag.TrialsEnabled = await IsTrialsEnabledAsync();
var rawTrialDays = await _platformSettings.GetAsync(PlatformSettingKeys.TrialPeriodDays);
ViewBag.TrialPeriodDays = int.TryParse(rawTrialDays, out var td) ? td : 7;
}
/// <summary>
/// Constructs the <c>ApplicationUser</c> for a new company administrator. All permissions are
/// granted by default because the first user of a new company must be able to do everything.
/// <c>EmailConfirmed</c> is set to <c>true</c> immediately — we skip the email-verification
/// step for registrations because the welcome email with a temp password serves as implicit
/// confirmation that the address is reachable.
/// </summary>
private static ApplicationUser BuildUser(
string email, string firstName, string lastName, int companyId)
{
var user = new ApplicationUser
{
UserName = email,
Email = email,
FirstName = firstName,
LastName = lastName,
CompanyId = companyId,
CompanyRole = "CompanyAdmin",
HireDate = DateTime.UtcNow.Date,
IsActive = true,
EmailConfirmed = true,
CreatedAt = DateTime.UtcNow
};
user.GrantAllPermissions();
return user;
}
/// <summary>
/// Derives a short, unique company code from the company name (e.g. "Acme Powder Coating" →
/// "APC"). Stop words (the, and, inc, llc, etc.) are stripped first. One-, two-, or three-word
/// names each use a different initialism strategy so the code is always 3 characters. A numeric
/// suffix (2, 3, …) is appended if the derived code is already taken. The collision check loads
/// all existing codes into memory — acceptable for small tenant counts but should be replaced
/// with a DB-level unique check if the platform ever scales to thousands of tenants.
/// </summary>
private async Task<string> GenerateUniqueCompanyCodeAsync(string companyName)
{
var stopWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"the", "and", "of", "a", "an", "for", "inc", "llc", "ltd", "co", "corp"
};
var words = companyName
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(w => !stopWords.Contains(w))
.ToList();
string baseCode;
if (words.Count >= 3)
baseCode = new string(words.Take(3).Select(w => char.ToUpper(w[0])).ToArray());
else if (words.Count == 2)
baseCode = char.ToUpper(words[0][0]).ToString() + char.ToUpper(words[1][0]).ToString() +
char.ToUpper(words[1].Length > 1 ? words[1][1] : words[0][1]);
else if (words.Count == 1)
baseCode = words[0].Length >= 4
? words[0][..4].ToUpper()
: words[0].ToUpper().PadRight(3, 'X');
else
baseCode = "CO";
var code = baseCode;
var allCodes = (await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true))
.Select(c => c.CompanyCode)
.Where(c => c != null)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
int suffix = 2;
while (allCodes.Contains(code))
{
code = baseCode + suffix;
suffix++;
}
return code;
}
// ─── Session model ────────────────────────────────────────────────────────
private sealed record PendingRegistration(
string CompanyName,
string? CompanyPhone,
string FirstName,
string LastName,
string Email,
int Plan,
bool IsAnnual = false);
// ─── Temporary password generation ────────────────────────────────────────
/// <summary>
/// Generates a cryptographically random 12-character temporary password that satisfies ASP.NET
/// Core Identity's default complexity rules (uppercase, lowercase, digit, special character).
/// Ambiguous characters (I, O, 0, 1, l) are excluded from the alphabet to reduce transcription
/// errors in case the user reads the password from the email rather than copying it.
/// A Fisher-Yates shuffle with a second batch of random bytes ensures no character class is
/// predictably in a fixed position (e.g. the guaranteed uppercase is not always at index 0).
/// </summary>
private static string GenerateTemporaryPassword()
{
const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // no I, O
const string lower = "abcdefghjkmnpqrstuvwxyz"; // no i, l, o
const string digits = "23456789"; // no 0, 1
const string special = "!@#$%&*";
const string all = upper + lower + digits + special;
var bytes = new byte[16];
RandomNumberGenerator.Fill(bytes);
var pw = new char[12];
// Guarantee one character of each required class
pw[0] = upper [bytes[0] % upper.Length];
pw[1] = lower [bytes[1] % lower.Length];
pw[2] = digits [bytes[2] % digits.Length];
pw[3] = special[bytes[3] % special.Length];
for (int i = 4; i < 12; i++)
pw[i] = all[bytes[i] % all.Length];
// Fisher-Yates shuffle with fresh random bytes
RandomNumberGenerator.Fill(bytes);
for (int i = 11; i > 0; i--)
{
int j = bytes[i] % (i + 1);
(pw[i], pw[j]) = (pw[j], pw[i]);
}
return new string(pw);
}
// ─── Welcome email ────────────────────────────────────────────────────────
/// <summary>
/// Sends the welcome email with temporary password, plan-specific feature highlights, and an
/// optional trial-expiry blurb. Called fire-and-forget so a transient email failure never blocks
/// the registration response. Logged at Error because a missing welcome email means the user
/// cannot retrieve their temporary password and must contact support.
/// </summary>
private async Task SendWelcomeEmailAsync(
string email, string firstName, string tempPassword,
int plan, DateTime? trialEndDate, string baseUrl)
{
try
{
var planName = plan switch
{
3 => "Starter",
0 => "Basic",
1 => "Pro",
2 => "Enterprise",
_ => "Starter"
};
// Plan-specific feature bullets (plain + html pairs)
var (plainFeatures, htmlFeatures) = plan switch
{
// Starter
3 => (
"• Job & quote management for up to 1 active user\r\n" +
"• Customer records and job history\r\n" +
"• Inventory tracking and low-stock alerts\r\n" +
"• PDF quotes and invoices\r\n" +
"• Basic financial reports",
"<li>Job &amp; quote management for up to 1 active user</li>" +
"<li>Customer records and job history</li>" +
"<li>Inventory tracking and low-stock alerts</li>" +
"<li>PDF quotes and invoices</li>" +
"<li>Basic financial reports</li>"
),
// Basic
0 => (
"• Job & quote management for up to 3 users\r\n" +
"• Customer records and job history\r\n" +
"• Inventory tracking, purchase orders, and vendor management\r\n" +
"• PDF quotes, invoices, and deposits\r\n" +
"• Oven scheduler for batch planning\r\n" +
"• Equipment and maintenance tracking\r\n" +
"• Financial reports and AR aging",
"<li>Job &amp; quote management for up to 3 users</li>" +
"<li>Customer records and job history</li>" +
"<li>Inventory tracking, purchase orders, and vendor management</li>" +
"<li>PDF quotes, invoices, and deposits</li>" +
"<li>Oven scheduler for batch planning</li>" +
"<li>Equipment and maintenance tracking</li>" +
"<li>Financial reports and AR aging</li>"
),
// Pro
1 => (
"• Everything in Basic, plus up to 10 users\r\n" +
"• AI Photo Quoting — upload a photo, get an instant estimate\r\n" +
"• AI Accounting Assistant — receipt scanning, cash flow forecasting, anomaly detection\r\n" +
"• Online customer approval portal with Stripe payments\r\n" +
"• Advanced analytics and custom reports\r\n" +
"• Priority support",
"<li>Everything in Basic, plus up to 10 users</li>" +
"<li><strong>AI Photo Quoting</strong> — upload a photo, get an instant estimate</li>" +
"<li><strong>AI Accounting Assistant</strong> — receipt scanning, cash flow forecasting, anomaly detection</li>" +
"<li>Online customer approval portal with Stripe payments</li>" +
"<li>Advanced analytics and custom reports</li>" +
"<li>Priority support</li>"
),
// Enterprise
_ => (
"• Everything in Pro, with unlimited users and jobs\r\n" +
"• AI Photo Quoting and AI Accounting Assistant\r\n" +
"• Online customer approval portal with Stripe payments\r\n" +
"• Full analytics suite and all report exports\r\n" +
"• Dedicated onboarding and priority support\r\n" +
"• Custom integrations available on request",
"<li>Everything in Pro, with unlimited users and jobs</li>" +
"<li><strong>AI Photo Quoting</strong> and <strong>AI Accounting Assistant</strong></li>" +
"<li>Online customer approval portal with Stripe payments</li>" +
"<li>Full analytics suite and all report exports</li>" +
"<li>Dedicated onboarding and priority support</li>" +
"<li>Custom integrations available on request</li>"
)
};
var trialPlainBlurb = trialEndDate.HasValue
? $"Your {planName} trial is active through {trialEndDate.Value:MMMM d, yyyy}. No credit card is required during the trial — we'll remind you before it ends.\r\n\r\n"
: string.Empty;
var trialHtmlBlurb = trialEndDate.HasValue
? $"<p style='background:#fff8e1;border-left:4px solid #f59e0b;padding:10px 14px;margin:20px 0;'>" +
$"Your <strong>{planName} trial</strong> is active through <strong>{trialEndDate.Value:MMMM d, yyyy}</strong>. " +
$"No credit card is required during the trial — we'll remind you before it ends.</p>"
: string.Empty;
var subject = $"Welcome to Powder Coating Logix — Your {planName} Account Is Ready";
var plain =
$"Hi {firstName},\r\n\r\n" +
$"Your Powder Coating Logix {planName} account is ready. Here's your temporary password to log in:\r\n\r\n" +
$" Temporary password: {tempPassword}\r\n\r\n" +
$"You'll be prompted to set a new password the first time you log in.\r\n\r\n" +
trialPlainBlurb +
$"Your {planName} plan includes:\r\n" +
plainFeatures + "\r\n\r\n" +
$"Get started quickly:\r\n" +
$" 1. Log in at {baseUrl}\r\n" +
$" 2. Complete the Setup Wizard — it takes about 5 minutes and configures your rates, inventory, and shop profile so quotes price correctly from day one.\r\n\r\n" +
$"Need help? Our AI assistant is built right into the app — just click the chat icon. You can also reach us at support@powdercoatinglogix.com.\r\n\r\n" +
$"Welcome aboard,\r\n" +
$"The Powder Coating Logix Team";
var html =
$@"<!DOCTYPE html>
<html>
<head><meta charset='utf-8'></head>
<body style='margin:0;padding:0;background:#f4f4f4;font-family:Arial,sans-serif;'>
<table width='100%' cellpadding='0' cellspacing='0' style='background:#f4f4f4;padding:30px 0;'>
<tr><td align='center'>
<table width='600' cellpadding='0' cellspacing='0' style='background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);'>
<!-- Header -->
<tr>
<td style='background:#1a3a5c;padding:28px 40px;'>
<p style='margin:0;color:#ffffff;font-size:22px;font-weight:bold;'>Powder Coating Logix</p>
<p style='margin:6px 0 0;color:#a8c4e0;font-size:13px;'>Shop management software built for powder coaters</p>
</td>
</tr>
<!-- Body -->
<tr><td style='padding:36px 40px;color:#333333;font-size:15px;line-height:1.6;'>
<p style='margin:0 0 16px;'>Hi {firstName},</p>
<p style='margin:0 0 24px;'>Your <strong>Powder Coating Logix {planName}</strong> account is ready. Here's your temporary password to get started:</p>
<div style='background:#f0f4f8;border:1px solid #d0dce8;border-radius:6px;padding:16px 24px;margin:0 0 24px;text-align:center;'>
<p style='margin:0 0 4px;font-size:12px;color:#666;text-transform:uppercase;letter-spacing:0.05em;'>Temporary Password</p>
<p style='margin:0;font-family:monospace;font-size:22px;font-weight:bold;letter-spacing:0.1em;color:#1a3a5c;'>{tempPassword}</p>
</div>
<p style='margin:0 0 24px;color:#555;font-size:14px;'>You'll be prompted to set a new password the first time you log in.</p>
{trialHtmlBlurb}
<p style='margin:0 0 10px;font-weight:bold;'>Your {planName} plan includes:</p>
<ul style='margin:0 0 24px;padding-left:20px;color:#444;'>
{htmlFeatures}
</ul>
<p style='margin:0 0 10px;font-weight:bold;'>Get started quickly:</p>
<ol style='margin:0 0 24px;padding-left:20px;color:#444;'>
<li style='margin-bottom:8px;'><a href='{baseUrl}' style='color:#1a3a5c;'>Log in to your account</a></li>
<li>Complete the <strong>Setup Wizard</strong> it takes about 5 minutes and configures your rates, inventory, and shop profile so quotes price correctly from day one.</li>
</ol>
<p style='margin:0 0 24px;'>Need help? Our <strong>AI assistant</strong> is built right into the app just click the chat icon. You can also reach us any time at <a href='mailto:support@powdercoatinglogix.com' style='color:#1a3a5c;'>support@powdercoatinglogix.com</a>.</p>
<p style='margin:0;'>Welcome aboard,<br><strong>The Powder Coating Logix Team</strong></p>
</td></tr>
<!-- Footer -->
<tr>
<td style='background:#f8f9fa;border-top:1px solid #e9ecef;padding:20px 40px;text-align:center;'>
<p style='margin:0;font-size:12px;color:#888;'>
&copy; {DateTime.UtcNow.Year} Powder Coating Logix &nbsp;&bull;&nbsp;
<a href='mailto:support@powdercoatinglogix.com' style='color:#888;'>support@powdercoatinglogix.com</a>
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>";
await _emailService.SendEmailAsync(email, firstName, subject, plain, html);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send welcome email to {Email}", email);
}
}
}
@@ -0,0 +1,212 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Manages the platform changelog / release notes feed.
/// <para>
/// Has two access tiers: the public changelog (<see cref="Index"/>) is available
/// to any authenticated tenant user so they can review what has changed; the
/// management actions (Create, Edit, TogglePublish, Delete) are restricted to
/// SuperAdmins because only platform staff should author release content.
/// </para>
/// </summary>
public class ReleaseNotesController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IInAppNotificationService _inApp;
public ReleaseNotesController(ApplicationDbContext db, IInAppNotificationService inApp)
{
_db = db;
_inApp = inApp;
}
// ── Public: Changelog ────────────────────────────────────────────────────
// Visible to all authenticated users
/// <summary>
/// Renders the public changelog — shows only published release notes ordered
/// newest-first. Drafts are invisible to ordinary users so SuperAdmins can
/// prepare notes in advance without surfacing them prematurely.
/// </summary>
[Authorize]
public async Task<IActionResult> Index()
{
var notes = await _db.ReleaseNotes
.AsNoTracking()
.Where(r => r.IsPublished)
.OrderByDescending(r => r.ReleasedAt)
.ThenByDescending(r => r.Id)
.ToListAsync();
return View(notes);
}
// ── SuperAdmin: Manage ───────────────────────────────────────────────────
/// <summary>
/// Returns the SuperAdmin management list of all release notes (published and
/// draft alike), ordered newest-first. Unlike <see cref="Index"/> there is no
/// <c>IsPublished</c> filter here so admins can see and edit drafts.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> Manage()
{
var notes = await _db.ReleaseNotes
.AsNoTracking()
.OrderByDescending(r => r.ReleasedAt)
.ThenByDescending(r => r.Id)
.ToListAsync();
return View(notes);
}
/// <summary>
/// Returns the Create form pre-populated with today's UTC date and the "Feature"
/// tag as sensible defaults for new entries, reducing data-entry friction.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public IActionResult Create()
{
return View(new ReleaseNote
{
ReleasedAt = DateTime.UtcNow,
Tag = "Feature"
});
}
/// <summary>
/// Persists a new release note and captures the creating SuperAdmin's identity
/// (<c>CreatedByUserId</c> / <c>CreatedByUserName</c>) for audit purposes.
/// New notes start unpublished by default unless the form explicitly sets
/// <c>IsPublished = true</c>, giving authors a chance to review before going live.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> Create(ReleaseNote model)
{
if (!ModelState.IsValid)
return View(model);
model.CreatedAt = DateTime.UtcNow;
model.CreatedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
model.CreatedByUserName = User.Identity?.Name;
_db.ReleaseNotes.Add(model);
await _db.SaveChangesAsync();
if (model.IsPublished)
await NotifyAllTenantsAsync(model);
TempData["Success"] = $"Release note v{model.Version} created.";
return RedirectToAction(nameof(Manage));
}
/// <summary>
/// Returns the Edit form loaded from the database by primary key.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> Edit(int id)
{
var note = await _db.ReleaseNotes.FindAsync(id);
if (note == null) return NotFound();
return View(note);
}
/// <summary>
/// Applies the edited values to the tracked entity. Uses explicit field
/// mapping (rather than <c>_db.Entry(model).State = Modified</c>) to prevent
/// over-posting attacks and to ensure audit fields like <c>CreatedAt</c> and
/// <c>CreatedByUserId</c> are never overwritten.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> Edit(int id, ReleaseNote model)
{
if (id != model.Id) return BadRequest();
if (!ModelState.IsValid)
return View(model);
var note = await _db.ReleaseNotes.FindAsync(id);
if (note == null) return NotFound();
note.Version = model.Version;
note.Title = model.Title;
note.Body = model.Body;
note.Tag = model.Tag;
note.IsPublished= model.IsPublished;
note.ReleasedAt = model.ReleasedAt;
note.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
TempData["Success"] = $"Release note v{note.Version} updated.";
return RedirectToAction(nameof(Manage));
}
/// <summary>
/// Toggles the published state of a release note. Publishing makes the note
/// immediately visible to all authenticated users via <see cref="Index"/>;
/// un-publishing hides it without permanently deleting it so it can be revised
/// and re-published later.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> TogglePublish(int id)
{
var note = await _db.ReleaseNotes.FindAsync(id);
if (note == null) return NotFound();
var wasPublished = note.IsPublished;
note.IsPublished = !note.IsPublished;
note.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
if (note.IsPublished && !wasPublished)
await NotifyAllTenantsAsync(note);
TempData["Success"] = note.IsPublished
? $"v{note.Version} published — now visible to all users."
: $"v{note.Version} unpublished — hidden from users.";
return RedirectToAction(nameof(Manage));
}
/// <summary>
/// Permanently (hard) deletes a release note. This is intentional — release notes
/// are platform metadata, not business data, so they do not use soft delete.
/// Use <see cref="TogglePublish"/> to hide a note without permanent removal.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> Delete(int id)
{
var note = await _db.ReleaseNotes.FindAsync(id);
if (note == null) return NotFound();
_db.ReleaseNotes.Remove(note);
await _db.SaveChangesAsync();
TempData["Success"] = $"Release note v{note.Version} deleted.";
return RedirectToAction(nameof(Manage));
}
/// <summary>
/// Fans out a "What's New" in-app notification to every active tenant company when
/// a release note transitions to published. Notification fires exactly once per
/// publish event — re-publishing after unpublishing will send a second notification,
/// which is intentional (the content may have changed).
/// </summary>
private Task NotifyAllTenantsAsync(ReleaseNote note)
{
var title = $"What's New — {note.Title}";
var message = note.Tag != null ? $"[{note.Tag}] See the latest updates in What's New." : "See the latest updates in What's New.";
return _inApp.CreateForAllCompaniesAsync(title, message, "ReleaseNote", "/ReleaseNotes");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,255 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only revenue intelligence dashboard that aggregates subscription
/// metrics across all tenant companies. Uses <see cref="ApplicationDbContext"/>
/// directly (bypassing the UoW) because it queries across company boundaries and
/// joins plan configs — a pattern that would require multiple unrelated repositories.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class RevenueController : Controller
{
private readonly ApplicationDbContext _db;
public RevenueController(ApplicationDbContext db)
{
_db = db;
}
/// <summary>
/// Renders the platform revenue dashboard with MRR, ARR, plan distribution,
/// a 12-month MRR trend, and a list of companies needing subscription attention.
/// <para>
/// Design decisions:
/// <list type="bullet">
/// <item>Demo company (Id = 1) and comped companies are excluded from all
/// revenue figures because they do not represent real subscription income.</item>
/// <item>The 12-month MRR trend is an approximation — it estimates active
/// subscribers per historical month using <c>CreatedAt</c> and current status,
/// not a ledger of actual billing events, because no billing-transactions table
/// exists yet.</item>
/// <item><c>IgnoreQueryFilters()</c> is required throughout so that the global
/// multi-tenancy filter does not restrict visibility to a single company.</item>
/// </list>
/// </para>
/// </summary>
public async Task<IActionResult> Index()
{
var now = DateTime.UtcNow;
var thisMonthStart = new DateTime(now.Year, now.Month, 1);
var lastMonthStart = thisMonthStart.AddMonths(-1);
// Plan configs (price lookup)
var planConfigs = await _db.SubscriptionPlanConfigs
.AsNoTracking().IgnoreQueryFilters()
.Where(p => p.IsActive)
.OrderBy(p => p.SortOrder)
.ToListAsync();
var priceByPlan = planConfigs.ToDictionary(p => p.Plan, p => p.MonthlyPrice);
string PlanName(int plan) => planConfigs.FirstOrDefault(p => p.Plan == plan)?.DisplayName ?? plan.ToString();
// Exclude demo company (Id 1) and comped companies from revenue calculations;
// they don't represent real subscription income.
const int DemoCompanyId = 1;
var companies = await _db.Companies
.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted && c.Id != DemoCompanyId && !c.IsComped)
.ToListAsync();
// Comped companies are excluded from revenue but must be counted separately
// so the status table in the view sums to a correct total.
var compedCount = await _db.Companies
.AsNoTracking().IgnoreQueryFilters()
.CountAsync(c => !c.IsDeleted && c.Id != DemoCompanyId && c.IsComped);
// ── Revenue metrics ──────────────────────────────────────────────
// Active paying companies
var payingActive = companies
.Where(c =>
(c.SubscriptionStatus == SubscriptionStatus.Active ||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod))
.ToList();
var mrr = payingActive.Sum(c =>
priceByPlan.TryGetValue(c.SubscriptionPlan, out var price) ? price : 0m);
var arr = mrr * 12;
// Average revenue per paying company
var avgRevenue = payingActive.Count > 0 ? mrr / payingActive.Count : 0m;
// ── Company counts ───────────────────────────────────────────────
// totalCompanies includes comped so that Active + GracePeriod + Expired + Canceled + Comped = Total.
var totalCompanies = companies.Count + compedCount;
var activeCount = companies.Count(c => c.SubscriptionStatus == SubscriptionStatus.Active);
var gracePeriodCount = companies.Count(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod);
var expiredCount = companies.Count(c => c.SubscriptionStatus == SubscriptionStatus.Expired);
var canceledCount = companies.Count(c =>
c.SubscriptionStatus == SubscriptionStatus.Canceled ||
c.SubscriptionStatus == SubscriptionStatus.Inactive);
var stripeCount = companies.Count(c => !string.IsNullOrEmpty(c.StripeSubscriptionId));
// ── Month-over-month ─────────────────────────────────────────────
var newThisMonth = companies.Count(c => c.CreatedAt >= thisMonthStart);
var newLastMonth = companies.Count(c => c.CreatedAt >= lastMonthStart && c.CreatedAt < thisMonthStart);
var churnThisMonth = companies.Count(c =>
c.UpdatedAt >= thisMonthStart &&
(c.SubscriptionStatus == SubscriptionStatus.Canceled ||
c.SubscriptionStatus == SubscriptionStatus.Expired));
// ── Plan distribution ────────────────────────────────────────────
var planDistribution = companies
.Where(c => !c.IsComped &&
(c.SubscriptionStatus == SubscriptionStatus.Active ||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod))
.GroupBy(c => c.SubscriptionPlan)
.Select(g => new PlanMetricRow
{
PlanId = g.Key,
PlanName = PlanName(g.Key),
CompanyCount = g.Count(),
MonthlyPrice = priceByPlan.TryGetValue(g.Key, out var p) ? p : 0m,
Revenue = g.Sum(c => priceByPlan.TryGetValue(c.SubscriptionPlan, out var pr) ? pr : 0m)
})
.OrderByDescending(r => r.Revenue)
.ToList();
// ── Approximated 12-month trend ──────────────────────────────────
// We estimate MRR per month by looking at company creation dates.
// (No billing transactions table yet — this is an approximation.)
var trendMonths = new List<MrrTrendPoint>();
for (int i = 11; i >= 0; i--)
{
var monthStart = new DateTime(now.Year, now.Month, 1).AddMonths(-i);
var monthEnd = monthStart.AddMonths(1);
var activeInMonth = companies
.Where(c =>
!c.IsComped &&
c.CreatedAt < monthEnd &&
(c.SubscriptionStatus == SubscriptionStatus.Active ||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod ||
c.CreatedAt >= monthStart)) // simplification
.ToList();
var mrrPoint = activeInMonth.Sum(c =>
priceByPlan.TryGetValue(c.SubscriptionPlan, out var pr) ? pr : 0m);
trendMonths.Add(new MrrTrendPoint
{
Month = monthStart.ToString("MMM yy"),
Mrr = mrrPoint,
CompanyCount = activeInMonth.Count
});
}
// ── Companies needing attention ──────────────────────────────────
var alerts = companies
.Where(c =>
c.SubscriptionStatus == SubscriptionStatus.GracePeriod ||
c.SubscriptionStatus == SubscriptionStatus.Expired ||
(c.SubscriptionStatus == SubscriptionStatus.Active &&
c.SubscriptionEndDate.HasValue &&
c.SubscriptionEndDate.Value <= now.AddDays(14) &&
!c.IsComped))
.OrderBy(c => c.SubscriptionEndDate)
.Take(20)
.Select(c => new SubscriptionAlertRow
{
Id = c.Id,
CompanyName = c.CompanyName,
PlanName = PlanName(c.SubscriptionPlan),
Status = c.SubscriptionStatus,
EndDate = c.SubscriptionEndDate,
DaysUntilExpiry = c.SubscriptionEndDate.HasValue
? (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays
: 0,
IsComped = c.IsComped
})
.ToList();
var vm = new RevenueDashboardViewModel
{
Mrr = mrr,
Arr = arr,
AvgRevenuePerCompany = avgRevenue,
TotalCompanies = totalCompanies,
ActiveCount = activeCount,
GracePeriodCount = gracePeriodCount,
ExpiredCount = expiredCount,
CanceledCount = canceledCount,
CompedCount = compedCount,
StripeManaged = stripeCount,
NewThisMonth = newThisMonth,
NewLastMonth = newLastMonth,
ChurnThisMonth = churnThisMonth,
PlanDistribution = planDistribution,
TrendMonths = trendMonths,
SubscriptionAlerts = alerts
};
return View(vm);
}
}
// ── Local view models (scoped to this controller file) ───────────────────────
public class RevenueDashboardViewModel
{
public decimal Mrr { get; set; }
public decimal Arr { get; set; }
public decimal AvgRevenuePerCompany { get; set; }
public int TotalCompanies { get; set; }
public int ActiveCount { get; set; }
public int GracePeriodCount { get; set; }
public int ExpiredCount { get; set; }
public int CanceledCount { get; set; }
public int CompedCount { get; set; }
public int StripeManaged { get; set; }
public int NewThisMonth { get; set; }
public int NewLastMonth { get; set; }
public int ChurnThisMonth { get; set; }
public List<PlanMetricRow> PlanDistribution { get; set; } = new();
public List<MrrTrendPoint> TrendMonths { get; set; } = new();
public List<SubscriptionAlertRow> SubscriptionAlerts { get; set; } = new();
}
public class PlanMetricRow
{
public int PlanId { get; set; }
public string PlanName { get; set; } = string.Empty;
public int CompanyCount { get; set; }
public decimal MonthlyPrice { get; set; }
public decimal Revenue { get; set; }
}
public class MrrTrendPoint
{
public string Month { get; set; } = string.Empty;
public decimal Mrr { get; set; }
public int CompanyCount { get; set; }
}
public class SubscriptionAlertRow
{
public int Id { get; set; }
public string CompanyName { get; set; } = string.Empty;
public string PlanName { get; set; } = string.Empty;
public SubscriptionStatus Status { get; set; }
public DateTime? EndDate { get; set; }
public int DaysUntilExpiry { get; set; }
public bool IsComped { get; set; }
}
@@ -0,0 +1,139 @@
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class SeedDataController : Controller
{
private readonly ISeedDataService _seedDataService;
private readonly ILogger<SeedDataController> _logger;
public SeedDataController(
ISeedDataService seedDataService,
ILogger<SeedDataController> logger)
{
_seedDataService = seedDataService;
_logger = logger;
}
/// <summary>
/// Displays the Seed Data management page with a list of all companies. Seeding is intentionally NOT automatic on startup — it must be triggered manually here by a SuperAdmin to avoid polluting production databases.
/// </summary>
public async Task<IActionResult> Index()
{
var companies = await _seedDataService.GetCompaniesAsync();
return View(companies);
}
/// <summary>
/// Triggers system-level seeding (global data such as SuperAdmin accounts and dashboard tips). Warnings are stored in TempData as a pipe-delimited string to survive the redirect; they display on the Index view after the POST-Redirect-GET cycle.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SeedSystem()
{
try
{
var result = await _seedDataService.SeedSystemDataAsync();
if (result.Success)
{
TempData["SuccessMessage"] = result.Message;
TempData["SeedDetails"] = string.Join("|", result.Details);
TempData["ItemsSeeded"] = result.ItemsSeeded;
if (result.Warnings.Any())
{
TempData["WarningMessage"] = $"{result.ItemsSkipped} item(s) were skipped";
TempData["SeedWarnings"] = string.Join("|", result.Warnings);
}
}
else
{
TempData["ErrorMessage"] = result.Message;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error seeding system data");
TempData["ErrorMessage"] = $"An error occurred: {ex.Message}";
}
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Seeds demo data (customers, jobs, quotes, inventory, etc.) for a specific company. Warnings are capped at 30 entries in TempData to stay within the 4 KB browser cookie limit; any overflow is noted with a count and a pointer to the server logs.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SeedCompany(int companyId)
{
try
{
var result = await _seedDataService.SeedCompanyDataAsync(companyId);
if (result.Success)
{
TempData["SuccessMessage"] = result.Message;
TempData["SeedDetails"] = string.Join("|", result.Details);
TempData["ItemsSeeded"] = result.ItemsSeeded;
if (result.Warnings.Any())
{
TempData["WarningMessage"] = $"{result.ItemsSkipped} item(s) were skipped";
// Cap at 30 warnings to keep the TempData cookie under the 4 KB browser limit
var displayWarnings = result.Warnings.Take(30).ToList();
if (result.Warnings.Count > 30)
displayWarnings.Add($"… and {result.Warnings.Count - 30} more (see logs)");
TempData["SeedWarnings"] = string.Join("|", displayWarnings);
}
}
else
{
TempData["ErrorMessage"] = result.Message;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error seeding company data");
TempData["ErrorMessage"] = $"An error occurred: {ex.Message}";
}
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Removes previously seeded demo data from a company according to the supplied options (e.g., jobs only, or all data). Used during QA/demo resets to return a company to a clean state without a full database drop.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveSeedData(int companyId, RemoveSeedDataOptions options)
{
try
{
var result = await _seedDataService.RemoveSeedDataAsync(companyId, options);
if (result.Success)
{
TempData["SuccessMessage"] = result.Message;
TempData["SeedDetails"] = string.Join("|", result.Details);
TempData["ItemsSeeded"] = result.ItemsSeeded;
}
else
{
TempData["ErrorMessage"] = result.Message;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error removing seed data for company {CompanyId}", companyId);
TempData["ErrorMessage"] = $"An error occurred: {ex.Message}";
}
return RedirectToAction(nameof(Index));
}
}
@@ -0,0 +1,989 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Wizard;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Text.Json;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class SetupWizardController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _context;
private readonly ISeedDataService _seedDataService;
private readonly ILogger<SetupWizardController> _logger;
public SetupWizardController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
ApplicationDbContext context,
ISeedDataService seedDataService,
ILogger<SetupWizardController> logger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_userManager = userManager;
_context = context;
_seedDataService = seedDataService;
_logger = logger;
}
// ─── Helpers ─────────────────────────────────────────────────────────────
/// <summary>
/// Returns the current tenant's company ID, throwing if the tenant context is unavailable.
/// All wizard actions are scoped to a single company, so failing fast here prevents any wizard
/// step from silently operating on the wrong (or no) tenant.
/// </summary>
private int GetCompanyId()
{
var id = _tenantContext.GetCurrentCompanyId();
if (id == null) throw new InvalidOperationException("No company context.");
return id.Value;
}
/// <summary>
/// Loads the current company together with its <see cref="CompanyPreferences"/> and
/// <see cref="CompanyOperatingCosts"/> child records, creating them if they do not yet exist.
/// The auto-creation logic exists because new companies seeded by the registration flow may not
/// have had these child rows created yet; rather than crash or show blank forms, the wizard
/// bootstraps them on first access so every step always has a writable record to update.
/// </summary>
private async Task<(Company company, CompanyPreferences prefs, CompanyOperatingCosts costs)> LoadCompanyDataAsync()
{
var companyId = GetCompanyId();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, false, c => c.Preferences, c => c.OperatingCosts)
?? throw new InvalidOperationException("Company not found.");
if (company.Preferences == null)
{
company.Preferences = new CompanyPreferences { CompanyId = companyId };
_context.Set<CompanyPreferences>().Add(company.Preferences);
await _unitOfWork.CompleteAsync();
}
if (company.OperatingCosts == null)
{
company.OperatingCosts = new CompanyOperatingCosts { CompanyId = companyId };
_context.Set<CompanyOperatingCosts>().Add(company.OperatingCosts);
await _unitOfWork.CompleteAsync();
}
return (company, company.Preferences, company.OperatingCosts);
}
/// <summary>
/// Builds a <see cref="WizardProgressDto"/> from the comma-separated step lists stored on
/// <see cref="CompanyPreferences"/>, which is then passed to <c>ViewBag.Progress</c> so the
/// Razor layout can render the step indicator. Done and skipped steps are stored as CSV strings
/// (not a bitmask or JSON array) to remain human-readable and easily editable in the database
/// if a support engineer ever needs to reset a specific step.
/// </summary>
private WizardProgressDto BuildProgress(CompanyPreferences prefs)
{
var progress = new WizardProgressDto
{
Started = prefs.SetupWizardStarted,
Completed = prefs.SetupWizardCompleted,
};
if (!string.IsNullOrWhiteSpace(prefs.SetupWizardDoneSteps))
progress.DoneSteps = prefs.SetupWizardDoneSteps.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var n) ? n : 0).Where(n => n > 0).ToList();
if (!string.IsNullOrWhiteSpace(prefs.SetupWizardSkippedSteps))
progress.SkippedSteps = prefs.SetupWizardSkippedSteps.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var n) ? n : 0).Where(n => n > 0).ToList();
return progress;
}
/// <summary>
/// Marks a wizard step as completed and removes it from the skipped list if it was previously
/// skipped. The dual-list update ensures a step cannot be simultaneously "done" and "skipped",
/// which would produce inconsistent progress-indicator rendering. Steps are deduplicated and
/// sorted before saving so re-submitting a step is safe and idempotent.
/// </summary>
private void MarkDone(CompanyPreferences prefs, int step)
{
var done = ParseSteps(prefs.SetupWizardDoneSteps);
done.Add(step);
prefs.SetupWizardDoneSteps = string.Join(",", done.Distinct().OrderBy(n => n));
var skipped = ParseSteps(prefs.SetupWizardSkippedSteps);
skipped.Remove(step);
prefs.SetupWizardSkippedSteps = string.Join(",", skipped.Distinct().OrderBy(n => n));
}
/// <summary>
/// Marks a wizard step as skipped unless it has already been completed, in which case the skip
/// is silently ignored. This guard prevents a "Back + Skip" navigation pattern from downgrading
/// a completed step to skipped, which would incorrectly strip the checkmark from the progress bar.
/// </summary>
private void MarkSkipped(CompanyPreferences prefs, int step)
{
var done = ParseSteps(prefs.SetupWizardDoneSteps);
if (done.Contains(step)) return;
var skipped = ParseSteps(prefs.SetupWizardSkippedSteps);
skipped.Add(step);
prefs.SetupWizardSkippedSteps = string.Join(",", skipped.Distinct().OrderBy(n => n));
}
private static List<int> ParseSteps(string? csv) =>
string.IsNullOrWhiteSpace(csv)
? new List<int>()
: csv.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var n) ? n : 0)
.Where(n => n > 0).ToList();
/// <summary>
/// Redirects the user to the next wizard step, or to the <see cref="Complete"/> page when all
/// steps have been processed. Centralizing this logic prevents individual POST handlers from
/// each hard-coding a step-number boundary check, making it easy to add or remove steps later.
/// </summary>
private IActionResult RedirectToStep(int step)
{
if (step > WizardProgressDto.TotalSteps) return RedirectToAction(nameof(Complete));
return RedirectToAction("Step", new { step });
}
// ─── Launch ───────────────────────────────────────────────────────────────
/// <summary>
/// Starts the setup wizard for the current company, seeding lookup tables, default vendors,
/// and a default catalog category if this is the first time the wizard is launched.
/// Seeding is intentionally non-fatal — if it fails (e.g., transient DB error), the wizard
/// still opens so the admin is not blocked from configuring their company. Any existing
/// step-progress is cleared on first launch to avoid stale data from a previous wizard version
/// confusing the progress indicator. Seed operations are idempotent, so re-launching an
/// already-started wizard skips seeding entirely.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Launch()
{
var companyId = GetCompanyId();
var (_, prefs, _) = await LoadCompanyDataAsync();
if (!prefs.SetupWizardStarted)
{
try
{
// Seed all lookup tables + chart of accounts (idempotent)
await _seedDataService.SeedCompanyLookupsAsync(companyId);
// Seed default vendors
await SeedDefaultVendorsAsync(companyId);
// Seed default catalog category
await SeedDefaultCatalogCategoryAsync(companyId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error seeding defaults during wizard launch for company {CompanyId}", companyId);
// Non-fatal — wizard can still proceed
}
prefs.SetupWizardStarted = true;
// Clear any stale step progress from a previous wizard version
prefs.SetupWizardDoneSteps = null;
prefs.SetupWizardSkippedSteps = null;
await _unitOfWork.CompleteAsync();
}
return RedirectToAction("Step", new { step = 1 });
}
/// <summary>
/// Seeds well-known powder coat suppliers as vendor records for the new company if they do not
/// already exist. These vendors (Prismatic Powders, Columbia Coatings) are pre-populated as a
/// convenience for common supplier setups; the admin can delete or rename them later.
/// The existence check by <c>CompanyName</c> makes the operation idempotent, so re-launching
/// the wizard never creates duplicate vendor rows.
/// </summary>
private async Task SeedDefaultVendorsAsync(int companyId)
{
var defaults = new[]
{
new { CompanyName = "Prismatic Powders", Website = "https://www.prismaticpowders.com" },
new { CompanyName = "Columbia Coatings", Website = "https://www.columbiacoatings.com" },
};
foreach (var v in defaults)
{
var exists = (await _unitOfWork.Vendors.FindAsync(
x => x.CompanyId == companyId && x.CompanyName == v.CompanyName)).Any();
if (exists) continue;
await _unitOfWork.Vendors.AddAsync(new Vendor
{
CompanyId = companyId,
CompanyName = v.CompanyName,
Website = v.Website,
IsActive = true,
Country = "USA"
});
}
await _unitOfWork.CompleteAsync();
}
/// <summary>
/// Creates a "General Services" catalog category for the company if one does not yet exist.
/// Catalog items require a category, so at least one must exist before an admin can add priced
/// services. This default category is intentionally generic so it works for any powder coating
/// shop configuration without requiring the admin to create one before they can use the catalog.
/// </summary>
private async Task SeedDefaultCatalogCategoryAsync(int companyId)
{
var exists = (await _unitOfWork.CatalogCategories.FindAsync(
c => c.CompanyId == companyId && c.Name == "General Services")).Any();
if (exists) return;
await _unitOfWork.CatalogCategories.AddAsync(new CatalogCategory
{
CompanyId = companyId,
Name = "General Services",
Description = "Default category created during setup"
});
await _unitOfWork.CompleteAsync();
}
// ─── GET Step ─────────────────────────────────────────────────────────────
/// <summary>
/// Serves the view for the requested wizard step, pre-populated with the company's existing data.
/// A single action handles all 10 steps via a switch expression so the URL pattern stays uniform
/// (<c>/SetupWizard/Step?step=N</c>) and shared progress-bar logic runs in one place.
/// Out-of-range step numbers redirect to step 1 rather than returning a 404 so direct URL
/// manipulation by the user degrades gracefully. Each branch delegates to a dedicated
/// builder (e.g., <see cref="BuildStep4ViewAsync"/>, <see cref="BuildStep8ViewAsync"/>) for
/// steps that require additional DB queries beyond the base company data.
/// </summary>
[HttpGet]
public async Task<IActionResult> Step(int step = 1)
{
if (step < 1 || step > WizardProgressDto.TotalSteps)
return RedirectToAction("Step", new { step = 1 });
try
{
var (company, prefs, costs) = await LoadCompanyDataAsync();
var progress = BuildProgress(prefs);
ViewBag.Progress = progress;
ViewBag.Step = step;
return step switch
{
1 => View("Step1", new WizardStep1Dto
{
CompanyName = company.CompanyName,
PrimaryContactName = company.PrimaryContactName,
PrimaryContactEmail = company.PrimaryContactEmail,
Phone = company.Phone,
Address = company.Address,
City = company.City,
State = company.State,
ZipCode = company.ZipCode,
TimeZone = company.TimeZone,
DefaultCurrency = prefs.DefaultCurrency,
UseMetricSystem = prefs.UseMetricSystem
}),
2 => View("Step2", new WizardStep2QbDto
{
MigratingFromQuickBooks = prefs.MigratingFromQuickBooks
}),
3 => View("Step3", new WizardStep2Dto
{
StandardLaborRate = costs.StandardLaborRate,
SandblasterCostPerHour = costs.SandblasterCostPerHour,
CoatingBoothCostPerHour = costs.CoatingBoothCostPerHour,
OvenOperatingCostPerHour = costs.OvenOperatingCostPerHour,
PowderCoatingCostPerSqFt = costs.PowderCoatingCostPerSqFt,
GeneralMarkupPercentage = costs.GeneralMarkupPercentage,
TaxPercent = costs.TaxPercent,
ShopMinimumCharge = costs.ShopMinimumCharge,
ShopCapabilityTier = costs.ShopCapabilityTier
}),
4 => await BuildStep4ViewAsync(GetCompanyId()),
5 => View("Step5", new WizardStep3Dto
{
QuoteNumberPrefix = prefs.QuoteNumberPrefix,
JobNumberPrefix = prefs.JobNumberPrefix,
InvoiceNumberPrefix = !string.IsNullOrWhiteSpace(prefs.InvoiceNumberPrefix) ? prefs.InvoiceNumberPrefix : "INV",
QtAccentColor = prefs.QtAccentColor,
InAccentColor = prefs.InAccentColor,
WoAccentColor = prefs.WoAccentColor
}),
6 => View("Step6", new WizardStep5Dto
{
DefaultJobPriority = prefs.DefaultJobPriority,
RequireCustomerPO = prefs.RequireCustomerPO,
AllowCustomerApproval = prefs.AllowCustomerApproval,
}),
7 => View("Step7", new WizardStep4Dto
{
DefaultPaymentTerms = prefs.DefaultPaymentTerms,
DefaultQuoteValidityDays = prefs.DefaultQuoteValidityDays,
DefaultTurnaroundDays = prefs.DefaultTurnaroundDays,
QtDefaultTerms = prefs.QtDefaultTerms,
QtFooterNote = prefs.QtFooterNote
}),
8 => await BuildStep8ViewAsync(GetCompanyId()),
9 => View("Step9", new WizardStep7Dto
{
EmailNotificationsEnabled = prefs.EmailNotificationsEnabled,
EmailFromAddress = prefs.EmailFromAddress,
EmailFromName = prefs.EmailFromName,
NotifyOnNewJob = prefs.NotifyOnNewJob,
NotifyOnNewQuote = prefs.NotifyOnNewQuote,
NotifyOnJobStatusChange = prefs.NotifyOnJobStatusChange,
NotifyOnQuoteApproval = prefs.NotifyOnQuoteApproval,
NotifyOnPaymentReceived = prefs.NotifyOnPaymentReceived,
PaymentRemindersEnabled = prefs.PaymentRemindersEnabled,
PaymentReminderDays = prefs.PaymentReminderDays,
QuoteExpiryWarningDays = prefs.QuoteExpiryWarningDays,
DueDateWarningDays = prefs.DueDateWarningDays,
MaintenanceAlertDays = prefs.MaintenanceAlertDays
}),
10 => await BuildStep10ViewAsync(GetCompanyId()),
_ => RedirectToAction("Step", new { step = 1 })
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading wizard step {Step}", step);
TempData["Error"] = "An error occurred loading this step.";
return RedirectToAction("Step", new { step = 1 });
}
}
// ─── GET helpers (preload existing data) ─────────────────────────────────
/// <summary>
/// Builds the view model for Step 4 (Named Ovens) by loading existing active
/// <see cref="OvenCost"/> records and serializing them to JSON for the client-side
/// oven management table. Passing existing ovens as JSON lets the wizard re-use a single
/// view template for both first-time setup and subsequent edits without any server-side
/// conditional rendering.
/// </summary>
private async Task<IActionResult> BuildStep4ViewAsync(int companyId)
{
var existingOvens = await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == companyId && !o.IsDeleted && o.IsActive);
var existingBlasts = await _unitOfWork.BlastSetups.FindAsync(b => b.CompanyId == companyId && !b.IsDeleted && b.IsActive);
var dto = new WizardOvensStepDto
{
OvensJson = existingOvens.Any()
? JsonSerializer.Serialize(existingOvens.OrderBy(o => o.DisplayOrder).Select(o => new WizardOvenDto
{
Id = o.Id,
Label = o.Label,
CostPerHour = o.CostPerHour,
MaxLoadSqFt = o.MaxLoadSqFt,
DefaultCycleMinutes = o.DefaultCycleMinutes
}))
: null,
BlastSetupsJson = existingBlasts.Any()
? JsonSerializer.Serialize(existingBlasts.OrderBy(b => b.DisplayOrder).Select(b => new WizardBlastSetupDto
{
Id = b.Id,
Name = b.Name,
SetupType = (int)b.SetupType,
CompressorCfm = b.CompressorCfm,
BlastNozzleSize = b.BlastNozzleSize,
PrimarySubstrate = (int)b.PrimarySubstrate,
BlastRateSqFtPerHourOverride = b.BlastRateSqFtPerHourOverride,
IsDefault = b.IsDefault
}))
: null
};
return View("Step4", dto);
}
/// <summary>
/// Builds the view model for Step 8 (Pricing Tiers) by loading existing tiers and serializing
/// them as camelCase JSON for the client-side tier management table.
/// CamelCase serialization is required here because the JavaScript that reads this JSON expects
/// camelCase property names (e.g., <c>tierName</c> not <c>TierName</c>), unlike the oven step
/// which uses PascalCase — a discrepancy inherited from different JS widget implementations.
/// </summary>
private async Task<IActionResult> BuildStep8ViewAsync(int companyId)
{
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && !t.IsDeleted);
var camelCase = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var dto = new WizardPricingTiersStepDto
{
TiersJson = existing.Any()
? JsonSerializer.Serialize(existing.OrderBy(t => t.Id).Select(t => new WizardPricingTierDto
{
Id = t.Id,
TierName = t.TierName,
Description = t.Description,
DiscountPercent = t.DiscountPercent
}), camelCase)
: null
};
return View("Step8", dto);
}
/// <summary>
/// Builds the view model for Step 10 (Team Members) by loading existing non-admin users so they
/// can be displayed as read-only in the view.
/// Only non-admin company users are shown because the wizard's team-member step is designed for
/// adding shop workers and managers; the CompanyAdmin who is running the wizard is already
/// implied. Showing existing members prevents the wizard user from accidentally creating
/// duplicates of accounts that were added outside the wizard flow.
/// </summary>
private async Task<IActionResult> BuildStep10ViewAsync(int companyId)
{
// Load existing non-admin team members so they're shown as read-only in the view
var existingUsers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive
&& u.CompanyRole != AppConstants.CompanyRoles.CompanyAdmin)
.OrderBy(u => u.LastName).ThenBy(u => u.FirstName)
.Select(u => new { u.FirstName, u.LastName, u.Email, u.CompanyRole })
.ToListAsync();
ViewBag.ExistingTeamMembers = existingUsers;
return View("Step10", new WizardStep9Dto());
}
// ─── POST Steps ───────────────────────────────────────────────────────────
/// <summary>
/// Saves company identity and locale preferences from Step 1 (Company Info).
/// <c>PrimaryContactName</c> and <c>PrimaryContactEmail</c> use null-coalescing to preserve
/// existing values when the form fields are left blank — the wizard UI shows them as optional,
/// so blanking them out would silently erase data that may have been set during registration.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep1(WizardStep1Dto model)
{
var (company, prefs, _) = await LoadCompanyDataAsync();
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 1;
if (!ModelState.IsValid) return View("Step1", model);
company.CompanyName = model.CompanyName;
company.PrimaryContactName = model.PrimaryContactName ?? company.PrimaryContactName;
company.PrimaryContactEmail = model.PrimaryContactEmail ?? company.PrimaryContactEmail;
company.Phone = model.Phone;
company.Address = model.Address;
company.City = model.City;
company.State = model.State;
company.ZipCode = model.ZipCode;
company.TimeZone = model.TimeZone;
prefs.DefaultCurrency = model.DefaultCurrency;
prefs.UseMetricSystem = model.UseMetricSystem;
MarkDone(prefs, 1);
await _unitOfWork.CompleteAsync();
return RedirectToStep(2);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep2(WizardStep2QbDto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
prefs.MigratingFromQuickBooks = model.MigratingFromQuickBooks;
MarkDone(prefs, 2);
await _unitOfWork.CompleteAsync();
return RedirectToStep(3);
}
/// <summary>
/// Saves base operating cost rates from Step 3 (Pricing Defaults) including labor, sandblasting,
/// booth, oven, powder cost per sq ft, markup, tax, and shop minimum.
/// Note: <c>OvenOperatingCostPerHour</c> is also updated here as a direct user entry, but Step 4
/// (Named Ovens) will overwrite it with the first named oven's cost per hour once that step is
/// saved. Step 3 is kept as a fallback for shops that do not configure named ovens at all.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep3(WizardStep2Dto model)
{
var (_, prefs, costs) = await LoadCompanyDataAsync();
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 3;
if (!ModelState.IsValid) return View("Step3", model);
costs.StandardLaborRate = model.StandardLaborRate;
costs.SandblasterCostPerHour = model.SandblasterCostPerHour;
costs.CoatingBoothCostPerHour = model.CoatingBoothCostPerHour;
costs.OvenOperatingCostPerHour = model.OvenOperatingCostPerHour;
costs.PowderCoatingCostPerSqFt = model.PowderCoatingCostPerSqFt;
costs.GeneralMarkupPercentage = model.GeneralMarkupPercentage;
costs.TaxPercent = model.TaxPercent;
costs.ShopMinimumCharge = model.ShopMinimumCharge;
// Apply capability tier defaults so quoting calibration has a sensible starting point.
// The shop can refine these later in Company Settings → Quoting Calibration.
costs.ShopCapabilityTier = model.ShopCapabilityTier;
var (setupType, cfm, nozzle, substrate) = ShopCapabilityCalculator.TierDefaults(model.ShopCapabilityTier);
costs.BlastSetupType = setupType;
costs.CompressorCfm = cfm;
costs.BlastNozzleSize = nozzle;
costs.PrimaryBlastSubstrate = substrate;
MarkDone(prefs, 3);
await _unitOfWork.CompleteAsync();
return RedirectToStep(4);
}
/// <summary>
/// Persists named ovens from Step 4, performing a full upsert: existing ovens are updated in
/// place, new ovens are inserted, and any oven omitted from the submitted list is soft-deleted.
/// After saving, <c>OvenOperatingCostPerHour</c> on <see cref="CompanyOperatingCosts"/> is
/// automatically set to the first oven's cost per hour. This auto-derivation is intentional —
/// the quote pricing engine uses <c>OvenOperatingCostPerHour</c> as a single flat rate, so it
/// must always reflect a real oven cost. The first oven in display order is used as the most
/// representative default. Admins can later edit the value directly in Company Settings.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep4(WizardOvensStepDto model)
{
var (_, prefs, costs) = await LoadCompanyDataAsync();
var companyId = GetCompanyId();
if (!string.IsNullOrWhiteSpace(model.OvensJson))
{
try
{
var ovens = JsonSerializer.Deserialize<List<WizardOvenDto>>(model.OvensJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
var validOvens = ovens?.Where(o => !string.IsNullOrWhiteSpace(o.Label)).ToList();
if (validOvens != null && validOvens.Count > 0)
{
var existing = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == companyId && !o.IsDeleted))
.ToDictionary(o => o.Id);
var submittedIds = validOvens.Where(o => o.Id > 0).Select(o => o.Id).ToHashSet();
// Soft-delete ovens that were removed from the list
foreach (var e in existing.Values.Where(e => !submittedIds.Contains(e.Id)))
await _unitOfWork.OvenCosts.SoftDeleteAsync(e.Id);
int order = 1;
foreach (var o in validOvens)
{
if (o.Id > 0 && existing.TryGetValue(o.Id, out var record))
{
// Update in place
record.Label = o.Label.Trim();
record.CostPerHour = o.CostPerHour;
record.MaxLoadSqFt = o.MaxLoadSqFt;
record.DefaultCycleMinutes = o.DefaultCycleMinutes;
record.DisplayOrder = order++;
await _unitOfWork.OvenCosts.UpdateAsync(record);
}
else
{
await _unitOfWork.OvenCosts.AddAsync(new OvenCost
{
CompanyId = companyId,
Label = o.Label.Trim(),
CostPerHour = o.CostPerHour,
MaxLoadSqFt = o.MaxLoadSqFt,
DefaultCycleMinutes = o.DefaultCycleMinutes,
IsActive = true,
DisplayOrder = order++
});
}
}
await _unitOfWork.CompleteAsync();
// Use the first oven's cost as the fallback rate for quotes with no oven selected
costs.OvenOperatingCostPerHour = validOvens[0].CostPerHour;
await _unitOfWork.CompleteAsync();
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to deserialize ovens JSON in wizard step 4");
}
}
if (!string.IsNullOrWhiteSpace(model.BlastSetupsJson))
{
try
{
var blasts = JsonSerializer.Deserialize<List<WizardBlastSetupDto>>(model.BlastSetupsJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
var validBlasts = blasts?.Where(b => !string.IsNullOrWhiteSpace(b.Name)).ToList();
if (validBlasts != null && validBlasts.Count > 0)
{
var existing = (await _unitOfWork.BlastSetups.FindAsync(b => b.CompanyId == companyId && !b.IsDeleted))
.ToDictionary(b => b.Id);
var submittedIds = validBlasts.Where(b => b.Id > 0).Select(b => b.Id).ToHashSet();
foreach (var e in existing.Values.Where(e => !submittedIds.Contains(e.Id)))
await _unitOfWork.BlastSetups.SoftDeleteAsync(e.Id);
// Ensure exactly one default — last one flagged wins if multiple submitted
var defaultIdx = validBlasts.FindLastIndex(b => b.IsDefault);
if (defaultIdx < 0) defaultIdx = 0;
int order = 1;
for (int i = 0; i < validBlasts.Count; i++)
{
var b = validBlasts[i];
bool isDefault = i == defaultIdx;
if (b.Id > 0 && existing.TryGetValue(b.Id, out var record))
{
record.Name = b.Name.Trim();
record.SetupType = (BlastSetupType)b.SetupType;
record.CompressorCfm = b.CompressorCfm;
record.BlastNozzleSize = b.BlastNozzleSize;
record.PrimarySubstrate = (BlastSubstrateType)b.PrimarySubstrate;
record.BlastRateSqFtPerHourOverride = b.BlastRateSqFtPerHourOverride;
record.IsDefault = isDefault;
record.DisplayOrder = order++;
await _unitOfWork.BlastSetups.UpdateAsync(record);
}
else
{
await _unitOfWork.BlastSetups.AddAsync(new CompanyBlastSetup
{
CompanyId = companyId,
Name = b.Name.Trim(),
SetupType = (BlastSetupType)b.SetupType,
CompressorCfm = b.CompressorCfm,
BlastNozzleSize = b.BlastNozzleSize,
PrimarySubstrate = (BlastSubstrateType)b.PrimarySubstrate,
BlastRateSqFtPerHourOverride = b.BlastRateSqFtPerHourOverride,
IsDefault = isDefault,
IsActive = true,
DisplayOrder = order++
});
}
}
await _unitOfWork.CompleteAsync();
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to deserialize blast setups JSON in wizard step 4");
}
}
MarkDone(prefs, 4);
await _unitOfWork.CompleteAsync();
return RedirectToStep(5);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep5(WizardStep3Dto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 5;
if (!ModelState.IsValid) return View("Step5", model);
prefs.QuoteNumberPrefix = model.QuoteNumberPrefix;
prefs.JobNumberPrefix = model.JobNumberPrefix;
prefs.InvoiceNumberPrefix = model.InvoiceNumberPrefix;
prefs.QtAccentColor = model.QtAccentColor;
prefs.InAccentColor = model.InAccentColor;
prefs.WoAccentColor = model.WoAccentColor;
MarkDone(prefs, 5);
await _unitOfWork.CompleteAsync();
return RedirectToStep(6);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep6(WizardStep5Dto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 6;
if (!ModelState.IsValid) return View("Step6", model);
prefs.DefaultJobPriority = model.DefaultJobPriority;
prefs.RequireCustomerPO = model.RequireCustomerPO;
prefs.AllowCustomerApproval = model.AllowCustomerApproval;
MarkDone(prefs, 6);
await _unitOfWork.CompleteAsync();
return RedirectToStep(7);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep7(WizardStep4Dto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 7;
if (!ModelState.IsValid) return View("Step7", model);
prefs.DefaultPaymentTerms = model.DefaultPaymentTerms;
prefs.DefaultQuoteValidityDays = model.DefaultQuoteValidityDays;
prefs.DefaultTurnaroundDays = model.DefaultTurnaroundDays;
prefs.QtDefaultTerms = model.QtDefaultTerms;
prefs.QtFooterNote = model.QtFooterNote;
MarkDone(prefs, 7);
await _unitOfWork.CompleteAsync();
return RedirectToStep(8);
}
/// <summary>
/// Persists pricing tiers from Step 8, using the same upsert-and-soft-delete pattern as
/// <see cref="PostStep4"/>: existing tiers updated in place, new ones inserted, removed ones
/// soft-deleted. Tiers with a blank <c>TierName</c> are silently ignored so the client-side
/// table's empty placeholder rows do not produce invalid records. JsonException is caught and
/// logged rather than thrown so a malformed JSON payload (e.g., from a broken browser extension)
/// still advances the wizard rather than stopping the admin from completing setup.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep8(WizardPricingTiersStepDto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
var companyId = GetCompanyId();
if (!string.IsNullOrWhiteSpace(model.TiersJson))
{
try
{
var tiers = JsonSerializer.Deserialize<List<WizardPricingTierDto>>(model.TiersJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (tiers != null)
{
var validTiers = tiers.Where(t => !string.IsNullOrWhiteSpace(t.TierName)).ToList();
if (validTiers.Count > 0)
{
var existing = (await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && !t.IsDeleted))
.ToDictionary(t => t.Id);
var submittedIds = validTiers.Where(t => t.Id > 0).Select(t => t.Id).ToHashSet();
// Soft-delete tiers that were removed from the list
foreach (var e in existing.Values.Where(e => !submittedIds.Contains(e.Id)))
await _unitOfWork.PricingTiers.SoftDeleteAsync(e.Id);
foreach (var t in validTiers)
{
if (t.Id > 0 && existing.TryGetValue(t.Id, out var record))
{
// Update in place
record.TierName = t.TierName.Trim();
record.Description = t.Description?.Trim();
record.DiscountPercent = t.DiscountPercent;
await _unitOfWork.PricingTiers.UpdateAsync(record);
}
else
{
await _unitOfWork.PricingTiers.AddAsync(new PricingTier
{
CompanyId = companyId,
TierName = t.TierName.Trim(),
Description = t.Description?.Trim(),
DiscountPercent = t.DiscountPercent,
IsActive = true
});
}
}
await _unitOfWork.CompleteAsync();
}
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to deserialize pricing tiers JSON in wizard step 8");
}
}
MarkDone(prefs, 8);
await _unitOfWork.CompleteAsync();
return RedirectToStep(9);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep9(WizardStep7Dto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 9;
if (!ModelState.IsValid) return View("Step9", model);
prefs.EmailNotificationsEnabled = model.EmailNotificationsEnabled;
prefs.EmailFromAddress = model.EmailFromAddress;
prefs.EmailFromName = model.EmailFromName;
prefs.NotifyOnNewJob = model.NotifyOnNewJob;
prefs.NotifyOnNewQuote = model.NotifyOnNewQuote;
prefs.NotifyOnJobStatusChange = model.NotifyOnJobStatusChange;
prefs.NotifyOnQuoteApproval = model.NotifyOnQuoteApproval;
prefs.NotifyOnPaymentReceived = model.NotifyOnPaymentReceived;
prefs.PaymentRemindersEnabled = model.PaymentRemindersEnabled;
prefs.PaymentReminderDays = model.PaymentReminderDays;
prefs.QuoteExpiryWarningDays = model.QuoteExpiryWarningDays;
prefs.DueDateWarningDays = model.DueDateWarningDays;
prefs.MaintenanceAlertDays = model.MaintenanceAlertDays;
MarkDone(prefs, 9);
await _unitOfWork.CompleteAsync();
return RedirectToStep(10);
}
/// <summary>
/// Creates team member accounts from Step 10 (Invite Team), assigns each user a company role,
/// and also maps them to the legacy ASP.NET Identity role system for policy-based authorization.
/// The dual-role assignment (CompanyRole + Identity role) is required because authorization
/// policies in this app evaluate both the legacy role claim and the <c>CompanyRole</c> property.
/// Users with emails that already exist are silently skipped so re-submitting the wizard after
/// a partial failure does not attempt to create duplicates. Setting <c>SetupWizardCompleted = true</c>
/// here hides the wizard prompt from the dashboard going forward.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep10(WizardStep9Dto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
var companyId = GetCompanyId();
if (!string.IsNullOrWhiteSpace(model.MembersJson))
{
try
{
var members = JsonSerializer.Deserialize<List<WizardTeamMemberDto>>(model.MembersJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (members != null)
{
foreach (var m in members.Where(m => !string.IsNullOrWhiteSpace(m.Email)
&& !string.IsNullOrWhiteSpace(m.Password)))
{
var existing = await _userManager.FindByEmailAsync(m.Email);
if (existing != null) continue;
var validRoles = new[] { AppConstants.CompanyRoles.CompanyAdmin, AppConstants.CompanyRoles.Manager, AppConstants.CompanyRoles.Worker, AppConstants.CompanyRoles.Viewer };
var companyRole = validRoles.Contains(m.CompanyRole) ? m.CompanyRole : AppConstants.CompanyRoles.Worker;
var user = new ApplicationUser
{
UserName = m.Email, Email = m.Email, EmailConfirmed = true,
FirstName = m.FirstName, LastName = m.LastName,
CompanyId = companyId, CompanyRole = companyRole, IsActive = true,
CanManageJobs = true, CanManageCustomers = true, CanCreateQuotes = true,
CanManageCalendar = true, CanViewCalendar = true, CanViewProducts = true
};
var result = await _userManager.CreateAsync(user, m.Password);
if (result.Succeeded)
{
var legacyRole = companyRole switch
{
AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator,
AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager,
AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee,
_ => AppConstants.Roles.ReadOnly
};
await _userManager.AddToRoleAsync(user, legacyRole);
}
else
{
_logger.LogWarning("Failed to create wizard user {Email}: {Errors}",
m.Email, string.Join(", ", result.Errors.Select(e => e.Description)));
}
}
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to deserialize team members JSON in wizard step 10");
}
}
MarkDone(prefs, 10);
prefs.SetupWizardCompleted = true;
// Record who completed the wizard and when so SuperAdmins can see completion status per-user.
var currentUser = await _userManager.GetUserAsync(User);
prefs.SetupWizardCompletedAt = DateTime.UtcNow;
prefs.SetupWizardCompletedByUserId = currentUser?.Id;
prefs.SetupWizardCompletedByName = currentUser != null
? $"{currentUser.FirstName} {currentUser.LastName}".Trim()
: User.Identity?.Name;
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Complete));
}
// ─── Skip ─────────────────────────────────────────────────────────────────
/// <summary>
/// Marks the given step as skipped and advances to the next step, or to the
/// <see cref="Complete"/> page when skipping the final step.
/// Skipped steps are tracked separately from done steps so the progress bar can display them
/// with a distinct visual state ("skipped" vs "completed"). A step that was already done
/// cannot be skipped — see <see cref="MarkSkipped"/> — so the admin cannot accidentally
/// uncheck progress by using the browser's back button then clicking Skip.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Skip(int step)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
MarkSkipped(prefs, step);
await _unitOfWork.CompleteAsync();
int next = step >= WizardProgressDto.TotalSteps ? 0 : step + 1;
return RedirectToStep(next == 0 ? WizardProgressDto.TotalSteps + 1 : next);
}
// ─── QB Migration Wizard State ────────────────────────────────────────────
/// <summary>
/// Returns the serialized QuickBooks migration wizard state JSON for the current company,
/// allowing the client-side QB migration UI to restore its multi-step state across page
/// refreshes. The state blob is opaque to the server — it is stored and returned verbatim.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetQbMigrationState()
{
var (_, prefs, _) = await LoadCompanyDataAsync();
return Json(new { state = prefs.QbMigrationStateJson });
}
/// <summary>
/// Persists an arbitrary QuickBooks migration wizard state JSON blob provided by the client.
/// The server treats this as an opaque string and does not validate its contents — the QB
/// migration UI owns the schema. Storing state server-side (rather than in sessionStorage)
/// allows the admin to close the browser and resume the QB migration later without losing
/// progress.
/// </summary>
[HttpPost]
public async Task<IActionResult> SaveQbMigrationState([FromBody] SaveQbMigrationStateRequest request)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
prefs.QbMigrationStateJson = request.State;
await _unitOfWork.CompleteAsync();
return Json(new { ok = true });
}
// ─── Complete ─────────────────────────────────────────────────────────────
/// <summary>
/// Renders the wizard completion/summary page after all steps have been submitted or skipped.
/// Progress data is passed to the view via <c>ViewBag.Progress</c> so the page can display
/// a summary of which steps were completed vs skipped, giving the admin a clear picture of
/// any configuration they may want to revisit later.
/// </summary>
[HttpGet]
public async Task<IActionResult> Complete()
{
var (_, prefs, _) = await LoadCompanyDataAsync();
ViewBag.Progress = BuildProgress(prefs);
return View();
}
}
public class SaveQbMigrationStateRequest
{
public string? State { get; set; }
}
@@ -0,0 +1,242 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Text;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Displays per-company SMS consent status for all customers. Intended for company admins to
/// verify compliance (who has opted in, who opted out, and when). SuperAdmins can also access
/// this via the normal admin impersonation path.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class SmsConsentAuditController : Controller
{
private readonly ApplicationDbContext _context;
private readonly ILogger<SmsConsentAuditController> _logger;
public SmsConsentAuditController(ApplicationDbContext context, ILogger<SmsConsentAuditController> logger)
{
_context = context;
_logger = logger;
}
/// <summary>
/// Shows the SMS consent audit report filtered by status and optional name/phone search.
/// </summary>
public async Task<IActionResult> Index(string? filter = "all", string? search = null)
{
try
{
var query = _context.Customers
.AsNoTracking()
.Where(c => !c.IsDeleted);
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
query = query.Where(c =>
(c.ContactFirstName != null && c.ContactFirstName.ToLower().Contains(s)) ||
(c.ContactLastName != null && c.ContactLastName.ToLower().Contains(s)) ||
(c.CompanyName != null && c.CompanyName.ToLower().Contains(s)) ||
(c.MobilePhone != null && c.MobilePhone.Contains(s)) ||
(c.Phone != null && c.Phone.Contains(s)));
}
var customers = await query
.Select(c => new
{
c.Id,
c.CompanyName,
c.ContactFirstName,
c.ContactLastName,
c.IsCommercial,
c.Phone,
c.MobilePhone,
c.NotifyBySms,
c.SmsConsentedAt,
c.SmsConsentMethod,
c.SmsOptedOutAt
})
.OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
.ToListAsync();
var allRows = customers.Select(c => new SmsConsentRow
{
CustomerId = c.Id,
CustomerName = GetDisplayName(c.IsCommercial, c.CompanyName, c.ContactFirstName, c.ContactLastName),
Phone = c.Phone,
MobilePhone = c.MobilePhone,
NotifyBySms = c.NotifyBySms,
ConsentedAt = c.SmsConsentedAt,
ConsentMethod = c.SmsConsentMethod,
OptedOutAt = c.SmsOptedOutAt,
SmsStatus = ResolveSmsStatus(c.NotifyBySms, c.SmsConsentedAt, c.SmsOptedOutAt)
}).ToList();
// Stat counts across unfiltered set
var optedIn = allRows.Count(r => r.SmsStatus == "active");
var optedOut = allRows.Count(r => r.SmsStatus == "opted-out");
var never = allRows.Count(r => r.SmsStatus == "never");
// Apply filter
var filtered = filter switch
{
"opted-in" => allRows.Where(r => r.SmsStatus == "active").ToList(),
"opted-out" => allRows.Where(r => r.SmsStatus == "opted-out").ToList(),
"never" => allRows.Where(r => r.SmsStatus == "never").ToList(),
_ => allRows
};
var vm = new SmsConsentAuditViewModel
{
Rows = filtered,
Filter = filter ?? "all",
Search = search,
TotalCount = allRows.Count,
OptedInCount = optedIn,
OptedOutCount = optedOut,
NeverSubscribedCount = never
};
return View(vm);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading SMS consent audit");
return View(new SmsConsentAuditViewModel());
}
}
/// <summary>
/// Exports the SMS consent audit as a CSV file for compliance record-keeping.
/// Includes all customers regardless of the current filter.
/// </summary>
public async Task<IActionResult> ExportCsv()
{
try
{
var customers = await _context.Customers
.AsNoTracking()
.Where(c => !c.IsDeleted)
.Select(c => new
{
c.Id,
c.CompanyName,
c.ContactFirstName,
c.ContactLastName,
c.IsCommercial,
c.Phone,
c.MobilePhone,
c.NotifyBySms,
c.SmsConsentedAt,
c.SmsConsentMethod,
c.SmsOptedOutAt
})
.OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
.ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("Customer Name,Phone,Mobile Phone,SMS Status,Consented At (UTC),Consent Method,Opted Out At (UTC)");
foreach (var c in customers)
{
var name = CsvEscape(GetDisplayName(c.IsCommercial, c.CompanyName, c.ContactFirstName, c.ContactLastName));
var status = ResolveSmsStatus(c.NotifyBySms, c.SmsConsentedAt, c.SmsOptedOutAt) switch
{
"active" => "Opted In",
"opted-out" => "Opted Out",
_ => "Never Subscribed"
};
sb.AppendLine(string.Join(",",
name,
CsvEscape(c.Phone ?? ""),
CsvEscape(c.MobilePhone ?? ""),
status,
c.SmsConsentedAt.HasValue ? c.SmsConsentedAt.Value.ToString("yyyy-MM-dd HH:mm:ss") : "",
CsvEscape(c.SmsConsentMethod ?? ""),
c.SmsOptedOutAt.HasValue ? c.SmsOptedOutAt.Value.ToString("yyyy-MM-dd HH:mm:ss") : ""
));
}
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
return File(bytes, "text/csv", $"sms-consent-audit-{DateTime.UtcNow:yyyyMMdd}.csv");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting SMS consent audit");
return RedirectToAction(nameof(Index));
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static string ResolveSmsStatus(bool notifyBySms, DateTime? consentedAt, DateTime? optedOutAt)
{
if (notifyBySms) return "active";
if (optedOutAt.HasValue || (consentedAt.HasValue && !notifyBySms)) return "opted-out";
return "never";
}
private static string GetDisplayName(bool isCommercial, string? companyName, string? first, string? last)
{
if (!isCommercial)
{
var contact = $"{first} {last}".Trim();
if (!string.IsNullOrEmpty(contact)) return contact;
}
if (!string.IsNullOrWhiteSpace(companyName)) return companyName;
return $"{first} {last}".Trim() is { Length: > 0 } n ? n : "—";
}
private static string CsvEscape(string value)
{
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
// ── View models ───────────────────────────────────────────────────────────────
public class SmsConsentAuditViewModel
{
public List<SmsConsentRow> Rows { get; set; } = [];
public string Filter { get; set; } = "all";
public string? Search { get; set; }
public int TotalCount { get; set; }
public int OptedInCount { get; set; }
public int OptedOutCount { get; set; }
public int NeverSubscribedCount { get; set; }
}
public class SmsConsentRow
{
public int CustomerId { get; set; }
public string CustomerName { get; set; } = "";
public string? Phone { get; set; }
public string? MobilePhone { get; set; }
public bool NotifyBySms { get; set; }
public string SmsStatus { get; set; } = "never"; // "active" | "opted-out" | "never"
public DateTime? ConsentedAt { get; set; }
public string? ConsentMethod { get; set; }
public DateTime? OptedOutAt { get; set; }
public string StatusBadgeClass => SmsStatus switch
{
"active" => "bg-success",
"opted-out" => "bg-danger",
_ => "bg-secondary"
};
public string StatusLabel => SmsStatus switch
{
"active" => "Opted In",
"opted-out" => "Opted Out",
_ => "Never Subscribed"
};
}
@@ -0,0 +1,73 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only tool for migrating locally stored media files from the on-disk
/// <c>media/</c> folder to Azure Blob Storage via
/// <see cref="IStorageMigrationService"/>. Intended to be run once (or a small number
/// of times) during a storage-backend transition — it is not a routine operation.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class StorageMigrationController : Controller
{
private readonly IStorageMigrationService _migrationService;
private readonly IWebHostEnvironment _environment;
public StorageMigrationController(
IStorageMigrationService migrationService,
IWebHostEnvironment environment)
{
_migrationService = migrationService;
_environment = environment;
}
/// <summary>
/// Renders the migration status page showing the local <c>media/</c> path, whether
/// the directory exists, and how many files it currently contains. This information
/// lets operators confirm there is work to migrate before triggering the potentially
/// long-running <see cref="Migrate"/> action.
/// </summary>
public IActionResult Index()
{
var mediaPath = Path.Combine(_environment.ContentRootPath, "media");
ViewBag.MediaPath = mediaPath;
ViewBag.MediaExists = Directory.Exists(mediaPath);
if (Directory.Exists(mediaPath))
{
var fileCount = Directory.EnumerateFiles(mediaPath, "*.*", SearchOption.AllDirectories).Count();
ViewBag.LocalFileCount = fileCount;
}
else
{
ViewBag.LocalFileCount = 0;
}
return View();
}
/// <summary>
/// Executes the migration of all local <c>media/</c> files to Azure Blob Storage
/// and renders a Results view with the outcome.
/// <para>
/// The <paramref name="deleteAfterMigration"/> flag controls whether source files
/// are removed from disk after successful upload. It defaults to <c>false</c> so
/// that operators can verify the migration result before committing to deletion.
/// The actual upload logic is encapsulated in <see cref="IStorageMigrationService"/>
/// so it can be tested independently of the HTTP layer.
/// </para>
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Migrate(bool deleteAfterMigration = false)
{
var mediaPath = Path.Combine(_environment.ContentRootPath, "media");
var result = await _migrationService.MigrateFilesystemToAzureAsync(mediaPath, deleteAfterMigration);
return View("Results", result);
}
}
@@ -0,0 +1,125 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only dashboard for inspecting Stripe webhook events logged by
/// <c>StripeWebhookController</c>. Provides filtering, search, and pagination over the
/// <c>StripeWebhookEvents</c> table so platform operators can diagnose failed events, confirm
/// delivery, and view the raw JSON payload Stripe sent. Restricted to SuperAdmin because the raw
/// event payloads may contain sensitive subscription and billing information.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class StripeEventsController : Controller
{
private readonly ApplicationDbContext _db;
private readonly ILogger<StripeEventsController> _logger;
public StripeEventsController(ApplicationDbContext db, ILogger<StripeEventsController> logger)
{
_db = db;
_logger = logger;
}
/// <summary>
/// Lists Stripe webhook events with optional filtering by event type, status, free-text search
/// (event ID or error message), and date range. Page size is validated against an allowlist
/// (25/50/100) to prevent unbounded queries. Summary counts (total, processed, failed, last 24h)
/// are computed with separate COUNT queries rather than in-memory aggregation so they reflect
/// the unfiltered totals regardless of the current filter state — this gives operators a quick
/// health snapshot even when the filter is narrowed. The distinct event-type list for the filter
/// dropdown is also fetched from the DB so it always reflects what has actually been received
/// rather than a hardcoded enum.
/// </summary>
public async Task<IActionResult> Index(
string? eventType,
string? status,
string? search,
DateTime? from,
DateTime? to,
int page = 1,
int pageSize = 50)
{
pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50;
page = Math.Max(1, page);
var query = _db.StripeWebhookEvents.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(eventType))
query = query.Where(e => e.EventType == eventType);
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<StripeWebhookEventStatus>(status, out var statusEnum))
query = query.Where(e => e.Status == statusEnum);
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(e => e.EventId.Contains(search) || (e.ErrorMessage != null && e.ErrorMessage.Contains(search)));
if (from.HasValue)
query = query.Where(e => e.ReceivedAt >= from.Value.Date);
if (to.HasValue)
query = query.Where(e => e.ReceivedAt < to.Value.Date.AddDays(1));
query = query.OrderByDescending(e => e.ReceivedAt);
var totalCount = await query.CountAsync();
var events = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
// Distinct event types for filter dropdown
var eventTypes = await _db.StripeWebhookEvents.AsNoTracking()
.Select(e => e.EventType)
.Where(t => t != string.Empty)
.Distinct()
.OrderBy(t => t)
.ToListAsync();
// Summary counts
ViewBag.TotalCount = await _db.StripeWebhookEvents.CountAsync();
ViewBag.ProcessedCount = await _db.StripeWebhookEvents.CountAsync(e => e.Status == StripeWebhookEventStatus.Processed);
ViewBag.FailedCount = await _db.StripeWebhookEvents.CountAsync(e => e.Status == StripeWebhookEventStatus.Failed);
ViewBag.Last24hCount = await _db.StripeWebhookEvents.CountAsync(e => e.ReceivedAt >= DateTime.UtcNow.AddHours(-24));
ViewBag.EventTypes = eventTypes;
ViewBag.EventTypeFilter = eventType;
ViewBag.StatusFilter = status;
ViewBag.Search = search;
ViewBag.From = from?.ToString("yyyy-MM-dd");
ViewBag.To = to?.ToString("yyyy-MM-dd");
ViewBag.Page = page;
ViewBag.PageSize = pageSize;
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
ViewBag.FilteredCount = totalCount;
return View(events);
}
/// <summary>
/// Shows the full details of a single Stripe webhook event including the raw JSON payload.
/// Pretty-prints the JSON using <c>System.Text.Json.JsonDocument</c> for readability; falls
/// back to the original raw string if parsing fails (e.g. for events logged before JSON
/// validation was added). Returns 404 for unknown event IDs rather than throwing.
/// </summary>
public async Task<IActionResult> Details(long id)
{
var evt = await _db.StripeWebhookEvents.AsNoTracking().FirstOrDefaultAsync(e => e.Id == id);
if (evt == null) return NotFound();
// Try to format the JSON for display
string formattedJson = evt.RawJson;
try
{
var doc = System.Text.Json.JsonDocument.Parse(evt.RawJson);
formattedJson = System.Text.Json.JsonSerializer.Serialize(doc.RootElement,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
}
catch { /* keep original if parse fails */ }
ViewBag.FormattedJson = formattedJson;
return View(evt);
}
}
@@ -0,0 +1,128 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Receives platform-level Stripe webhook events (subscription lifecycle, checkout completion, etc.)
/// from the Stripe platform account. This is distinct from <c>PaymentController.ConnectWebhook</c>
/// which handles Stripe Connect account-level events (payments, refunds, disputes on tenant
/// accounts). Raw body reading is required — Stripe's signature verification computes an HMAC over
/// the exact bytes received, so any middleware that re-encodes the body (e.g. JSON middleware)
/// would break signature validation. The request body size limit is 5 MB; Stripe events are always
/// much smaller, but this cap prevents memory exhaustion from malformed oversized POST requests.
/// </summary>
[AllowAnonymous]
[IgnoreAntiforgeryToken]
[RequestSizeLimit(5 * 1024 * 1024)] // 5 MB — Stripe events are never larger than this
[EnableRateLimiting(AppConstants.RateLimitPolicies.Webhook)]
public class StripeWebhookController : Controller
{
private readonly IStripeService _stripeService;
private readonly ApplicationDbContext _db;
private readonly ILogger<StripeWebhookController> _logger;
public StripeWebhookController(
IStripeService stripeService,
ApplicationDbContext db,
ILogger<StripeWebhookController> logger)
{
_stripeService = stripeService;
_db = db;
_logger = logger;
}
/// <summary>
/// Receives, verifies, and processes a Stripe webhook event at <c>POST /stripe/webhook</c>.
/// The body is read as raw text rather than via model binding so the HMAC signature computed
/// by Stripe over the exact byte sequence remains valid. The event ID and type are parsed
/// structurally (before signature check) purely for logging — this pre-parse does not trust the
/// payload; the authoritative verification happens inside
/// <c>IStripeService.HandleWebhookAsync</c>. The <c>StripeWebhookEvent</c> audit record is
/// written AFTER signature verification to prevent unauthenticated callers from flooding the
/// events table. On a processing error after verification, the event is still recorded with
/// <c>Status = Failed</c> so SuperAdmins can investigate in <c>StripeEventsController</c>.
/// Returns HTTP 500 on processing failure — Stripe will retry the event up to its retry policy
/// limit, which is the desired behaviour for transient DB or service errors.
/// </summary>
[HttpPost("/stripe/webhook")]
public async Task<IActionResult> Webhook()
{
// Read raw body
using var reader = new StreamReader(Request.Body);
var json = await reader.ReadToEndAsync();
var stripeSignature = Request.Headers["Stripe-Signature"].ToString();
if (string.IsNullOrEmpty(stripeSignature))
{
_logger.LogWarning("Stripe webhook received without signature header");
return BadRequest("Missing Stripe-Signature header");
}
// Parse event id/type for logging (no signature check yet — just structural parse)
string eventId = string.Empty;
string eventType = string.Empty;
try
{
var evt = Stripe.EventUtility.ParseEvent(json);
eventId = evt.Id ?? string.Empty;
eventType = evt.Type ?? string.Empty;
}
catch { /* invalid JSON — HandleWebhookAsync will reject it below */ }
// Process first (signature verification happens inside HandleWebhookAsync).
// Only persist a log record after we know the request is authentic — this prevents
// unauthenticated callers from flooding the StripeWebhookEvents table.
try
{
await _stripeService.HandleWebhookAsync(json, stripeSignature);
// Signature verified and event processed — now record it
var logEntry = new StripeWebhookEvent
{
EventId = eventId,
EventType = eventType,
RawJson = json,
Status = StripeWebhookEventStatus.Processed,
ReceivedAt = DateTime.UtcNow,
ProcessedAt = DateTime.UtcNow
};
_db.StripeWebhookEvents.Add(logEntry);
await _db.SaveChangesAsync();
return Ok();
}
catch (Stripe.StripeException ex)
{
// Signature invalid or event malformed — log warning, do NOT save to DB
_logger.LogWarning("Stripe webhook rejected (invalid signature or payload): {Message}", ex.Message);
return BadRequest("Invalid Stripe signature");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing Stripe webhook {EventType} {EventId}", eventType, eventId);
// Processing failed after signature was verified — record it for debugging
var logEntry = new StripeWebhookEvent
{
EventId = eventId,
EventType = eventType,
RawJson = json,
Status = StripeWebhookEventStatus.Failed,
ReceivedAt = DateTime.UtcNow,
ProcessedAt = DateTime.UtcNow,
ErrorMessage = ex.Message
};
_db.StripeWebhookEvents.Add(logEntry);
await _db.SaveChangesAsync();
return StatusCode(500, "Webhook processing error");
}
}
}
@@ -0,0 +1,630 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using Stripe;
using System.Text;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only controller for viewing and managing tenant company subscriptions.
/// Provides a paginated/filterable company list, per-company subscription editing,
/// trial extension, AI feature flag toggling, bulk operations, and Stripe payment history lookup.
/// All queries use <c>IgnoreQueryFilters()</c> to bypass the global EF multi-tenancy filter,
/// which would otherwise scope results to a single company context.
/// Changes are persisted directly via <see cref="ApplicationDbContext"/> (not IUnitOfWork)
/// because subscription management is a platform-level concern, not a tenant-domain concern,
/// and requires direct access to the Company entity which lives outside the tenant data layer.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class SubscriptionManagementController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IConfiguration _configuration;
private readonly ILogger<SubscriptionManagementController> _logger;
public SubscriptionManagementController(
ApplicationDbContext db,
IConfiguration configuration,
ILogger<SubscriptionManagementController> logger)
{
_db = db;
_configuration = configuration;
_logger = logger;
}
/// <summary>
/// Displays the paginated, sortable, filterable list of all tenant companies with their
/// current subscription state. Also loads all active <c>SubscriptionPlanConfig</c> records
/// so the view can render human-readable plan names in the filter dropdown and data table.
/// Page size is whitelisted to {10, 25, 50, 100} — any other submitted value is normalised
/// to 25 to prevent memory exhaustion from arbitrarily large page requests.
/// <c>IgnoreQueryFilters()</c> is required because deleted companies should be invisible
/// here (excluded by explicit <c>!c.IsDeleted</c>) while active companies from all tenants
/// must all be visible to the SuperAdmin, bypassing the single-tenant global filter.
/// </summary>
/// <param name="search">Optional text filter applied to company name, email, and Stripe customer ID.</param>
/// <param name="status">Optional <see cref="SubscriptionStatus"/> enum name to filter by.</param>
/// <param name="plan">Optional subscription plan integer to filter by.</param>
/// <param name="sortCol">Column name to sort by (default: "CompanyName").</param>
/// <param name="sortDir">Sort direction: "asc" or "desc" (default: "asc").</param>
/// <param name="page">1-based page number (minimum 1).</param>
/// <param name="pageSize">Rows per page; must be 10, 25, 50, or 100.</param>
public async Task<IActionResult> Index(
string? search,
string? status,
string? plan,
string sortCol = "CompanyName",
string sortDir = "asc",
int page = 1,
int pageSize = 25)
{
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
page = Math.Max(1, page);
var query = _db.Companies.AsNoTracking().IgnoreQueryFilters().Where(c => !c.IsDeleted);
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(c =>
c.CompanyName.Contains(search) ||
(c.PrimaryContactEmail != null && c.PrimaryContactEmail.Contains(search)) ||
(c.StripeCustomerId != null && c.StripeCustomerId.Contains(search)));
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<SubscriptionStatus>(status, out var statusEnum))
query = query.Where(c => c.SubscriptionStatus == statusEnum);
if (!string.IsNullOrWhiteSpace(plan) && int.TryParse(plan, out var planInt))
query = query.Where(c => c.SubscriptionPlan == planInt);
query = (sortCol, sortDir) switch
{
("CompanyName", "desc") => query.OrderByDescending(c => c.CompanyName),
("Plan", "asc") => query.OrderBy(c => c.SubscriptionPlan),
("Plan", "desc") => query.OrderByDescending(c => c.SubscriptionPlan),
("Status", "asc") => query.OrderBy(c => c.SubscriptionStatus),
("Status", "desc") => query.OrderByDescending(c => c.SubscriptionStatus),
("EndDate", "asc") => query.OrderBy(c => c.SubscriptionEndDate),
("EndDate", "desc") => query.OrderByDescending(c => c.SubscriptionEndDate),
("Active", "asc") => query.OrderBy(c => c.IsActive),
("Active", "desc") => query.OrderByDescending(c => c.IsActive),
_ => query.OrderBy(c => c.CompanyName)
};
var totalCount = await query.CountAsync();
var companies = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
var planConfigs = await _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
.Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToListAsync();
ViewBag.PlanConfigs = planConfigs;
ViewBag.Search = search;
ViewBag.StatusFilter = status;
ViewBag.PlanFilter = plan;
ViewBag.SortCol = sortCol;
ViewBag.SortDir = sortDir;
ViewBag.Page = page;
ViewBag.PageSize = pageSize;
ViewBag.TotalCount = totalCount;
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
return View(companies);
}
/// <summary>
/// Renders the per-company subscription management detail page.
/// Loads current usage counts (users, jobs, customers) so the SuperAdmin can see whether
/// the company is approaching plan limits before adjusting the subscription.
/// Also loads all active plan configs so the plan dropdown is populated correctly.
/// </summary>
/// <param name="id">Primary key of the company to manage.</param>
public async Task<IActionResult> Manage(int id)
{
var company = await _db.Companies.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id);
if (company == null) return NotFound();
var planConfigs = await _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
.Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToListAsync();
var userCount = await _db.Users.CountAsync(u => u.CompanyId == id);
var jobCount = await _db.Jobs.IgnoreQueryFilters().CountAsync(j => j.CompanyId == id && !j.IsDeleted);
var customerCount = await _db.Customers.IgnoreQueryFilters().CountAsync(c => c.CompanyId == id && !c.IsDeleted);
ViewBag.PlanConfigs = planConfigs;
ViewBag.UserCount = userCount;
ViewBag.JobCount = jobCount;
ViewBag.CustomerCount = customerCount;
return View(company);
}
/// <summary>
/// Saves subscription changes for a single company and writes a manual entry to the
/// <c>AuditLogs</c> table describing exactly what changed.
/// Per-entity limit overrides are stored as nullable integers; a form value of 0 is
/// converted to <c>null</c> via <see cref="NullIfZero"/> so the system falls back to the
/// plan default rather than enforcing a hard zero limit.
/// The audit log is written via raw SQL (not EF) to avoid routing audit writes through
/// the multi-tenant <c>ApplicationDbContext</c> save pipeline, which applies global filters
/// and could inadvertently scope the log entry to the wrong tenant.
/// </summary>
/// <param name="id">Primary key of the company to update.</param>
/// <param name="subscriptionPlan">New plan integer identifier.</param>
/// <param name="subscriptionStatus">New <see cref="SubscriptionStatus"/> value.</param>
/// <param name="isActive">Whether the company account is active.</param>
/// <param name="isComped">Whether the company receives complimentary (free) access.</param>
/// <param name="subscriptionEndDate">New subscription end/expiry date.</param>
/// <param name="subscriptionNotes">Internal notes visible only to SuperAdmins.</param>
/// <param name="notes">Reason for this change; appended to the audit log entry.</param>
/// <param name="maxUsersOverride">Per-company user limit override; 0 = use plan default.</param>
/// <param name="maxActiveJobsOverride">Per-company active jobs limit override; 0 = use plan default.</param>
/// <param name="maxCustomersOverride">Per-company customer limit override; 0 = use plan default.</param>
/// <param name="maxQuotesOverride">Per-company monthly quote limit override; 0 = use plan default.</param>
/// <param name="maxCatalogItemsOverride">Per-company catalog item limit override; 0 = use plan default.</param>
/// <param name="maxJobPhotosOverride">Per-company job photo limit override; 0 = use plan default.</param>
/// <param name="maxQuotePhotosOverride">Per-company quote photo limit override; 0 = use plan default.</param>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateSubscription(int id,
int subscriptionPlan,
SubscriptionStatus subscriptionStatus,
bool isActive,
bool isComped,
DateTime? subscriptionEndDate,
string? subscriptionNotes,
string? notes,
int? maxUsersOverride,
int? maxActiveJobsOverride,
int? maxCustomersOverride,
int? maxQuotesOverride,
int? maxCatalogItemsOverride,
int? maxJobPhotosOverride,
int? maxQuotePhotosOverride)
{
var company = await _db.Companies.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id);
if (company == null) return NotFound();
var userName = User.Identity?.Name ?? "SuperAdmin";
// Build audit description of changes
var changes = new List<string>();
if (company.SubscriptionPlan != subscriptionPlan)
changes.Add($"Plan: {company.SubscriptionPlan} → {subscriptionPlan}");
if (company.SubscriptionStatus != subscriptionStatus)
changes.Add($"Status: {company.SubscriptionStatus} → {subscriptionStatus}");
if (company.IsActive != isActive)
changes.Add($"Active: {company.IsActive} → {isActive}");
if (company.IsComped != isComped)
changes.Add($"Comped: {company.IsComped} → {isComped}");
if (company.SubscriptionEndDate != subscriptionEndDate)
changes.Add($"EndDate: {company.SubscriptionEndDate:d} → {subscriptionEndDate:d}");
company.SubscriptionPlan = subscriptionPlan;
company.SubscriptionStatus = subscriptionStatus;
company.IsActive = isActive;
company.IsComped = isComped;
company.SubscriptionEndDate = subscriptionEndDate;
company.SubscriptionNotes = subscriptionNotes;
company.MaxUsersOverride = NullIfZero(maxUsersOverride);
company.MaxActiveJobsOverride = NullIfZero(maxActiveJobsOverride);
company.MaxCustomersOverride = NullIfZero(maxCustomersOverride);
company.MaxQuotesOverride = NullIfZero(maxQuotesOverride);
company.MaxCatalogItemsOverride = NullIfZero(maxCatalogItemsOverride);
company.MaxJobPhotosOverride = NullIfZero(maxJobPhotosOverride);
company.MaxQuotePhotosOverride = NullIfZero(maxQuotePhotosOverride);
company.UpdatedAt = DateTime.UtcNow;
company.UpdatedBy = userName;
await _db.SaveChangesAsync();
// Write a manual audit entry
if (changes.Count > 0)
{
var summary = string.Join("; ", changes);
if (!string.IsNullOrWhiteSpace(notes)) summary += $". Notes: {notes}";
await _db.Database.ExecuteSqlRawAsync("""
INSERT INTO AuditLogs (UserId, UserName, CompanyId, CompanyName, Action, EntityType, EntityId,
EntityDescription, OldValues, NewValues, IpAddress, Timestamp)
VALUES ({0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11})
""",
(object?)User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value,
userName,
(object?)null,
(object?)null,
"ManualChange",
"Company",
id.ToString(),
company.CompanyName,
(object?)null,
summary,
(object?)HttpContext.Connection.RemoteIpAddress?.ToString(),
DateTime.UtcNow);
}
TempData["Success"] = $"Subscription updated for {company.CompanyName}.";
return RedirectToAction(nameof(Manage), new { id });
}
/// <summary>
/// Extends a single company's subscription end date by the specified number of days and
/// ensures the account is marked Active with a status of <see cref="SubscriptionStatus.Active"/>.
/// Extension is calculated from the later of the existing end date or now, so adding days
/// to an already-future date compounds correctly rather than resetting to today + N days.
/// This covers common support scenarios such as granting a grace period to a customer whose
/// payment is late but who has contacted support.
/// </summary>
/// <param name="id">Primary key of the company to extend.</param>
/// <param name="days">Number of days to add to the current end date (or today if already expired).</param>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ExtendTrial(int id, int days)
{
var company = await _db.Companies.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id);
if (company == null) return NotFound();
var baseDate = company.SubscriptionEndDate.HasValue && company.SubscriptionEndDate > DateTime.UtcNow
? company.SubscriptionEndDate.Value
: DateTime.UtcNow;
company.SubscriptionEndDate = baseDate.AddDays(days);
company.SubscriptionStatus = SubscriptionStatus.Active;
company.IsActive = true;
company.UpdatedAt = DateTime.UtcNow;
company.UpdatedBy = User.Identity?.Name;
await _db.SaveChangesAsync();
TempData["Success"] = $"Extended subscription by {days} days. New end date: {company.SubscriptionEndDate:d}.";
return RedirectToAction(nameof(Manage), new { id });
}
/// <summary>
/// AJAX endpoint that toggles a named boolean feature on a <c>SubscriptionPlanConfig</c> record.
/// Currently only <c>AllowOnlinePayments</c> is supported; unknown feature names return
/// <c>400 Bad Request</c> to prevent silent no-ops from misspelled feature identifiers.
/// Returns JSON so the Manage view can update the UI toggle without a full page reload.
/// </summary>
/// <param name="planId">Primary key of the <c>SubscriptionPlanConfig</c> to update.</param>
/// <param name="feature">Feature name string (e.g. "AllowOnlinePayments").</param>
/// <param name="enabled">New boolean value for the feature flag.</param>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> TogglePlanFeature(int planId, string feature, bool enabled)
{
var config = await _db.SubscriptionPlanConfigs.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.Id == planId);
if (config == null) return NotFound();
switch (feature)
{
case "AllowOnlinePayments":
config.AllowOnlinePayments = enabled;
break;
default:
return BadRequest("Unknown feature.");
}
config.UpdatedAt = DateTime.UtcNow;
_db.Update(config);
await _db.SaveChangesAsync();
_logger.LogInformation("Plan {PlanId} ({Name}): {Feature} set to {Value} by {User}",
planId, config.DisplayName, feature, enabled, User.Identity?.Name);
return Json(new { success = true });
}
// ── Bulk Operations ───────────────────────────────────────────────────
/// <summary>
/// Extends the subscription end date for multiple companies in a single operation,
/// applying the same day-extension logic as <see cref="ExtendTrial"/>: dates in the future
/// are extended from their current end date; expired dates are extended from today.
/// Validates that at least one company is selected and that the day count is positive
/// before issuing any database writes, to prevent accidental empty or no-op submissions.
/// </summary>
/// <param name="ids">Array of company primary keys to extend.</param>
/// <param name="days">Number of days to add to each company's end date.</param>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> BulkExtendTrial(int[] ids, int days)
{
if (ids.Length == 0 || days <= 0)
{
TempData["Error"] = "Select at least one company and a valid number of days.";
return RedirectToAction(nameof(Index));
}
var companies = await _db.Companies.IgnoreQueryFilters()
.Where(c => ids.Contains(c.Id)).ToListAsync();
foreach (var company in companies)
{
var baseDate = company.SubscriptionEndDate.HasValue && company.SubscriptionEndDate > DateTime.UtcNow
? company.SubscriptionEndDate.Value
: DateTime.UtcNow;
company.SubscriptionEndDate = baseDate.AddDays(days);
company.SubscriptionStatus = SubscriptionStatus.Active;
company.IsActive = true;
company.UpdatedAt = DateTime.UtcNow;
company.UpdatedBy = User.Identity?.Name;
}
await _db.SaveChangesAsync();
TempData["Success"] = $"Extended {companies.Count} company subscription(s) by {days} days.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Activates or deactivates multiple companies in a single batch.
/// Deactivating a company prevents its users from logging in (the subscription middleware
/// and login pipeline check <c>Company.IsActive</c>).
/// Requires at least one company to be selected; an empty array returns an error to prevent
/// accidental empty-selection submissions from having no visible effect.
/// </summary>
/// <param name="ids">Array of company primary keys to toggle.</param>
/// <param name="active">Target active state: <c>true</c> to activate, <c>false</c> to deactivate.</param>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> BulkToggleActive(int[] ids, bool active)
{
if (ids.Length == 0)
{
TempData["Error"] = "Select at least one company.";
return RedirectToAction(nameof(Index));
}
var companies = await _db.Companies.IgnoreQueryFilters()
.Where(c => ids.Contains(c.Id)).ToListAsync();
foreach (var company in companies)
{
company.IsActive = active;
company.UpdatedAt = DateTime.UtcNow;
company.UpdatedBy = User.Identity?.Name;
}
await _db.SaveChangesAsync();
TempData["Success"] = $"{(active ? "Activated" : "Deactivated")} {companies.Count} company(ies).";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Exports the company subscription list to a CSV file, applying the same search, status,
/// and plan filters as the <see cref="Index"/> view but without pagination.
/// Plan names are resolved from the <c>SubscriptionPlanConfig</c> lookup table so the CSV
/// contains human-readable plan names (e.g. "Professional") rather than raw integer plan codes.
/// The CSV is returned as a byte array (not a stream) to avoid partial-write issues on slow clients.
/// </summary>
/// <param name="search">Optional text filter applied to company name and email.</param>
/// <param name="status">Optional <see cref="SubscriptionStatus"/> enum name to filter by.</param>
/// <param name="plan">Optional subscription plan integer to filter by.</param>
public async Task<IActionResult> ExportCsv(string? search, string? status, string? plan)
{
var query = _db.Companies.AsNoTracking().IgnoreQueryFilters().Where(c => !c.IsDeleted);
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(c => c.CompanyName.Contains(search) ||
(c.PrimaryContactEmail != null && c.PrimaryContactEmail.Contains(search)));
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<SubscriptionStatus>(status, out var statusEnum))
query = query.Where(c => c.SubscriptionStatus == statusEnum);
if (!string.IsNullOrWhiteSpace(plan) && int.TryParse(plan, out var planInt))
query = query.Where(c => c.SubscriptionPlan == planInt);
var planConfigs = await _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
.Where(p => p.IsActive).ToListAsync();
var planNames = planConfigs.ToDictionary(p => p.Plan, p => p.DisplayName);
var companies = await query.OrderBy(c => c.CompanyName).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("Company,Contact,Email,Phone,Plan,Status,Active,Comped,End Date,Stripe Customer,Created");
foreach (var c in companies)
{
sb.AppendLine(string.Join(",",
CsvEscape(c.CompanyName),
CsvEscape(c.PrimaryContactName),
CsvEscape(c.PrimaryContactEmail),
CsvEscape(c.Phone ?? ""),
CsvEscape(planNames.GetValueOrDefault(c.SubscriptionPlan, c.SubscriptionPlan.ToString())),
c.SubscriptionStatus.ToString(),
c.IsActive ? "Yes" : "No",
c.IsComped ? "Yes" : "No",
c.SubscriptionEndDate.HasValue ? c.SubscriptionEndDate.Value.ToString("MM/dd/yyyy") : "",
CsvEscape(c.StripeCustomerId ?? ""),
c.CreatedAt.ToString("MM/dd/yyyy")));
}
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
return File(bytes, "text/csv", $"companies-{DateTime.UtcNow:yyyyMMdd}.csv");
}
/// <summary>
/// RFC 4180-compliant CSV field escaper for string values.
/// Wraps the value in double-quotes and escapes embedded double-quotes when the value
/// contains a comma, double-quote, or newline. Unlike the overload in the export controllers,
/// this version accepts only non-null strings because all callers in this controller
/// guarantee non-null input (using null-coalescing before calling).
/// </summary>
private static string CsvEscape(string value) =>
value.Contains(',') || value.Contains('"') || value.Contains('\n')
? $"\"{value.Replace("\"", "\"\"")}\""
: value;
/// <summary>
/// Updates per-company AI feature flags for a single tenant company.
/// Feature flags control which AI capabilities are enabled beyond the plan default
/// (e.g., AI photo quoting, AI inventory assist). The monthly photo quote override allows
/// granting a specific company more AI calls than their plan tier normally permits.
/// Zero is treated as "use plan default" via <see cref="NullIfZero"/>, matching the same
/// convention used for the limit overrides in <see cref="UpdateSubscription"/>.
/// </summary>
/// <param name="id">Primary key of the company to update.</param>
/// <param name="aiPhotoQuotesEnabled">Whether AI photo quoting is enabled for this company.</param>
/// <param name="aiInventoryAssistEnabled">Whether AI inventory assistance is enabled for this company.</param>
/// <param name="maxAiPhotoQuotesPerMonthOverride">Monthly AI photo quote limit override; 0 = plan default.</param>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateFeatureFlags(
int id,
bool aiPhotoQuotesEnabled,
bool aiInventoryAssistEnabled,
int? maxAiPhotoQuotesPerMonthOverride)
{
var company = await _db.Companies.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id);
if (company == null) return NotFound();
company.AiPhotoQuotesEnabled = aiPhotoQuotesEnabled;
company.AiInventoryAssistEnabled = aiInventoryAssistEnabled;
company.MaxAiPhotoQuotesPerMonthOverride = NullIfZero(maxAiPhotoQuotesPerMonthOverride);
company.UpdatedAt = DateTime.UtcNow;
company.UpdatedBy = User.Identity?.Name;
await _db.SaveChangesAsync();
TempData["Success"] = $"Feature flags updated for {company.CompanyName}.";
return RedirectToAction(nameof(Manage), new { id });
}
/// <summary>
/// Returns the last 24 Stripe charges for a company's Stripe customer as JSON,
/// allowing the Manage view to render a payment history panel (with refund buttons) via AJAX.
/// Charges are used rather than invoices because in Stripe.net 50.x the Invoice object no
/// longer exposes <c>PaymentIntentId</c> or a direct <c>Charge</c> reference — the new
/// <c>Invoice.Payments</c> collection requires additional expansion round-trips.
/// The Charge object has <c>Amount</c>, <c>AmountRefunded</c>, <c>ReceiptUrl</c>, and
/// <c>Id</c> (the charge ID used as the refund target), which is everything the refund
/// workflow needs. The API key is read at call time to support key rotation without a restart.
/// Returns a JSON error object (not an HTTP error status) on failures.
/// </summary>
/// <param name="id">Primary key of the company whose Stripe charge history to fetch.</param>
public async Task<IActionResult> PaymentHistory(int id)
{
var company = await _db.Companies.IgnoreQueryFilters().AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == id);
if (company == null) return NotFound();
if (string.IsNullOrEmpty(company.StripeCustomerId))
return Json(new { error = "No Stripe customer ID on record for this company." });
try
{
StripeConfiguration.ApiKey = _configuration["Stripe:SecretKey"];
var chargeService = new ChargeService();
var charges = await chargeService.ListAsync(new ChargeListOptions
{
Customer = company.StripeCustomerId,
Limit = 24
});
var rows = charges.Data.Select(ch =>
{
var amountCents = ch.Amount;
var amountRefundedCents = ch.AmountRefunded;
var refundableCents = Math.Max(0L, amountCents - amountRefundedCents);
return new
{
id = ch.Id,
created = ch.Created.ToString("MM/dd/yyyy"),
amount = (amountCents / 100m).ToString("C"),
amountRefunded = amountRefundedCents > 0 ? (amountRefundedCents / 100m).ToString("C") : (string?)null,
refundableCents,
status = ch.Status,
receipt = ch.ReceiptUrl,
description = ch.Description
};
});
return Json(new { success = true, charges = rows });
}
catch (StripeException ex)
{
_logger.LogWarning(ex, "Stripe charge lookup failed for company {Id}", id);
return Json(new { error = ex.StripeError?.Message ?? ex.Message });
}
}
/// <summary>
/// Issues a full or partial refund against a Stripe subscription payment on behalf of a
/// SuperAdmin. The refund is created via Stripe's <c>RefundService</c> against the invoice's
/// <c>chargeId</c> (Stripe Charge ID); partial refunds are supported by passing an explicit
/// <paramref name="amount"/> in dollars (converted to cents for Stripe).
/// The action is written to <c>AuditLogs</c> with the Stripe refund ID so there is a
/// durable record of who authorized the refund and when. Returns JSON so the Manage view
/// can show an inline result without a full page reload.
/// </summary>
/// <param name="id">Company primary key — used for audit logging only.</param>
/// <param name="chargeId">Stripe Charge ID for the payment being refunded.</param>
/// <param name="amount">Refund amount in dollars; must be &gt; 0 and ≤ remaining refundable amount.</param>
/// <param name="reason">Human-readable reason recorded in the audit log.</param>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> IssueSubscriptionRefund(int id, string chargeId, decimal amount, string reason)
{
var company = await _db.Companies.IgnoreQueryFilters().AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == id);
if (company == null) return Json(new { error = "Company not found." });
if (string.IsNullOrWhiteSpace(chargeId))
return Json(new { error = "Charge ID is required." });
if (amount <= 0)
return Json(new { error = "Refund amount must be greater than zero." });
try
{
StripeConfiguration.ApiKey = _configuration["Stripe:SecretKey"];
var refundService = new RefundService();
var refund = await refundService.CreateAsync(new RefundCreateOptions
{
Charge = chargeId,
Amount = (long)Math.Round(amount * 100), // dollars → cents
Reason = RefundReasons.RequestedByCustomer
});
var userName = User.Identity?.Name ?? "SuperAdmin";
_logger.LogInformation(
"SuperAdmin {User} issued {Amount} subscription refund for company {CompanyId} ({CompanyName}). " +
"ChargeId={ChargeId} StripeRefundId={RefundId} Reason={Reason}",
userName, amount.ToString("C"), id, company.CompanyName,
chargeId, refund.Id, reason);
// Durable audit trail so the action is visible without opening Stripe
await _db.Database.ExecuteSqlRawAsync("""
INSERT INTO AuditLogs (UserId, UserName, CompanyId, CompanyName, Action, EntityType, EntityId,
EntityDescription, OldValues, NewValues, IpAddress, Timestamp)
VALUES ({0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11})
""",
(object?)User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value,
userName,
(object?)null,
(object?)null,
"SubscriptionRefund",
"Company",
id.ToString(),
company.CompanyName,
(object?)null,
$"Issued {amount:C} subscription refund. Reason: {reason}. Stripe Charge: {chargeId}. Stripe Refund ID: {refund.Id}.",
(object?)HttpContext.Connection.RemoteIpAddress?.ToString(),
DateTime.UtcNow);
return Json(new { success = true, refundId = refund.Id, amountFormatted = amount.ToString("C") });
}
catch (StripeException ex)
{
_logger.LogWarning(ex, "Stripe refund failed for company {Id}: {Message}", id, ex.StripeError?.Message);
return Json(new { error = ex.StripeError?.Message ?? ex.Message });
}
}
/// <summary>
/// Converts 0 to <c>null</c> for nullable integer limit override fields.
/// HTML number inputs cannot represent <c>null</c> — they submit 0 when left empty — so
/// this helper normalises 0 back to <c>null</c>, which the application interprets as
/// "use the plan's default limit" rather than "enforce a hard zero limit".
/// Any non-zero value passes through unchanged.
/// </summary>
/// <param name="value">Raw form-submitted integer override value.</param>
/// <returns><c>null</c> when <paramref name="value"/> is 0; otherwise the original value.</returns>
private static int? NullIfZero(int? value) => value == 0 ? null : value;
}
@@ -0,0 +1,134 @@
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using System.Reflection;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only system health and runtime information page.
/// Aggregates application version, database connectivity, migration state,
/// seed history, user counts, and host environment details into a single view
/// to give platform operators a quick operational snapshot without needing
/// server or database console access.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class SystemInfoController : Controller
{
private readonly ApplicationDbContext _dbContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IWebHostEnvironment _env;
public SystemInfoController(
ApplicationDbContext dbContext,
UserManager<ApplicationUser> userManager,
IWebHostEnvironment env)
{
_dbContext = dbContext;
_userManager = userManager;
_env = env;
}
/// <summary>
/// Gathers and renders runtime system information into a
/// <see cref="SystemInfoViewModel"/>.
/// <para>
/// Key design decisions:
/// <list type="bullet">
/// <item>The informational version string (from
/// <see cref="AssemblyInformationalVersionAttribute"/>) is preferred over the
/// numeric version because it can include Git commit hashes or pre-release labels
/// set during CI builds.</item>
/// <item>Database connectivity is tested via <c>CanConnectAsync()</c> rather than
/// a raw query to keep the check lightweight and independent of schema state.</item>
/// <item>Applied migrations are listed (not pending) so the view can report the
/// last applied migration without needing write access to the migration history.</item>
/// <item><c>SeedData.LastSeedRun</c> is a static field updated by the seed service
/// each time seeding completes; it shows <c>null</c> if no seed has run since
/// the last application restart.</item>
/// <item>Server time is reported in both local and UTC to help operators diagnose
/// timezone-related scheduling issues.</item>
/// </list>
/// </para>
/// </summary>
public async Task<IActionResult> Index()
{
// App version from assembly
var assembly = Assembly.GetEntryAssembly();
var version = assembly?.GetName().Version?.ToString() ?? "Unknown";
var infoVersion = assembly?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion ?? version;
// Database connectivity
bool dbConnected;
string dbStatus;
string? lastMigration = null;
int migrationCount = 0;
try
{
dbConnected = await _dbContext.Database.CanConnectAsync();
dbStatus = dbConnected ? "Connected" : "Unreachable";
if (dbConnected)
{
var applied = await _dbContext.Database.GetAppliedMigrationsAsync();
var migrations = applied.ToList();
migrationCount = migrations.Count;
lastMigration = migrations.LastOrDefault();
}
}
catch (Exception ex)
{
dbConnected = false;
dbStatus = $"Error: {ex.Message}";
}
// User counts
var totalUsers = await _userManager.Users.CountAsync();
var activeUsers = await _userManager.Users.CountAsync(u => u.IsActive);
var vm = new SystemInfoViewModel
{
AppVersion = infoVersion,
DatabaseConnected = dbConnected,
DatabaseStatus = dbStatus,
LastAppliedMigration = lastMigration,
MigrationCount = migrationCount,
LastSeedRun = SeedData.LastSeedRun,
ServerTime = DateTime.Now,
ServerTimeUtc = DateTime.UtcNow,
ServerTimeZone = TimeZoneInfo.Local.DisplayName,
ActiveUserCount = activeUsers,
TotalUserCount = totalUsers,
EnvironmentName = _env.EnvironmentName,
MachineName = Environment.MachineName,
OsDescription = System.Runtime.InteropServices.RuntimeInformation.OSDescription,
RuntimeVersion = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription
};
return View(vm);
}
}
public class SystemInfoViewModel
{
public string AppVersion { get; set; } = string.Empty;
public bool DatabaseConnected { get; set; }
public string DatabaseStatus { get; set; } = string.Empty;
public string? LastAppliedMigration { get; set; }
public int MigrationCount { get; set; }
public DateTime? LastSeedRun { get; set; }
public DateTime ServerTime { get; set; }
public DateTime ServerTimeUtc { get; set; }
public string ServerTimeZone { get; set; } = string.Empty;
public int ActiveUserCount { get; set; }
public int TotalUserCount { get; set; }
public string EnvironmentName { get; set; } = string.Empty;
public string MachineName { get; set; } = string.Empty;
public string OsDescription { get; set; } = string.Empty;
public string RuntimeVersion { get; set; } = string.Empty;
}
@@ -0,0 +1,413 @@
using Azure.Identity;
using Azure.Monitor.Query;
using Azure.Monitor.Query.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only viewer for structured application logs.
///
/// In production (when <c>ApplicationInsights:WorkspaceId</c> is configured) logs are queried
/// from the Azure Monitor / Application Insights workspace via <c>LogsQueryClient</c> using
/// <c>DefaultAzureCredential</c> (Managed Identity on App Service; developer credentials locally).
///
/// In development (or when the AI workspace is not configured) the controller falls back to
/// querying the Serilog <c>SystemLogs</c> SQL table via raw ADO.NET, exactly as before.
///
/// Both paths populate the same <see cref="SystemLogRow"/> model so the view is unchanged.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class SystemLogsController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IConfiguration _configuration;
private readonly ILogger<SystemLogsController> _logger;
public SystemLogsController(
ApplicationDbContext db,
IConfiguration configuration,
ILogger<SystemLogsController> logger)
{
_db = db;
_configuration = configuration;
_logger = logger;
}
/// <summary>
/// Renders a paginated, filterable view of application logs.
/// Routes to <see cref="QueryApplicationInsightsAsync"/> when an AI workspace is configured,
/// or <see cref="QuerySqlTableAsync"/> otherwise.
/// </summary>
public async Task<IActionResult> Index(
string? search,
string? level,
string? source,
DateTime? from,
DateTime? to,
int page = 1,
int pageSize = 50)
{
pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50;
page = Math.Max(1, page);
// Prefer resource-based querying (works for both classic and workspace-based AI resources).
// Fall back to workspace-based querying if only WorkspaceId is configured (legacy).
var resourceId = _configuration["ApplicationInsights:ResourceId"];
var workspaceId = _configuration["ApplicationInsights:WorkspaceId"];
var useAi = !string.IsNullOrWhiteSpace(resourceId) || !string.IsNullOrWhiteSpace(workspaceId);
ViewBag.UseAi = useAi;
ViewBag.Search = search;
ViewBag.Level = level;
ViewBag.Source = source;
ViewBag.From = from?.ToString("yyyy-MM-dd");
ViewBag.To = to?.ToString("yyyy-MM-dd");
ViewBag.Page = page;
ViewBag.PageSize = pageSize;
List<SystemLogRow> items;
int totalCount;
if (useAi)
(items, totalCount) = await QueryApplicationInsightsAsync(resourceId, workspaceId, search, level, source, from, to, page, pageSize);
else
(items, totalCount) = await QuerySqlTableAsync(search, level, source, from, to, page, pageSize);
ViewBag.TotalCount = totalCount;
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
return View(items);
}
// ── Application Insights path ─────────────────────────────────────────────
/// <summary>
/// Queries the Application Insights <c>traces</c> table via KQL using
/// <c>DefaultAzureCredential</c>. On Azure App Service this resolves to Managed Identity
/// automatically; in local dev it uses Azure CLI / Visual Studio credentials.
///
/// Prefers <c>QueryResourceAsync</c> with the AI resource ID (works for both classic and
/// workspace-based resources). Falls back to <c>QueryWorkspaceAsync</c> if only a workspace
/// ID is configured.
///
/// Serilog writes all structured properties (SourceContext, UserName, CompanyId, Exception)
/// into the <c>customDimensions</c> bag. The KQL query extends them into named columns so
/// they map cleanly onto <see cref="SystemLogRow"/>.
///
/// Pagination is approximated: a separate count query runs first (capped at 10 000 to
/// avoid expensive full scans), then the data query uses <c>| skip / | take</c>.
/// </summary>
private async Task<(List<SystemLogRow> items, int totalCount)> QueryApplicationInsightsAsync(
string? resourceId, string? workspaceId, string? search, string? level, string? source,
DateTime? from, DateTime? to, int page, int pageSize)
{
var items = new List<SystemLogRow>();
var totalCount = 0;
try
{
var client = new LogsQueryClient(new DefaultAzureCredential());
var timeRange = BuildTimeRange(from, to);
var filters = BuildKqlFilters(search, level, source);
// Count query (capped — full scans on large workspaces are expensive)
var countKql = $"""
traces
| where severityLevel >= 2
{filters}
| count
""";
// Data query — fetch offset+pageSize rows and skip client-side.
// The Application Insights query API does not support the KQL `skip` operator,
// so we over-fetch and discard the leading rows in C#.
var offset = (page - 1) * pageSize;
var dataKql = $"""
traces
| where severityLevel >= 2
{filters}
| extend
Level = case(severityLevel == 2, "Warning",
severityLevel == 3, "Error",
"Fatal"),
SourceContext = tostring(customDimensions["SourceContext"]),
UserName = tostring(customDimensions["UserName"]),
CompanyIdStr = tostring(customDimensions["CompanyId"]),
ExceptionText = tostring(customDimensions["Exception"])
| project Timestamp = timestamp, Level, SourceContext, Message = message, ExceptionText, UserName, CompanyIdStr
| order by Timestamp desc
| take {offset + pageSize}
""";
if (!string.IsNullOrWhiteSpace(resourceId))
{
// Resource-based query — works for both classic and workspace-based AI resources.
var rid = new Azure.Core.ResourceIdentifier(resourceId);
var countResult = await client.QueryResourceAsync<long>(rid, countKql, timeRange);
totalCount = (int)Math.Min(countResult.Value.FirstOrDefault(), 10_000);
var dataResult = await client.QueryResourceAsync<AiTraceRow>(rid, dataKql, timeRange);
foreach (var row in dataResult.Value.Skip(offset))
{
items.Add(MapAiRow(row));
}
}
else
{
// Workspace-based query (legacy fallback).
var countResult = await client.QueryWorkspaceAsync<long>(workspaceId!, countKql, timeRange);
totalCount = (int)Math.Min(countResult.Value.FirstOrDefault(), 10_000);
var dataResult = await client.QueryWorkspaceAsync<AiTraceRow>(workspaceId!, dataKql, timeRange);
foreach (var row in dataResult.Value.Skip(offset))
{
items.Add(MapAiRow(row));
}
}
}
catch (Exception ex)
{
// SEM0100 specifically means the "traces" table doesn't exist yet in the workspace —
// it is created automatically on first ingestion, typically within a few minutes.
// Any other exception (auth failures, wrong workspace ID, network errors) shows the
// real message so it can be diagnosed rather than silently swallowed.
if (ex.Message.Contains("SEM0100"))
{
ViewBag.QueryError = "No log data yet — the Application Insights traces table is created automatically " +
"after the first Warning or Error is written. Check back in a few minutes.";
}
else
{
_logger.LogError(ex, "Error querying Application Insights workspace {WorkspaceId}", workspaceId);
ViewBag.QueryError = $"Error loading logs from Application Insights: {ex.Message}";
}
}
return (items, totalCount);
}
/// <summary>
/// Builds a KQL time range from optional date filters.
/// Defaults to the last 7 days when no date is specified, matching the AI portal default.
/// </summary>
private static QueryTimeRange BuildTimeRange(DateTime? from, DateTime? to)
{
if (from.HasValue && to.HasValue)
return new QueryTimeRange(from.Value.ToUniversalTime(), to.Value.ToUniversalTime().AddDays(1));
if (from.HasValue)
return new QueryTimeRange(from.Value.ToUniversalTime(), DateTimeOffset.UtcNow);
if (to.HasValue)
return new QueryTimeRange(DateTimeOffset.UtcNow.AddDays(-90), to.Value.ToUniversalTime().AddDays(1));
return new QueryTimeRange(TimeSpan.FromDays(7));
}
/// <summary>
/// Builds KQL filter lines from the optional search/level/source parameters.
/// Each line is a separate <c>| where</c> clause appended to the base query.
/// </summary>
private static string BuildKqlFilters(string? search, string? level, string? source)
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(search))
parts.Add($"| where message contains \"{EscapeKql(search)}\" or tostring(customDimensions[\"Exception\"]) contains \"{EscapeKql(search)}\"");
if (!string.IsNullOrWhiteSpace(level))
{
var severity = level switch
{
"Warning" => "2",
"Error" => "3",
"Fatal" => "4",
_ => "2"
};
parts.Add($"| where severityLevel == {severity}");
}
if (!string.IsNullOrWhiteSpace(source))
parts.Add($"| where tostring(customDimensions[\"SourceContext\"]) contains \"{EscapeKql(source)}\"");
return string.Join("\n", parts);
}
/// <summary>
/// Escapes a user-supplied string for safe embedding inside a KQL string literal.
/// Backslashes and double-quotes are the only special characters in KQL string literals.
/// </summary>
private static string EscapeKql(string value) =>
value.Replace("\\", "\\\\").Replace("\"", "\\\"");
private static SystemLogRow MapAiRow(AiTraceRow row) => new()
{
Id = 0,
Timestamp = row.Timestamp.UtcDateTime,
Level = row.Level ?? "Information",
SourceContext = NullIfEmpty(row.SourceContext),
Message = row.Message ?? "",
Exception = NullIfEmpty(row.ExceptionText),
UserName = NullIfEmpty(row.UserName),
CompanyId = int.TryParse(row.CompanyIdStr, out var cid) ? cid : null,
RemoteIP = null
};
private static string? NullIfEmpty(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value;
// ── SQL fallback path (dev / no AI configured) ────────────────────────────
/// <summary>
/// Queries the Serilog <c>SystemLogs</c> SQL table via raw ADO.NET.
/// Used in development or when no Application Insights workspace ID is configured.
/// An idempotent <c>ALTER TABLE</c> ensures the <c>SourceContext</c> column exists on
/// databases created before that column was added to the sink configuration.
/// </summary>
private async Task<(List<SystemLogRow> items, int totalCount)> QuerySqlTableAsync(
string? search, string? level, string? source,
DateTime? from, DateTime? to, int page, int pageSize)
{
var items = new List<SystemLogRow>();
var totalCount = 0;
try
{
var connStr = _db.Database.GetConnectionString()!;
// Ensure SourceContext column exists (added after initial deployment)
await using (var ensureConn = new SqlConnection(connStr))
{
await ensureConn.OpenAsync();
await using var ensureCmd = new SqlCommand("""
IF NOT EXISTS (
SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('SystemLogs') AND name = 'SourceContext'
)
ALTER TABLE SystemLogs ADD SourceContext NVARCHAR(512) NULL
""", ensureConn);
await ensureCmd.ExecuteNonQueryAsync();
}
var conditions = new List<string>();
var sqlParams = new List<SqlParameter>();
if (!string.IsNullOrWhiteSpace(search))
{
conditions.Add("(Message LIKE @search OR Exception LIKE @search)");
sqlParams.Add(new SqlParameter("@search", $"%{search}%"));
}
if (!string.IsNullOrWhiteSpace(level))
{
conditions.Add("Level = @level");
sqlParams.Add(new SqlParameter("@level", level));
}
if (!string.IsNullOrWhiteSpace(source))
{
conditions.Add("SourceContext LIKE @source");
sqlParams.Add(new SqlParameter("@source", $"%{source}%"));
}
if (from.HasValue)
{
conditions.Add("Timestamp >= @from");
sqlParams.Add(new SqlParameter("@from", from.Value.ToUniversalTime()));
}
if (to.HasValue)
{
conditions.Add("Timestamp < @to");
sqlParams.Add(new SqlParameter("@to", to.Value.ToUniversalTime().AddDays(1)));
}
var where = conditions.Count > 0 ? "WHERE " + string.Join(" AND ", conditions) : "";
await using var conn = new SqlConnection(connStr);
await conn.OpenAsync();
await using (var countCmd = new SqlCommand($"SELECT COUNT(*) FROM SystemLogs {where}", conn))
{
foreach (var p in sqlParams) countCmd.Parameters.Add(CloneParam(p));
totalCount = (int)await countCmd.ExecuteScalarAsync();
}
var offset = (page - 1) * pageSize;
var dataSql = $"""
SELECT Id, Timestamp, Level, SourceContext, Message, Exception, UserName, CompanyId, RemoteIP
FROM SystemLogs {where}
ORDER BY Timestamp DESC
OFFSET {offset} ROWS FETCH NEXT {pageSize} ROWS ONLY
""";
await using var dataCmd = new SqlCommand(dataSql, conn);
foreach (var p in sqlParams) dataCmd.Parameters.Add(CloneParam(p));
await using var reader = await dataCmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
items.Add(new SystemLogRow
{
Id = reader.GetInt32(0),
Timestamp = reader.GetDateTime(1),
Level = reader.IsDBNull(2) ? "" : reader.GetString(2),
SourceContext = reader.IsDBNull(3) ? null : reader.GetString(3),
Message = reader.IsDBNull(4) ? "" : reader.GetString(4),
Exception = reader.IsDBNull(5) ? null : reader.GetString(5),
UserName = reader.IsDBNull(6) ? null : reader.GetString(6),
CompanyId = reader.IsDBNull(7) ? null : reader.GetInt32(7),
RemoteIP = reader.IsDBNull(8) ? null : reader.GetString(8),
});
}
}
catch (Exception ex) when (ex.Message.Contains("Invalid object name 'SystemLogs'"))
{
ViewBag.TableMissing = true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error querying SystemLogs SQL table");
ViewBag.QueryError = "Error loading logs from SQL table.";
}
return (items, totalCount);
}
/// <summary>
/// Clones a <see cref="SqlParameter"/> so the same logical parameter can be added to both
/// the COUNT and data SELECT commands. A single instance cannot be reused across commands.
/// </summary>
private static SqlParameter CloneParam(SqlParameter p) =>
new(p.ParameterName, p.SqlDbType, p.Size) { Value = p.Value };
}
/// <summary>
/// Unified log row used by the view regardless of whether data came from AI or SQL.
/// </summary>
public class SystemLogRow
{
public int Id { get; set; }
public DateTime Timestamp { get; set; }
public string Level { get; set; } = "";
public string? SourceContext { get; set; }
public string Message { get; set; } = "";
public string? Exception { get; set; }
public string? UserName { get; set; }
public int? CompanyId { get; set; }
public string? RemoteIP { get; set; }
}
/// <summary>
/// Strongly-typed projection used by <c>LogsQueryClient.QueryWorkspaceAsync&lt;T&gt;</c>
/// to deserialize each row from the KQL result. Property names must match the KQL column
/// names exactly (case-insensitive). The <c>Message</c> column comes from the Serilog sink's
/// <c>message</c> field; the others are extended from <c>customDimensions</c> in the query.
/// </summary>
internal class AiTraceRow
{
public DateTimeOffset Timestamp { get; set; }
public string? Level { get; set; }
public string? SourceContext { get; set; }
public string? Message { get; set; }
public string? ExceptionText { get; set; }
public string? UserName { get; set; }
public string? CompanyIdStr { get; set; }
}
@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Mvc;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Sets the surface theme preference cookie server-side so it survives page navigation reliably.
/// Client-side document.cookie writes can silently fail on some browser/HTTPS configurations.
/// </summary>
public class ThemeController : Controller
{
[HttpPost]
[IgnoreAntiforgeryToken]
public IActionResult Set([FromForm] string surface)
{
var value = surface == "ink" ? "ink" : "paper";
Response.Cookies.Append("pcl_surface", value, new CookieOptions
{
Path = "/",
MaxAge = TimeSpan.FromDays(365),
SameSite = SameSiteMode.Lax,
HttpOnly = false
});
return Ok();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Core.Entities;
using QRCoder;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// TOTP-based 2FA setup and management for SuperAdmin accounts.
/// Accessible to any authenticated user — SuperAdmins are redirected here
/// by EnforceSuperAdmin2FAFilter when 2FA is not yet configured.
/// </summary>
[Authorize]
public class TwoFactorSetupController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IHostEnvironment _env;
public TwoFactorSetupController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
IHostEnvironment env)
{
_userManager = userManager;
_signInManager = signInManager;
_env = env;
}
/// <summary>
/// Returns the TOTP issuer name embedded in the authenticator URI.
/// Non-production environments append the environment name (e.g. "[Development]")
/// so that QR codes scanned against a staging server show up separately in the
/// authenticator app and cannot be accidentally used against production.
/// </summary>
private string AppName => _env.IsProduction()
? "Powder Coating Logix"
: $"Powder Coating Logix [{_env.EnvironmentName}]";
/// <summary>
/// Renders the 2FA management overview page showing the current user's
/// two-factor status, whether an authenticator key has been registered, and
/// whether they hold the SuperAdmin role (which affects which actions are available).
/// </summary>
// GET: /TwoFactorSetup
public async Task<IActionResult> Index()
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Challenge();
var isSuperAdmin = await _userManager.IsInRoleAsync(user, "SuperAdmin");
ViewBag.IsSuperAdmin = isSuperAdmin;
ViewBag.TwoFactorEnabled = user.TwoFactorEnabled;
ViewBag.HasAuthenticator = await _userManager.GetAuthenticatorKeyAsync(user) != null;
return View();
}
/// <summary>
/// Returns the Setup page with a QR code and manual entry key for the user's
/// authenticator app. Only generates a new secret key if one does not already
/// exist — this prevents the key from being silently reset when background
/// notification-poll requests follow a filter redirect back to this page,
/// which would invalidate a code the user just scanned.
/// </summary>
// GET: /TwoFactorSetup/Setup
public async Task<IActionResult> Setup()
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Challenge();
// Only generate a new key if none exists — avoids resetting the key when
// background fetch requests (e.g. notification polling) follow filter redirects
// back to this page and silently invalidate the code the user just scanned.
var key = await _userManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(key))
{
await _userManager.ResetAuthenticatorKeyAsync(user);
key = await _userManager.GetAuthenticatorKeyAsync(user) ?? string.Empty;
}
var appName = AppName;
var email = user.Email ?? user.UserName ?? "user";
var totpUri = $"otpauth://totp/{Uri.EscapeDataString(appName)}:{Uri.EscapeDataString(email)}?secret={key}&issuer={Uri.EscapeDataString(appName)}&algorithm=SHA1&digits=6&period=30";
var qrBase64 = GenerateQrBase64(totpUri);
ViewBag.SharedKey = FormatKey(key);
ViewBag.QrCodeBase64 = qrBase64;
ViewBag.TotpUri = totpUri;
return View();
}
/// <summary>
/// Verifies the TOTP code entered by the user and, on success, enables
/// two-factor authentication on their account. Strips spaces and dashes before
/// verification because authenticator apps often display codes with separators.
/// Calls <c>RefreshSignInAsync</c> so the authentication cookie reflects the
/// updated 2FA state without requiring a new login.
/// </summary>
// POST: /TwoFactorSetup/Setup
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Setup(string verificationCode)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Challenge();
var code = verificationCode?.Replace(" ", "").Replace("-", "") ?? string.Empty;
var isValid = await _userManager.VerifyTwoFactorTokenAsync(
user, _userManager.Options.Tokens.AuthenticatorTokenProvider, code);
if (!isValid)
{
// Re-show the setup page with current key (don't reset again)
var key = await _userManager.GetAuthenticatorKeyAsync(user) ?? string.Empty;
var appName = AppName;
var email = user.Email ?? user.UserName ?? "user";
var totpUri = $"otpauth://totp/{Uri.EscapeDataString(appName)}:{Uri.EscapeDataString(email)}?secret={key}&issuer={Uri.EscapeDataString(appName)}&algorithm=SHA1&digits=6&period=30";
ViewBag.SharedKey = FormatKey(key);
ViewBag.QrCodeBase64 = GenerateQrBase64(totpUri);
ViewBag.TotpUri = totpUri;
ViewBag.Error = "Verification code was incorrect. Make sure your authenticator app is synced and try again.";
return View();
}
await _userManager.SetTwoFactorEnabledAsync(user, true);
await _signInManager.RefreshSignInAsync(user);
TempData["Success"] = "Two-factor authentication has been enabled on your account.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Disables two-factor authentication and resets the authenticator key.
/// SuperAdmins must supply a valid current TOTP code before 2FA can be
/// removed — this prevents an attacker with a hijacked session from weakening
/// a privileged account's security posture without physical access to the device.
/// Non-SuperAdmin users can disable 2FA without a code (standard Identity behaviour).
/// The authenticator key is reset (not just disabled) so that a re-enabled key
/// requires fresh scanning of a new QR code.
/// </summary>
// POST: /TwoFactorSetup/Disable
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Disable(string confirmationCode)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Challenge();
var isSuperAdmin = await _userManager.IsInRoleAsync(user, "SuperAdmin");
// SuperAdmins cannot disable 2FA without a valid TOTP code
if (isSuperAdmin)
{
var code = confirmationCode?.Replace(" ", "").Replace("-", "") ?? string.Empty;
var isValid = await _userManager.VerifyTwoFactorTokenAsync(
user, _userManager.Options.Tokens.AuthenticatorTokenProvider, code);
if (!isValid)
{
TempData["Error"] = "Verification code was incorrect. 2FA has not been disabled.";
return RedirectToAction(nameof(Index));
}
}
await _userManager.SetTwoFactorEnabledAsync(user, false);
await _userManager.ResetAuthenticatorKeyAsync(user);
await _signInManager.RefreshSignInAsync(user);
TempData["Success"] = "Two-factor authentication has been disabled.";
return RedirectToAction(nameof(Index));
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>
/// Generates a PNG QR code for the given <paramref name="content"/> string
/// and returns it as a Base64-encoded string suitable for embedding in an
/// <c>&lt;img src="data:image/png;base64,…"&gt;</c> element.
/// Uses error-correction level Q (≈25 % recovery capacity) — a balanced
/// choice that keeps the code scannable even if partially obscured, without
/// making the image too dense for typical phone cameras.
/// </summary>
private static string GenerateQrBase64(string content)
{
using var qrGenerator = new QRCodeGenerator();
var qrData = qrGenerator.CreateQrCode(content, QRCodeGenerator.ECCLevel.Q);
using var qrCode = new PngByteQRCode(qrData);
var bytes = qrCode.GetGraphic(6);
return Convert.ToBase64String(bytes);
}
/// <summary>
/// Formats a raw Base32 authenticator key into space-separated groups of four
/// uppercase characters (e.g. <c>ABCD EFGH IJKL …</c>) to improve readability
/// for users who prefer to type the key manually instead of scanning the QR code.
/// </summary>
private static string FormatKey(string key)
{
// Group into chunks of 4 for readability: XXXX XXXX XXXX ...
var sb = new System.Text.StringBuilder();
for (int i = 0; i < key.Length; i++)
{
if (i > 0 && i % 4 == 0) sb.Append(' ');
sb.Append(char.ToUpperInvariant(key[i]));
}
return sb.ToString();
}
}
@@ -0,0 +1,120 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Handles customer self-service email opt-out via tokenized links.
/// No authentication required — the token IS the proof of identity.
/// </summary>
[AllowAnonymous]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Public)]
public class UnsubscribeController : Controller
{
private readonly ApplicationDbContext _context;
private readonly ILogger<UnsubscribeController> _logger;
public UnsubscribeController(ApplicationDbContext context, ILogger<UnsubscribeController> logger)
{
_context = context;
_logger = logger;
}
/// <summary>
/// GET /Unsubscribe/Email/{token}
/// Called when a customer clicks the unsubscribe link in an email.
/// Sets NotifyByEmail = false and shows a confirmation page.
/// </summary>
[HttpGet]
[Route("Unsubscribe/Email/{token}")]
public async Task<IActionResult> Email(string token)
{
if (string.IsNullOrWhiteSpace(token))
return View("Invalid");
try
{
// Bypass global query filters so we can find the customer by token
// regardless of company context (the user clicking is not authenticated)
var customer = await _context.Customers
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.UnsubscribeToken == token && !c.IsDeleted);
if (customer == null)
{
_logger.LogWarning("Unsubscribe attempt with unknown token: {Token}", token);
return View("Invalid");
}
if (!customer.NotifyByEmail)
{
// Already unsubscribed — show success page anyway (idempotent)
ViewBag.CustomerName = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
ViewBag.AlreadyUnsubscribed = true;
return View("EmailConfirm");
}
customer.NotifyByEmail = false;
customer.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Customer {CustomerId} unsubscribed from email notifications via link", customer.Id);
ViewBag.CustomerName = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
ViewBag.AlreadyUnsubscribed = false;
return View("EmailConfirm");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing unsubscribe for token {Token}", token);
return View("Invalid");
}
}
/// <summary>
/// GET /Unsubscribe/BroadcastEmail/{token}
/// Opts a company's primary contact out of platform broadcast/marketing emails.
/// </summary>
[HttpGet]
[Route("Unsubscribe/BroadcastEmail/{token}")]
public async Task<IActionResult> BroadcastEmail(string token)
{
if (string.IsNullOrWhiteSpace(token))
return View("Invalid");
try
{
var company = await _context.Companies
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.MarketingUnsubscribeToken == token && !c.IsDeleted);
if (company == null)
{
_logger.LogWarning("Broadcast unsubscribe attempt with unknown token: {Token}", token);
return View("Invalid");
}
ViewBag.CompanyName = company.CompanyName;
ViewBag.AlreadyUnsubscribed = company.MarketingEmailOptOut;
if (!company.MarketingEmailOptOut)
{
company.MarketingEmailOptOut = true;
company.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Company {CompanyId} opted out of broadcast emails via link", company.Id);
}
return View("BroadcastEmailConfirm");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing broadcast unsubscribe for token {Token}", token);
return View("Invalid");
}
}
}
@@ -0,0 +1,189 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only dashboard that shows per-company resource usage versus
/// plan limits across users, active jobs, customers, active quotes, and catalog items.
/// Uses <see cref="ApplicationDbContext"/> directly (bypassing UoW) to issue
/// efficient bulk-count GROUP BY queries in parallel rather than loading
/// each company's data through separate repository calls.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class UsageQuotaController : Controller
{
private readonly ApplicationDbContext _db;
public UsageQuotaController(ApplicationDbContext db) => _db = db;
/// <summary>
/// Renders the usage quota dashboard with one <see cref="UsageRow"/> per company.
/// <para>
/// Design decisions:
/// <list type="bullet">
/// <item>Five separate GROUP BY queries are issued in sequence (not parallel) so
/// the same <see cref="ApplicationDbContext"/> instance is not used concurrently.</item>
/// <item>Comped companies receive unlimited limits (-1) on every resource because
/// they are exempt from plan restrictions by design.</item>
/// <item>Company-level overrides (<c>MaxUsersOverride</c> etc.) take precedence
/// over plan defaults, enabling per-tenant exceptions without a plan change.</item>
/// <item>"Near limit" is defined as ≥ 80 % of the limit; "at limit" is ≥ 100 %.</item>
/// <item>The <c>concern</c> filter is applied in-memory after building rows because
/// the at/near-limit logic combines data from multiple resource types.</item>
/// </list>
/// </para>
/// </summary>
public async Task<IActionResult> Index(string? search, string? status, string? plan, string? concern)
{
// Plan configs for limit resolution
var planConfigs = await _db.SubscriptionPlanConfigs
.AsNoTracking().IgnoreQueryFilters()
.Where(p => p.IsActive)
.OrderBy(p => p.SortOrder)
.ToListAsync();
var planById = planConfigs.ToDictionary(p => p.Plan);
// All non-deleted companies
var companiesQuery = _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted);
if (!string.IsNullOrWhiteSpace(search))
companiesQuery = companiesQuery.Where(c => c.CompanyName.Contains(search));
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<SubscriptionStatus>(status, out var statusEnum))
companiesQuery = companiesQuery.Where(c => c.SubscriptionStatus == statusEnum);
if (!string.IsNullOrWhiteSpace(plan) && int.TryParse(plan, out var planInt))
companiesQuery = companiesQuery.Where(c => c.SubscriptionPlan == planInt);
var companies = await companiesQuery.OrderBy(c => c.CompanyName).ToListAsync();
var companyIds = companies.Select(c => c.Id).ToList();
// Bulk-fetch usage counts in parallel — one query per resource type
var userCounts = await _db.Users.AsNoTracking().IgnoreQueryFilters()
.Where(u => companyIds.Contains(u.CompanyId))
.GroupBy(u => u.CompanyId)
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
// Active jobs = non-deleted jobs whose status is not a terminal status
var activeJobCounts = await _db.Jobs.AsNoTracking().IgnoreQueryFilters()
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted &&
!j.JobStatus.IsTerminalStatus)
.GroupBy(j => j.CompanyId)
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
var customerCounts = await _db.Customers.AsNoTracking().IgnoreQueryFilters()
.Where(c => companyIds.Contains(c.CompanyId) && !c.IsDeleted)
.GroupBy(c => c.CompanyId)
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
// Active quotes = non-deleted quotes not in a terminal status (converted, rejected)
var quoteCounts = await _db.Quotes.AsNoTracking().IgnoreQueryFilters()
.Where(q => companyIds.Contains(q.CompanyId) && !q.IsDeleted &&
!q.QuoteStatus.IsConvertedStatus && !q.QuoteStatus.IsRejectedStatus)
.GroupBy(q => q.CompanyId)
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
var catalogCounts = await _db.CatalogItems.AsNoTracking().IgnoreQueryFilters()
.Where(ci => companyIds.Contains(ci.CompanyId) && !ci.IsDeleted)
.GroupBy(ci => ci.CompanyId)
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
// Build rows
int Effective(int? companyOverride, int planDefault) =>
companyOverride.HasValue ? companyOverride.Value : planDefault;
var rows = companies.Select(c =>
{
var cfg = planById.TryGetValue(c.SubscriptionPlan, out var pc) ? pc : null;
int users = userCounts.GetValueOrDefault(c.Id);
int jobs = activeJobCounts.GetValueOrDefault(c.Id);
int customers = customerCounts.GetValueOrDefault(c.Id);
int quotes = quoteCounts.GetValueOrDefault(c.Id);
int catalog = catalogCounts.GetValueOrDefault(c.Id);
int maxUsers = c.IsComped ? -1 : Effective(c.MaxUsersOverride, cfg?.MaxUsers ?? -1);
int maxJobs = c.IsComped ? -1 : Effective(c.MaxActiveJobsOverride, cfg?.MaxActiveJobs ?? -1);
int maxCust = c.IsComped ? -1 : Effective(c.MaxCustomersOverride, cfg?.MaxCustomers ?? -1);
int maxQuotes = c.IsComped ? -1 : Effective(c.MaxQuotesOverride, cfg?.MaxQuotes ?? -1);
int maxCatalog = c.IsComped ? -1 : Effective(c.MaxCatalogItemsOverride, cfg?.MaxCatalogItems ?? -1);
bool IsNearLimit(int used, int max) => max > 0 && used >= max * 0.8;
bool IsAtLimit(int used, int max) => max > 0 && used >= max;
bool anyAtLimit = IsAtLimit(users, maxUsers) || IsAtLimit(jobs, maxJobs) ||
IsAtLimit(customers, maxCust) || IsAtLimit(quotes, maxQuotes) ||
IsAtLimit(catalog, maxCatalog);
bool anyNearLimit = IsNearLimit(users, maxUsers) || IsNearLimit(jobs, maxJobs) ||
IsNearLimit(customers, maxCust) || IsNearLimit(quotes, maxQuotes) ||
IsNearLimit(catalog, maxCatalog);
return new UsageRow
{
CompanyId = c.Id,
CompanyName = c.CompanyName,
PlanName = cfg?.DisplayName ?? c.SubscriptionPlan.ToString(),
Status = c.SubscriptionStatus,
IsActive = c.IsActive,
IsComped = c.IsComped,
Users = users, MaxUsers = maxUsers,
ActiveJobs = jobs, MaxActiveJobs = maxJobs,
Customers = customers, MaxCustomers = maxCust,
ActiveQuotes = quotes, MaxActiveQuotes = maxQuotes,
CatalogItems = catalog, MaxCatalogItems = maxCatalog,
IsAtLimit = anyAtLimit,
IsNearLimit = anyNearLimit && !anyAtLimit
};
}).ToList();
// "concern" filter — only show rows with issues
if (concern == "limit") rows = rows.Where(r => r.IsAtLimit || r.IsNearLimit).ToList();
else if (concern == "atlimit") rows = rows.Where(r => r.IsAtLimit).ToList();
ViewBag.Search = search;
ViewBag.StatusFilter = status;
ViewBag.PlanFilter = plan;
ViewBag.ConcernFilter = concern;
ViewBag.PlanConfigs = planConfigs;
ViewBag.AtLimitCount = rows.Count(r => r.IsAtLimit);
ViewBag.NearLimitCount = rows.Count(r => r.IsNearLimit);
ViewBag.TotalCount = rows.Count;
return View(rows);
}
}
public class UsageRow
{
public int CompanyId { get; set; }
public string CompanyName { get; set; } = string.Empty;
public string PlanName { get; set; } = string.Empty;
public SubscriptionStatus Status { get; set; }
public bool IsActive { get; set; }
public bool IsComped { get; set; }
public int Users { get; set; }
public int MaxUsers { get; set; }
public int ActiveJobs { get; set; }
public int MaxActiveJobs { get; set; }
public int Customers { get; set; }
public int MaxCustomers { get; set; }
public int ActiveQuotes { get; set; }
public int MaxActiveQuotes { get; set; }
public int CatalogItems { get; set; }
public int MaxCatalogItems { get; set; }
public bool IsAtLimit { get; set; }
public bool IsNearLimit { get; set; }
}
@@ -0,0 +1,301 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Services;
using System.Text;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class UserActivityController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IOnlineUserTracker _tracker;
public UserActivityController(ApplicationDbContext db, IOnlineUserTracker tracker)
{
_db = db;
_tracker = tracker;
}
/// <summary>
/// Renders a paginated, filterable table of all platform users across every tenant
/// with their last-login date and account status (SuperAdmin only).
/// <para>
/// Filters are composable: company, role, active/inactive status, login-age bucket,
/// and free-text search can all be combined. Sorting is handled via a switch
/// expression; the default sort puts users who have never logged in first (NULL
/// last-login dates sorted before oldest dates), then ascending by date, so the
/// most at-risk accounts appear at the top by default.
/// </para>
/// <para>
/// Platform-wide summary KPI stats (total users, active users, never logged in,
/// inactive 30+ days) are computed from a <em>separate</em> un-filtered base query
/// rather than the current filter query so the header cards always reflect
/// platform totals regardless of which filter is active.
/// </para>
/// <para>
/// All queries use <c>IgnoreQueryFilters()</c> so SuperAdmins see users from all
/// companies, including those in inactive or deleted tenants.
/// </para>
/// </summary>
public async Task<IActionResult> Index(
int? companyId,
string? role,
string? activeStatus,
string? loginAge,
string? search,
string sortCol = "LastLogin",
string sortDir = "asc",
int page = 1,
int pageSize = 50)
{
pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50;
page = Math.Max(1, page);
var query = _db.Users.AsNoTracking().IgnoreQueryFilters()
.Include(u => u.Company)
.AsQueryable();
if (companyId.HasValue)
query = query.Where(u => u.CompanyId == companyId);
if (!string.IsNullOrWhiteSpace(role))
query = query.Where(u => u.CompanyRole == role);
if (activeStatus == "active")
query = query.Where(u => u.IsActive);
else if (activeStatus == "inactive")
query = query.Where(u => !u.IsActive);
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(u =>
u.FirstName.Contains(search) ||
u.LastName.Contains(search) ||
u.Email!.Contains(search));
var now = DateTime.UtcNow;
if (loginAge == "never")
query = query.Where(u => u.LastLoginDate == null);
else if (loginAge == "90plus")
query = query.Where(u => u.LastLoginDate == null || u.LastLoginDate < now.AddDays(-90));
else if (loginAge == "30plus")
query = query.Where(u => u.LastLoginDate == null || u.LastLoginDate < now.AddDays(-30));
else if (loginAge == "7plus")
query = query.Where(u => u.LastLoginDate == null || u.LastLoginDate < now.AddDays(-7));
else if (loginAge == "recent")
query = query.Where(u => u.LastLoginDate != null && u.LastLoginDate >= now.AddDays(-7));
query = (sortCol, sortDir) switch
{
("Company", "asc") => query.OrderBy(u => u.Company!.CompanyName),
("Company", "desc") => query.OrderByDescending(u => u.Company!.CompanyName),
("Name", "asc") => query.OrderBy(u => u.LastName).ThenBy(u => u.FirstName),
("Name", "desc") => query.OrderByDescending(u => u.LastName).ThenBy(u => u.FirstName),
("Role", "asc") => query.OrderBy(u => u.CompanyRole),
("Role", "desc") => query.OrderByDescending(u => u.CompanyRole),
("LastLogin", "desc") => query.OrderByDescending(u => u.LastLoginDate),
("Created", "asc") => query.OrderBy(u => u.CreatedAt),
("Created", "desc") => query.OrderByDescending(u => u.CreatedAt),
_ => query.OrderBy(u => u.LastLoginDate == null).ThenBy(u => u.LastLoginDate)
};
var totalCount = await query.CountAsync();
var users = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(u => new UserActivityRow
{
Id = u.Id,
CompanyId = u.CompanyId,
CompanyName = u.Company != null ? u.Company.CompanyName : "—",
FullName = u.FirstName + " " + u.LastName,
Email = u.Email ?? string.Empty,
CompanyRole = u.CompanyRole ?? "—",
IsActive = u.IsActive,
LastLoginDate = u.LastLoginDate,
CreatedAt = u.CreatedAt,
DaysSinceLogin = u.LastLoginDate.HasValue
? (int)(now - u.LastLoginDate.Value).TotalDays
: (int?)null
})
.ToListAsync();
// Summary stats — CountAsync queries instead of loading all users into memory
var statsBase = _db.Users.AsNoTracking().IgnoreQueryFilters();
var cutoff30 = now.AddDays(-30);
ViewBag.TotalUsers = await statsBase.CountAsync();
ViewBag.ActiveUsers = await statsBase.CountAsync(u => u.IsActive);
ViewBag.NeverLoggedIn = await statsBase.CountAsync(u => u.LastLoginDate == null);
ViewBag.Inactive30 = await statsBase.CountAsync(u => u.LastLoginDate == null || u.LastLoginDate < cutoff30);
// Company list for filter dropdown
ViewBag.Companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.OrderBy(c => c.CompanyName)
.Select(c => new { c.Id, c.CompanyName })
.ToListAsync();
ViewBag.CompanyIdFilter = companyId;
ViewBag.RoleFilter = role;
ViewBag.ActiveStatusFilter = activeStatus;
ViewBag.LoginAgeFilter = loginAge;
ViewBag.Search = search;
ViewBag.SortCol = sortCol;
ViewBag.SortDir = sortDir;
ViewBag.Page = page;
ViewBag.PageSize = pageSize;
ViewBag.TotalCount = totalCount;
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
return View(users);
}
/// <summary>
/// Renders a live view of users who have been active within the last 15 minutes,
/// sourced from the in-memory <see cref="IOnlineUserTracker"/> service.
/// <para>
/// The tracker only holds lightweight session data (userId, email, display name,
/// current path, IP). Company name and role are enriched from the database in a
/// single batch query keyed by user IDs, avoiding N+1 lookups. The window of 15
/// minutes is hardcoded here (passed to the tracker) and echoed in ViewBag so the
/// view can display "active in last 15 min" without embedding a magic number in
/// the template.
/// </para>
/// </summary>
public async Task<IActionResult> Online()
{
var active = _tracker.GetActiveUsers(windowMinutes: 15);
// Enrich with company name + role from DB (batch query by user IDs)
var userIds = active.Select(e => e.UserId).ToList();
var dbUsers = await _db.Users.AsNoTracking().IgnoreQueryFilters()
.Where(u => userIds.Contains(u.Id))
.Include(u => u.Company)
.Select(u => new
{
u.Id,
u.CompanyRole,
CompanyName = u.Company != null ? u.Company.CompanyName : null,
u.CompanyId
})
.ToDictionaryAsync(u => u.Id);
var rows = active.Select(e =>
{
dbUsers.TryGetValue(e.UserId, out var db);
return new OnlineUserRow
{
UserId = e.UserId,
Email = e.Email,
DisplayName = e.DisplayName,
IsSuperAdmin = e.IsSuperAdmin,
CompanyName = db?.CompanyName ?? e.CompanyName ?? (e.IsSuperAdmin ? "Platform" : "—"),
CompanyId = db?.CompanyId ?? e.CompanyId,
Role = e.IsSuperAdmin ? "SuperAdmin" : (db?.CompanyRole ?? "—"),
CurrentPath = e.CurrentPath,
IpAddress = e.IpAddress,
LastSeen = e.LastSeen
};
}).ToList();
ViewBag.WindowMinutes = 15;
return View(rows);
}
/// <summary>
/// Exports the current user-activity query result as a UTF-8 CSV file for
/// offline analysis in Excel or similar tools.
/// <para>
/// Applies the same filter logic as <see cref="Index"/> (minus sorting and
/// pagination) and streams the full result set. Column values that contain
/// commas, quotes, or newlines are wrapped in double-quotes via
/// <see cref="CsvEscape"/> to produce a valid RFC 4180 CSV.
/// The filename is date-stamped (<c>user-activity-yyyyMMdd.csv</c>) so multiple
/// exports on different days do not overwrite each other when saved to a folder.
/// </para>
/// </summary>
public async Task<IActionResult> ExportCsv(
int? companyId, string? role, string? activeStatus, string? loginAge, string? search)
{
var query = _db.Users.AsNoTracking().IgnoreQueryFilters()
.Include(u => u.Company)
.AsQueryable();
if (companyId.HasValue) query = query.Where(u => u.CompanyId == companyId);
if (!string.IsNullOrWhiteSpace(role)) query = query.Where(u => u.CompanyRole == role);
if (activeStatus == "active") query = query.Where(u => u.IsActive);
else if (activeStatus == "inactive") query = query.Where(u => !u.IsActive);
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(u => u.FirstName.Contains(search) || u.LastName.Contains(search) || u.Email!.Contains(search));
var now = DateTime.UtcNow;
if (loginAge == "never") query = query.Where(u => u.LastLoginDate == null);
else if (loginAge == "90plus") query = query.Where(u => u.LastLoginDate == null || u.LastLoginDate < now.AddDays(-90));
else if (loginAge == "30plus") query = query.Where(u => u.LastLoginDate == null || u.LastLoginDate < now.AddDays(-30));
else if (loginAge == "recent") query = query.Where(u => u.LastLoginDate != null && u.LastLoginDate >= now.AddDays(-7));
var users = await query.OrderBy(u => u.Company!.CompanyName).ThenBy(u => u.LastName).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("Company,Name,Email,Role,Active,Last Login,Days Since Login,Created");
foreach (var u in users)
{
var days = u.LastLoginDate.HasValue ? (int)(now - u.LastLoginDate.Value).TotalDays : -1;
sb.AppendLine(string.Join(",",
CsvEscape(u.Company?.CompanyName ?? ""),
CsvEscape(u.FirstName + " " + u.LastName),
CsvEscape(u.Email ?? ""),
CsvEscape(u.CompanyRole ?? ""),
u.IsActive ? "Yes" : "No",
u.LastLoginDate.HasValue ? u.LastLoginDate.Value.ToString("MM/dd/yyyy") : "Never",
days >= 0 ? days.ToString() : "—",
u.CreatedAt.ToString("MM/dd/yyyy")));
}
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
return File(bytes, "text/csv", $"user-activity-{DateTime.UtcNow:yyyyMMdd}.csv");
}
/// <summary>
/// Wraps <paramref name="value"/> in double-quotes and escapes any internal
/// double-quotes by doubling them, conforming to RFC 4180 CSV quoting rules.
/// Values that contain no commas, quotes, or newlines are returned as-is to
/// keep the output clean and readable.
/// </summary>
private static string CsvEscape(string value) =>
value.Contains(',') || value.Contains('"') || value.Contains('\n')
? $"\"{value.Replace("\"", "\"\"")}\""
: value;
}
public class OnlineUserRow
{
public string UserId { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public bool IsSuperAdmin { get; set; }
public string? CompanyName { get; set; }
public int? CompanyId { get; set; }
public string? Role { get; set; }
public string? CurrentPath { get; set; }
public string? IpAddress { get; set; }
public DateTime LastSeen { get; set; }
}
public class UserActivityRow
{
public string Id { get; set; } = string.Empty;
public int CompanyId { get; set; }
public string CompanyName { get; set; } = string.Empty;
public string FullName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string CompanyRole { get; set; } = string.Empty;
public bool IsActive { get; set; }
public DateTime? LastLoginDate { get; set; }
public DateTime CreatedAt { get; set; }
public int? DaysSinceLogin { get; set; }
}
@@ -0,0 +1,410 @@
using AutoMapper;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Vendor;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Manages vendor (supplier) records: company details, payment terms, preferred-vendor flag, and the
/// default expense account used when AP bills are created for this vendor. Vendors link to inventory
/// items (indicating their source supplier) and purchase orders. Deletion is blocked when live
/// inventory items reference the vendor, protecting referential integrity without resorting to a
/// cascade delete that would silently break historical records.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageVendors)]
public class VendorsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<VendorsController> _logger;
private readonly ApplicationDbContext _context;
public VendorsController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<VendorsController> logger,
ApplicationDbContext context)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_context = context;
}
/// <summary>
/// Displays a paginated, sortable, searchable vendor list. Search covers company name, contact
/// name, email, phone, and city so a user can locate a vendor by any of its common identifiers.
/// Inventory items are eagerly loaded solely to compute the per-vendor item count badge shown in
/// the grid; the count explicitly excludes soft-deleted items so only live references are shown.
/// </summary>
public async Task<IActionResult> Index(
string? searchTerm,
string? sortColumn,
string sortDirection = "asc",
int pageNumber = 1,
int pageSize = 25)
{
try
{
// Create and validate grid request
var gridRequest = new GridRequest
{
PageNumber = pageNumber,
PageSize = pageSize,
SortColumn = sortColumn ?? "CompanyName",
SortDirection = sortDirection,
SearchTerm = searchTerm
};
gridRequest.Validate();
// Build search filter
System.Linq.Expressions.Expression<Func<Vendor, bool>>? filter = null;
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
filter = s => s.CompanyName.ToLower().Contains(search)
|| (s.ContactName != null && s.ContactName.ToLower().Contains(search))
|| (s.Email != null && s.Email.ToLower().Contains(search))
|| (s.Phone != null && s.Phone.ToLower().Contains(search))
|| (s.City != null && s.City.ToLower().Contains(search));
}
// Build orderBy function
Func<IQueryable<Vendor>, IOrderedQueryable<Vendor>> orderBy = gridRequest.SortColumn switch
{
"CompanyName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.CompanyName) : q.OrderByDescending(s => s.CompanyName),
"ContactName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.ContactName) : q.OrderByDescending(s => s.ContactName),
"Phone" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.Phone) : q.OrderByDescending(s => s.Phone),
"Email" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.Email) : q.OrderByDescending(s => s.Email),
"IsActive" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.IsActive) : q.OrderByDescending(s => s.IsActive),
"IsPreferred" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(s => s.IsPreferred) : q.OrderByDescending(s => s.IsPreferred),
_ => q => q.OrderBy(s => s.CompanyName)
};
// Get paged data with inventory items eager loading for count
var (items, totalCount) = await _unitOfWork.Vendors.GetPagedAsync(
gridRequest.PageNumber,
gridRequest.PageSize,
filter,
orderBy,
s => s.InventoryItems);
// Map to DTOs
var vendorDtos = items.Select(s => new VendorListDto
{
Id = s.Id,
CompanyName = s.CompanyName,
ContactName = s.ContactName,
Phone = s.Phone,
Email = s.Email,
IsActive = s.IsActive,
IsPreferred = s.IsPreferred,
InventoryItemCount = s.InventoryItems.Count(i => !i.IsDeleted)
}).ToList();
// Create paged result
var pagedResult = new PagedResult<VendorListDto>
{
Items = vendorDtos,
PageNumber = gridRequest.PageNumber,
PageSize = gridRequest.PageSize,
TotalCount = totalCount
};
// Set ViewBag for sorting
ViewBag.SearchTerm = searchTerm;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
return View(pagedResult);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving vendors");
TempData["Error"] = "An error occurred while loading vendors.";
return View(new PagedResult<VendorListDto>());
}
}
/// <summary>
/// Shows full vendor detail including linked inventory items and the human-readable default expense
/// account name. The account name is resolved via a direct <c>_context.Accounts.FindAsync</c>
/// call rather than an eager-load because the Account entity lives outside the standard
/// <c>IUnitOfWork</c> pattern and is only needed for display here.
/// </summary>
public async Task<IActionResult> Details(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value, false, s => s.InventoryItems);
if (vendor == null)
{
return NotFound();
}
var vendorDto = _mapper.Map<VendorDto>(vendor);
if (vendor.DefaultExpenseAccountId.HasValue)
{
var acct = await _context.Accounts.FindAsync(vendor.DefaultExpenseAccountId.Value);
vendorDto.DefaultExpenseAccountName = acct != null ? $"{acct.AccountNumber} {acct.Name}" : null;
}
return View(vendorDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving vendor {VendorId}", id);
TempData["Error"] = "An error occurred while loading the vendor.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Renders the Create form with the expense account dropdown pre-populated.
/// When <paramref name="inline"/> is true the layout is stripped so the HTML can be injected
/// into a modal; the form will POST back with the same flag and return JSON instead of a redirect.
/// </summary>
public async Task<IActionResult> Create(bool inline = false)
{
await PopulateExpenseAccountsAsync();
if (inline)
return PartialView(new CreateVendorDto());
return View(new CreateVendorDto());
}
/// <summary>
/// Persists a new vendor, stamping it with the current user's <c>CompanyId</c> so multi-tenant
/// isolation is enforced at creation rather than relying solely on query filters. Redirects to
/// Details on success so the user can immediately verify the saved record.
/// When <paramref name="inline"/> is true (quick-add modal path) returns JSON {success, id, name}
/// instead of a redirect so the caller can add the new option to the originating select element.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateVendorDto dto, bool inline = false)
{
if (!ModelState.IsValid)
{
if (inline)
{
var errors = ModelState.Values
.SelectMany(v => v.Errors)
.Select(e => e.ErrorMessage);
return Json(new { success = false, errors });
}
await PopulateExpenseAccountsAsync();
return View(dto);
}
try
{
var currentUser = await _userManager.GetUserAsync(User);
var vendor = _mapper.Map<Vendor>(dto);
vendor.CompanyId = currentUser!.CompanyId;
await _unitOfWork.Vendors.AddAsync(vendor);
await _unitOfWork.CompleteAsync();
if (inline)
return Json(new { success = true, id = vendor.Id, name = vendor.CompanyName });
TempData["Success"] = $"Vendor '{vendor.CompanyName}' created successfully.";
return RedirectToAction(nameof(Details), new { id = vendor.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating vendor");
if (inline)
return Json(new { success = false, errors = new[] { "An error occurred while saving." } });
TempData["Error"] = "An error occurred while creating the vendor.";
return View(dto);
}
}
/// <summary>
/// Renders the Edit form for an existing vendor.
/// </summary>
public async Task<IActionResult> Edit(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value);
if (vendor == null)
{
return NotFound();
}
var dto = _mapper.Map<UpdateVendorDto>(vendor);
await PopulateExpenseAccountsAsync();
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading vendor {VendorId} for edit", id);
TempData["Error"] = "An error occurred while loading the vendor.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Applies edits to an existing vendor. Uses AutoMapper's map-onto-existing-entity overload so
/// EF Core's change tracker only marks modified columns dirty, preserving audit timestamps and
/// <c>CompanyId</c> that are not present in the DTO.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateVendorDto dto)
{
if (id != dto.Id)
{
return NotFound();
}
if (!ModelState.IsValid)
{
await PopulateExpenseAccountsAsync();
return View(dto);
}
try
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id);
if (vendor == null)
{
return NotFound();
}
_mapper.Map(dto, vendor);
await _unitOfWork.Vendors.UpdateAsync(vendor);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Vendor '{vendor.CompanyName}' updated successfully.";
return RedirectToAction(nameof(Details), new { id = vendor.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating vendor {VendorId}", id);
TempData["Error"] = "An error occurred while updating the vendor.";
return View(dto);
}
}
/// <summary>
/// Shows the Delete confirmation page with the vendor's active inventory item count, so the user
/// understands the impact before confirming. The count is surfaced here to avoid a surprise
/// block error on the POST — surfacing the constraint on the GET gives the user an opportunity to
/// reassign items first.
/// </summary>
public async Task<IActionResult> Delete(int? id)
{
if (id == null)
{
return NotFound();
}
try
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value, false, s => s.InventoryItems);
if (vendor == null)
{
return NotFound();
}
var vendorDto = _mapper.Map<VendorDto>(vendor);
ViewBag.InventoryItemCount = vendor.InventoryItems.Count(i => !i.IsDeleted);
return View(vendorDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading vendor {VendorId} for deletion", id);
TempData["Error"] = "An error occurred while loading the vendor.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Soft-deletes a vendor after a business-rule guard: deletion is blocked when the vendor is
/// still referenced by one or more active (non-soft-deleted) inventory items. The block is
/// enforced in application code rather than via a DB foreign key constraint because soft deletes
/// make constraint-based blocking unreliable — a DB constraint would fire even on logically
/// deleted items. Soft delete is used so purchase order and AP bill history that references this
/// vendor remains intact.
/// </summary>
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
try
{
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id, false, s => s.InventoryItems);
if (vendor == null)
{
return NotFound();
}
var inventoryItemCount = vendor.InventoryItems.Count(i => !i.IsDeleted);
if (inventoryItemCount > 0)
{
TempData["Error"] = $"Cannot delete vendor '{vendor.CompanyName}' because it is currently used by {inventoryItemCount} inventory item(s). Please reassign or remove those items first.";
return RedirectToAction(nameof(Delete), new { id });
}
var vendorName = vendor.CompanyName;
await _unitOfWork.Vendors.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Vendor '{vendorName}' has been deleted.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting vendor {VendorId}", id);
TempData["Error"] = "An error occurred while deleting the vendor.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Populates <c>ViewBag.ExpenseAccounts</c> with active Expense, Cost of Goods, and Asset accounts
/// for the vendor's default expense account dropdown. All three account types are included because
/// vendor bills can legitimately be coded to COGS (powder, materials) or asset accounts (equipment
/// purchases) in addition to regular operating expenses. A "— None —" placeholder is prepended so
/// the field is optional — not every vendor needs a default account pre-set.
/// </summary>
private async Task PopulateExpenseAccountsAsync()
{
var accounts = await _context.Accounts
.Where(a => !a.IsDeleted && a.IsActive &&
(a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods ||
a.AccountType == AccountType.Asset))
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToListAsync();
accounts.Insert(0, new SelectListItem("— None —", ""));
ViewBag.ExpenseAccounts = accounts;
}
}
@@ -0,0 +1,306 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using Twilio.Security;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Receives inbound webhooks from Twilio (SMS status / opt-out).
/// AllowAnonymous — Twilio posts from their servers, not authenticated users.
/// Requests are validated using the Twilio Request Validator (HMAC-SHA1).
/// </summary>
[AllowAnonymous]
[ApiController]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Webhook)]
public class WebhooksController : ControllerBase
{
private readonly ApplicationDbContext _context;
private readonly IConfiguration _configuration;
private readonly ILogger<WebhooksController> _logger;
// CTIA-standard opt-out keywords (case-insensitive)
private static readonly HashSet<string> StopKeywords = new(StringComparer.OrdinalIgnoreCase)
{
"STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"
};
// CTIA-standard help keywords
private static readonly HashSet<string> HelpKeywords = new(StringComparer.OrdinalIgnoreCase)
{
"HELP", "INFO"
};
public WebhooksController(
ApplicationDbContext context,
IConfiguration configuration,
ILogger<WebhooksController> logger)
{
_context = context;
_configuration = configuration;
_logger = logger;
}
/// <summary>
/// Receives inbound SMS messages forwarded by Twilio. Configure this endpoint as the
/// "A message comes in" webhook URL in the Twilio Console for the outbound SMS number.
/// Validates the request signature, then routes STOP keywords (opts customer out with
/// timestamp + DB log + TwiML confirmation), HELP keywords (TwiML help reply + DB log),
/// and all other messages (silent acknowledgment). Always returns TwiML — Twilio expects a
/// 200 with valid TwiML; any other response triggers automatic retries.
/// </summary>
[HttpPost("Webhooks/TwilioSms")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> TwilioSms([FromForm] TwilioSmsPayload payload)
{
if (!ValidateTwilioRequest())
{
_logger.LogWarning("Rejected Twilio webhook: invalid signature from {IP}",
HttpContext.Connection.RemoteIpAddress);
return Forbid();
}
if (string.IsNullOrWhiteSpace(payload.From))
return TwimlEmpty();
var body = (payload.Body ?? string.Empty).Trim();
_logger.LogInformation("Twilio inbound SMS from {From}: {Body}", payload.From, body);
if (StopKeywords.Contains(body))
return await HandleStopAsync(payload.From);
if (HelpKeywords.Contains(body))
return await HandleHelpAsync(payload.From);
// Unrecognized message — silent acknowledge
return TwimlEmpty();
}
// ── STOP ──────────────────────────────────────────────────────────────────
/// <summary>
/// Processes a STOP keyword: opts the customer out, stamps SmsOptedOutAt, logs to
/// NotificationLog, and returns a TwiML confirmation message.
/// </summary>
private async Task<IActionResult> HandleStopAsync(string from)
{
var (customer, digits10) = await FindCustomerByPhoneAsync(from);
if (customer == null)
{
_logger.LogWarning("Twilio STOP from {From} — no matching customer found", from);
// Still send a polite confirmation even if we can't find them
return TwimlMessage("You have been unsubscribed and will not receive further messages.");
}
var companyName = await GetCompanyNameAsync(customer.CompanyId);
if (customer.NotifyBySms)
{
customer.NotifyBySms = false;
customer.SmsOptedOutAt = DateTime.UtcNow;
customer.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Customer {CustomerId} opted out of SMS via STOP reply", customer.Id);
}
await WriteInboundLogAsync(
NotificationType.SmsInboundStop,
customer,
from,
"STOP");
return TwimlMessage(
$"{companyName}: You have been unsubscribed. No further messages will be sent. " +
$"Reply START to re-subscribe.");
}
// ── HELP ──────────────────────────────────────────────────────────────────
/// <summary>
/// Processes a HELP keyword: logs the inbound message and returns a TwiML help response
/// with the program description and opt-out instructions.
/// </summary>
private async Task<IActionResult> HandleHelpAsync(string from)
{
var (customer, _) = await FindCustomerByPhoneAsync(from);
string companyName;
if (customer != null)
{
companyName = await GetCompanyNameAsync(customer.CompanyId);
await WriteInboundLogAsync(
NotificationType.SmsInboundHelp,
customer,
from,
"HELP");
}
else
{
companyName = "Powder Coating Logix";
_logger.LogWarning("Twilio HELP from {From} — no matching customer found", from);
}
return TwimlMessage(
$"{companyName}: Job status & pickup alerts. Msg & data rates may apply. " +
$"Reply STOP to unsubscribe. Contact us directly for additional help.");
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>
/// Finds a customer by matching the last 10 digits of the inbound phone number against
/// MobilePhone and Phone fields (handles E.164, formatted, and local number variations).
/// When the same phone number exists across multiple tenant companies, breaks the tie by
/// choosing the customer whose company most recently sent them an outbound SMS — it is
/// nearly impossible for two shops to be texting the same number about a job on the same day.
/// Falls back to the alphabetically first match if no outbound SMS log exists for any candidate.
/// </summary>
private async Task<(Customer? customer, string digits10)> FindCustomerByPhoneAsync(string from)
{
var digits10 = from.Length >= 10 ? from[^10..] : from;
var candidates = await _context.Customers
.IgnoreQueryFilters()
.Where(c =>
!c.IsDeleted && (
(c.MobilePhone != null && c.MobilePhone.Replace("-", "").Replace("(", "").Replace(")", "").Replace(" ", "").Replace("+", "").EndsWith(digits10)) ||
(c.Phone != null && c.Phone.Replace("-", "").Replace("(", "").Replace(")", "").Replace(" ", "").Replace("+", "").EndsWith(digits10))
))
.ToListAsync();
if (candidates.Count == 0) return (null, digits10);
if (candidates.Count == 1) return (candidates[0], digits10);
// Multiple tenants share this phone number — pick the one whose company most recently
// sent an outbound SMS to this number (tiebreaker: most recent NotificationLog entry).
var candidateIds = candidates.Select(c => c.Id).ToList();
var mostRecentLog = await _context.NotificationLogs
.IgnoreQueryFilters()
.Where(l =>
l.Channel == NotificationChannel.Sms &&
l.Status == NotificationStatus.Sent &&
l.CustomerId.HasValue &&
candidateIds.Contains(l.CustomerId.Value))
.OrderByDescending(l => l.SentAt)
.Select(l => new { l.CustomerId, l.SentAt })
.FirstOrDefaultAsync();
if (mostRecentLog?.CustomerId != null)
{
var match = candidates.FirstOrDefault(c => c.Id == mostRecentLog.CustomerId.Value);
if (match != null)
{
_logger.LogInformation(
"Multi-tenant phone match resolved to customer {CustomerId} via most recent outbound SMS at {SentAt}",
match.Id, mostRecentLog.SentAt);
return (match, digits10);
}
}
// No outbound log found for any candidate — fall back to first result
_logger.LogWarning(
"Multi-tenant phone match for {Digits} could not be resolved by SMS log; using first candidate (CustomerId {CustomerId})",
digits10, candidates[0].Id);
return (candidates[0], digits10);
}
/// <summary>Writes a NotificationLog row for an inbound STOP or HELP message.</summary>
private async Task WriteInboundLogAsync(
NotificationType type,
Customer customer,
string fromPhone,
string body)
{
try
{
_context.NotificationLogs.Add(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = type,
Status = NotificationStatus.Sent,
RecipientName = GetCustomerDisplayName(customer),
Recipient = fromPhone,
Message = body,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
CompanyId = customer.CompanyId
});
await _context.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to write inbound SMS log for customer {CustomerId}", customer.Id);
}
}
private async Task<string> GetCompanyNameAsync(int companyId)
{
var company = await _context.Companies
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == companyId);
return company?.CompanyName ?? "Powder Coating Logix";
}
private static string GetCustomerDisplayName(Customer customer)
{
var contact = $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
if (!string.IsNullOrEmpty(contact)) return contact;
return customer.CompanyName ?? "Customer";
}
/// <summary>Returns an empty TwiML response (no reply sent to sender).</summary>
private static ContentResult TwimlEmpty() =>
new ContentResult { Content = "<Response/>", ContentType = "application/xml" };
/// <summary>Returns a TwiML response that sends <paramref name="message"/> as an SMS reply.</summary>
private static ContentResult TwimlMessage(string message) =>
new ContentResult
{
Content = $"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response><Message>{System.Net.WebUtility.HtmlEncode(message)}</Message></Response>",
ContentType = "application/xml"
};
/// <summary>
/// Validates the incoming Twilio webhook request using HMAC-SHA1. Skips validation when the
/// auth token is unconfigured so the endpoint works in local development without real Twilio credentials.
/// </summary>
private bool ValidateTwilioRequest()
{
var authToken = _configuration["Twilio:AuthToken"];
if (string.IsNullOrWhiteSpace(authToken) || authToken.StartsWith("your-"))
{
_logger.LogDebug("Twilio auth token not configured; skipping signature validation");
return true;
}
var signature = Request.Headers["X-Twilio-Signature"].FirstOrDefault() ?? string.Empty;
if (string.IsNullOrEmpty(signature))
{
_logger.LogWarning("Twilio webhook missing X-Twilio-Signature header");
return false;
}
var url = $"{Request.Scheme}://{Request.Host}{Request.Path}";
var parameters = new Dictionary<string, string>();
foreach (var kvp in Request.Form)
parameters[kvp.Key] = kvp.Value.ToString();
return new RequestValidator(authToken).Validate(url, parameters, signature);
}
}
/// <summary>Bound from the Twilio POST form body.</summary>
public class TwilioSmsPayload
{
public string? From { get; set; }
public string? Body { get; set; }
public string? To { get; set; }
}
@@ -0,0 +1,239 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Interfaces;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace PowderCoating.Web.Controllers;
[Authorize]
public class WorkOrderController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly ICompanyLogoService _logoService;
public WorkOrderController(IUnitOfWork unitOfWork, ITenantContext tenantContext, ICompanyLogoService logoService)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_logoService = logoService;
}
/// <summary>
/// Streams a blank work order PDF. Uses the company logo from file storage,
/// accent color, and terms from CompanyPreferences.WoTerms.
/// </summary>
[HttpGet]
public async Task<IActionResult> Blank()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Forbid();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == companyId.Value && !p.IsDeleted);
// Try file-system logo first, then fall back to legacy DB bytes
byte[]? logoBytes = null;
if (!string.IsNullOrWhiteSpace(company?.LogoFilePath))
{
var (ok, content, _, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
if (ok) logoBytes = content;
}
if (logoBytes == null || logoBytes.Length == 0)
logoBytes = company?.LogoData;
var companyInfo = new CompanyInfoDto
{
CompanyName = company?.CompanyName ?? string.Empty,
Phone = company?.Phone,
Address = company?.Address,
City = company?.City,
State = company?.State,
ZipCode = company?.ZipCode,
};
var pdfBytes = GenerateBlankWorkOrderPdf(logoBytes, companyInfo, prefs?.WoAccentColor, prefs?.WoTerms);
Response.Headers["Content-Disposition"] = "inline; filename=\"Blank-Work-Order.pdf\"";
return File(pdfBytes, "application/pdf");
}
private static byte[] GenerateBlankWorkOrderPdf(
byte[]? logoData,
CompanyInfoDto companyInfo,
string? accentHex,
string? terms)
{
QuestPDF.Settings.License = LicenseType.Community;
var accent = ResolveColor(accentHex, Colors.Grey.Darken3);
const string altRow = "#F5F5F5";
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.MarginHorizontal(0.6f, Unit.Inch);
page.MarginTop(0.5f, Unit.Inch);
page.MarginBottom(0.4f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(8.5f).FontFamily("Arial"));
page.Content().Column(col =>
{
// ── HEADER: logo left, company info + drop-off date right ─
col.Item().Row(row =>
{
if (logoData != null && logoData.Length > 0)
row.ConstantItem(100).PaddingRight(10).Image(logoData).FitArea();
row.RelativeItem().AlignRight().Column(c =>
{
c.Item().Text(companyInfo.CompanyName)
.FontSize(18).Bold().FontColor(accent);
var parts = new[]
{
companyInfo.Address,
string.Join(", ", new[] { companyInfo.City, companyInfo.State }
.Where(s => !string.IsNullOrWhiteSpace(s))),
companyInfo.ZipCode,
companyInfo.Phone
}.Where(s => !string.IsNullOrWhiteSpace(s));
foreach (var p in parts)
c.Item().Text(p!).FontSize(9).FontColor(Colors.Grey.Darken1);
});
});
// ── TITLE ROW: "WORK ORDER" left, Drop Off Date field right ─
col.Item().PaddingTop(8).Row(row =>
{
row.RelativeItem().AlignBottom()
.Text("WORK ORDER")
.FontSize(24).FontColor(Colors.Grey.Lighten1).Bold();
row.ConstantItem(180).AlignBottom().Column(c =>
{
c.Item().Text("DROP OFF DATE")
.FontSize(7.5f).Bold().FontColor(accent);
c.Item().PaddingTop(2)
.LineHorizontal(1).LineColor(Colors.Grey.Darken1);
});
});
col.Item().PaddingTop(4).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
col.Item().Height(6);
// ── CLIENT INFO (no Drop Off Date column) ────────────────
col.Item().Table(table =>
{
table.ColumnsDefinition(c => c.RelativeColumn());
void HeaderCell(uint r, string label)
=> table.Cell().Row(r).Column(1)
.Background(accent).Padding(4)
.Text(label).FontSize(7.5f).Bold().FontColor(Colors.White);
void DataCell(uint r)
=> table.Cell().Row(r).Column(1)
.Border(0.5f).BorderColor(Colors.Grey.Lighten2)
.MinHeight(18).Padding(3).Text("");
HeaderCell(1, "CLIENT NAME");
DataCell(2);
HeaderCell(3, "CLIENT PHONE");
DataCell(4);
HeaderCell(5, "DUE DATE (if applicable)");
DataCell(6);
});
col.Item().Height(8);
// ── ITEMS TABLE ──────────────────────────────────────────
col.Item().Table(table =>
{
table.ColumnsDefinition(c =>
{
c.RelativeColumn(6);
c.RelativeColumn(2);
c.RelativeColumn(1.5f);
});
// Header
foreach (var (label, align) in new[] { ("PART DESCRIPTION", "left"), ("COLOR", "center"), ("QUOTE", "center") })
{
var cell = table.Cell().Background(accent).Padding(5);
var txt = cell.Text(label).FontSize(7.5f).Bold().FontColor(Colors.White);
if (align == "center") txt.AlignCenter();
}
// 12 data rows — tight height to fit page
for (int i = 0; i < 12; i++)
{
string bg = i % 2 == 1 ? altRow : Colors.White;
for (int c = 0; c < 3; c++)
table.Cell().Background(bg)
.Border(0.5f).BorderColor(Colors.Grey.Lighten2)
.MinHeight(16).Padding(3).Text("");
}
// Dark footer bar
table.Cell().ColumnSpan(3).Background(accent).MinHeight(6).Text("");
});
col.Item().Height(8);
// ── NOTES ────────────────────────────────────────────────
col.Item().Table(table =>
{
table.ColumnsDefinition(c => c.RelativeColumn());
table.Cell().Background(accent).Padding(5)
.Text("NOTES").FontSize(7.5f).Bold().FontColor(Colors.White);
table.Cell().Border(0.5f).BorderColor(Colors.Grey.Lighten2)
.MinHeight(65).Padding(3).Text("");
});
col.Item().Height(8);
// ── TERMS ────────────────────────────────────────────────
if (!string.IsNullOrWhiteSpace(terms))
{
col.Item().PaddingBottom(8)
.Text(terms).FontSize(7.5f).Italic();
}
// ── SIGNATURE LINE ───────────────────────────────────────
col.Item().Row(row =>
{
row.RelativeItem(3).Column(c =>
{
c.Item().Text("Customer Signature:").FontSize(8.5f);
c.Item().PaddingTop(2).LineHorizontal(1).LineColor(Colors.Grey.Darken1);
});
row.ConstantItem(20);
row.RelativeItem(1).Column(c =>
{
c.Item().Text("Date:").FontSize(8.5f);
c.Item().PaddingTop(2).LineHorizontal(1).LineColor(Colors.Grey.Darken1);
});
});
});
});
});
return document.GeneratePdf();
}
private static string ResolveColor(string? hex, string fallback)
{
if (string.IsNullOrWhiteSpace(hex)) return fallback;
try { _ = System.Drawing.ColorTranslator.FromHtml(hex); return hex; }
catch { return fallback; }
}
}