Phases 3 & 4: Complete data access architecture migration

Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers,
routing all data access through IUnitOfWork. Added IPlainRepository<T> for
the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote)
that intentionally don't extend BaseEntity and therefore can't use the
constrained IRepository<T>. Added permanent-exception comments to the 18
controllers that legitimately retain direct DbContext access (Identity infra,
cross-tenant platform ops, bulk streaming exports).

Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup
gate that reflects over every Controller subclass and throws at boot if any
non-exempt controller injects ApplicationDbContext. The app cannot start with
a violation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 09:17:29 -04:00
parent 90bc0d965f
commit 1cb7a8ca4a
72 changed files with 9060 additions and 2323 deletions
@@ -7,7 +7,7 @@ using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Extensions;
using System.Security.Claims;
@@ -24,7 +24,9 @@ public class CompaniesController : Controller
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ISeedDataService _seedDataService;
private readonly ApplicationDbContext _context;
private readonly ICompanyListService _companyList;
private readonly ICompanyDataPurgeService _companyPurge;
private readonly IAuditLogService _auditLog;
private readonly IInAppNotificationService _inApp;
private readonly ILogger<CompaniesController> _logger;
@@ -33,7 +35,9 @@ public class CompaniesController : Controller
IMapper mapper,
UserManager<ApplicationUser> userManager,
ISeedDataService seedDataService,
ApplicationDbContext context,
ICompanyListService companyList,
ICompanyDataPurgeService companyPurge,
IAuditLogService auditLog,
IInAppNotificationService inApp,
ILogger<CompaniesController> logger)
{
@@ -41,7 +45,9 @@ public class CompaniesController : Controller
_mapper = mapper;
_userManager = userManager;
_seedDataService = seedDataService;
_context = context;
_companyList = companyList;
_companyPurge = companyPurge;
_auditLog = auditLog;
_inApp = inApp;
_logger = logger;
}
@@ -67,88 +73,27 @@ public class CompaniesController : Controller
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
var query = _context.Companies
.AsNoTracking()
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var s = searchTerm.ToLower();
query = query.Where(c =>
c.CompanyName.ToLower().Contains(s) ||
(c.CompanyCode != null && c.CompanyCode.ToLower().Contains(s)) ||
(c.PrimaryContactEmail != null && c.PrimaryContactEmail.ToLower().Contains(s)) ||
(c.Phone != null && c.Phone.ToLower().Contains(s)));
}
query = (sortColumn, sortDirection == "asc") switch
{
("CompanyName", true) => query.OrderBy(c => c.CompanyName),
("CompanyName", false) => query.OrderByDescending(c => c.CompanyName),
("Plan", true) => query.OrderBy(c => c.SubscriptionPlan),
("Plan", false) => query.OrderByDescending(c => c.SubscriptionPlan),
("Status", true) => query.OrderBy(c => c.IsActive),
("Status", false) => query.OrderByDescending(c => c.IsActive),
("Created", true) => query.OrderBy(c => c.CreatedAt),
("Created", false) => query.OrderByDescending(c => c.CreatedAt),
_ => query.OrderBy(c => c.CompanyName)
};
var totalCount = await query.CountAsync();
var companies = await query
.Include(c => c.Users)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var (companies, totalCount) = await _companyList.GetPagedAsync(
searchTerm, sortColumn, sortDirection, pageNumber, pageSize);
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
// Populate job/quote/customer counts efficiently via group queries
if (companyDtos.Any())
if (companyDtos.Count > 0)
{
var ids = companyDtos.Select(c => c.Id).ToList();
var jobCounts = await _context.Jobs.IgnoreQueryFilters()
.Where(j => ids.Contains(j.CompanyId) && !j.IsDeleted)
.GroupBy(j => j.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var quoteCounts = await _context.Quotes.IgnoreQueryFilters()
.Where(q => ids.Contains(q.CompanyId) && !q.IsDeleted)
.GroupBy(q => q.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var customerCounts = await _context.Customers.IgnoreQueryFilters()
.Where(c => ids.Contains(c.CompanyId) && !c.IsDeleted)
.GroupBy(c => c.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var wizardData = await _context.CompanyPreferences.IgnoreQueryFilters()
.Where(p => ids.Contains(p.CompanyId) && p.SetupWizardCompleted)
.Select(p => new
{
p.CompanyId,
p.SetupWizardCompletedAt,
p.SetupWizardCompletedByName
})
.ToDictionaryAsync(x => x.CompanyId);
var summary = await _companyList.GetCountSummaryAsync(ids);
foreach (var dto in companyDtos)
{
dto.JobCount = jobCounts.GetValueOrDefault(dto.Id, 0);
dto.QuoteCount = quoteCounts.GetValueOrDefault(dto.Id, 0);
dto.CustomerCount = customerCounts.GetValueOrDefault(dto.Id, 0);
dto.JobCount = summary.JobCounts.GetValueOrDefault(dto.Id, 0);
dto.QuoteCount = summary.QuoteCounts.GetValueOrDefault(dto.Id, 0);
dto.CustomerCount = summary.CustomerCounts.GetValueOrDefault(dto.Id, 0);
if (wizardData.TryGetValue(dto.Id, out var w))
if (summary.WizardInfo.TryGetValue(dto.Id, out var w))
{
dto.WizardCompleted = true;
dto.WizardCompletedAt = w.SetupWizardCompletedAt;
dto.WizardCompletedByName = w.SetupWizardCompletedByName;
dto.WizardCompletedAt = w.CompletedAt;
dto.WizardCompletedByName = w.CompletedByName;
}
}
}
@@ -380,8 +325,6 @@ public class CompaniesController : Controller
}
else
{
// If user creation failed, we should consider rolling back company creation
// For now, log the error and inform the user
_logger.LogError("Failed to create admin user for company {CompanyName}: {Errors}",
company.CompanyName, string.Join(", ", result.Errors.Select(e => e.Description)));
@@ -441,14 +384,10 @@ public class CompaniesController : Controller
public async Task<IActionResult> Edit(int id, UpdateCompanyDto model)
{
if (id != model.Id)
{
return NotFound();
}
if (!ModelState.IsValid)
{
return View(model);
}
try
{
@@ -473,9 +412,7 @@ public class CompaniesController : Controller
}
}
// Update company properties
_mapper.Map(model, company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyName} updated successfully by {User}",
@@ -560,7 +497,6 @@ public class CompaniesController : Controller
var companyName = company.CompanyName;
var userCount = company.Users.Count;
// Soft-delete the company and deactivate all users
company.IsDeleted = true;
company.IsActive = false;
company.UpdatedAt = DateTime.UtcNow;
@@ -573,8 +509,7 @@ public class CompaniesController : Controller
await _unitOfWork.CompleteAsync();
// Write audit log
_context.AuditLogs.Add(new AuditLog
await _auditLog.LogAsync(new AuditLog
{
UserId = adminUserId,
UserName = adminName,
@@ -588,7 +523,6 @@ public class CompaniesController : Controller
Timestamp = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
await _context.SaveChangesAsync();
_logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) soft-deleted by {User}",
companyName, id, adminName);
@@ -625,8 +559,7 @@ public class CompaniesController : Controller
return RedirectToAction(nameof(Index));
}
var company = await _context.Companies.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == id);
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
@@ -640,91 +573,25 @@ public class CompaniesController : Controller
try
{
// ── Tier 1: Leaf children (must go before their parents) ─────────────
// JobItem children
await _context.JobItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// QuoteItem children
await _context.QuoteItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// AnnouncementDismissals (no CompanyId — delete by user or company-targeted announcement)
// Load user IDs first — needed for announcement-dismissal cleanup in the purge service
var userIds = await _userManager.Users.IgnoreQueryFilters()
.Where(u => u.CompanyId == id).Select(u => u.Id).ToListAsync();
var announcementIds = await _context.Announcements
.Where(a => a.TargetCompanyId == id).Select(a => a.Id).ToListAsync();
await _context.AnnouncementDismissals.IgnoreQueryFilters()
.Where(x => userIds.Contains(x.UserId) || announcementIds.Contains(x.AnnouncementId))
.ExecuteDeleteAsync();
// ── Tier 2: Mid-level children ────────────────────────────────────────
await _context.JobItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobStatusHistory.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobPhotos.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobNotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobDailyPriorities.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CustomerNotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.MaintenanceRecords.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.BillLineItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.BillPayments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Payments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InvoiceItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuotePrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// Tiers 1-4: bulk delete all business data (service mirrors the original tier ordering)
await _companyPurge.DeleteAllBusinessDataAsync(id, userIds);
// ── Tier 3: Top-level company entities ───────────────────────────────
// Order matters: child-side of FK must be deleted before parent-side.
// Invoices/Appointments → Customers; Bills/Expenses → Vendors
await _context.Invoices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Appointments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Jobs.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Quotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Customers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Bills.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Expenses.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Vendors.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Equipment.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CatalogItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// Announcements are platform-wide; only delete company-targeted ones (TargetCompanyId)
await _context.Announcements.Where(x => x.TargetCompanyId == id).ExecuteDeleteAsync();
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// ── Tier 4: Company configs and lookup tables ─────────────────────────
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobPriorityLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.AppointmentStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.AppointmentTypeLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PricingTiers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CompanyOperatingCosts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CompanyPreferences.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// ── Tier 5: Users (via Identity to cascade AspNetUser* tables) ────────
// Tier 5: delete Identity users so AspNetUser* tables cascade correctly
var users = await _userManager.Users.IgnoreQueryFilters()
.Where(u => u.CompanyId == id).ToListAsync();
var userCount = users.Count;
foreach (var user in users)
await _userManager.DeleteAsync(user);
// ── Tier 6: Company record ────────────────────────────────────────────
await _context.Companies.IgnoreQueryFilters().Where(c => c.Id == id).ExecuteDeleteAsync();
// Tier 6: delete company record
await _unitOfWork.Companies.DeleteAsync(company);
await _unitOfWork.CompleteAsync();
// Write audit log (use platform default company context — no companyId since it's gone)
_context.AuditLogs.Add(new AuditLog
await _auditLog.LogAsync(new AuditLog
{
UserId = adminUserId,
UserName = adminName,
@@ -738,7 +605,6 @@ public class CompaniesController : Controller
Timestamp = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
await _context.SaveChangesAsync();
_logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) HARD DELETED by {User}. {UserCount} users removed.",
companyName, id, adminName, userCount);
@@ -764,8 +630,6 @@ public class CompaniesController : Controller
/// to record the action. This operation is irreversible.
/// </summary>
// POST: Companies/ResetData/5
// Permanently hard-deletes all business data for a company while keeping the company record,
// its users, operating costs, preferences, and lookup tables intact.
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetData(int id, string confirmation)
@@ -776,8 +640,7 @@ public class CompaniesController : Controller
return RedirectToAction(nameof(Details), new { id });
}
var company = await _context.Companies.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == id);
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
@@ -791,94 +654,9 @@ public class CompaniesController : Controller
try
{
// ── Tier 0: Grandchildren ─────────────────────────────────────────────
await _context.JobTemplateItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobTemplateItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.GiftCertificateRedemptions .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CreditMemoApplications .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.OvenBatchItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _companyPurge.ResetBusinessDataAsync(id);
// AnnouncementDismissals for company-targeted announcements
var announcementIds = await _context.Announcements
.Where(a => a.TargetCompanyId == id).Select(a => a.Id).ToListAsync();
if (announcementIds.Any())
await _context.AnnouncementDismissals.IgnoreQueryFilters()
.Where(x => announcementIds.Contains(x.AnnouncementId))
.ExecuteDeleteAsync();
// ── Tier 1: Children ──────────────────────────────────────────────────
await _context.JobTemplateItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobStatusHistory .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobChangeHistories .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobPhotos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobNotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobDailyPriorities .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobTimeEntries .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.ReworkRecords .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuotePrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuotePhotos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CustomerNotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.MaintenanceRecords .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.BillLineItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.BillPayments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Payments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Deposits .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InvoiceItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PurchaseOrderItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.AiItemPredictions .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PowderUsageLogs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.ShopWorkerRoleCosts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.OvenBatches .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Refunds .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CreditMemos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.GiftCertificates .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// ── Tier 2: Top-level business entities ──────────────────────────────
// Order matters: child-side of FK must be deleted before parent-side.
// Invoices/Appointments → Customers; Bills/PurchaseOrders/Expenses → Vendors
await _context.Invoices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Appointments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Jobs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobTemplates .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Quotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Customers .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Bills .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PurchaseOrders .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Expenses .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Vendors .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CatalogItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Equipment .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.OvenCosts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Accounts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.NotificationLogs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.ShopWorkers .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// Company-targeted announcements only (platform-wide announcements are left alone)
await _context.Announcements.Where(x => x.TargetCompanyId == id).ExecuteDeleteAsync();
// Reset QB migration wizard progress
var prefs = await _context.CompanyPreferences.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.CompanyId == id);
if (prefs?.QbMigrationStateJson != null)
{
prefs.QbMigrationStateJson = null;
prefs.UpdatedAt = DateTime.UtcNow;
}
// Audit log
_context.AuditLogs.Add(new AuditLog
await _auditLog.LogAsync(new AuditLog
{
UserId = adminUserId,
UserName = adminName,
@@ -892,7 +670,6 @@ public class CompaniesController : Controller
Timestamp = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
await _context.SaveChangesAsync();
_logger.LogWarning(
"Company {CompanyName} (ID:{CompanyId}) ALL BUSINESS DATA RESET by {User}.",
@@ -960,9 +737,7 @@ public class CompaniesController : Controller
{
var company = await _unitOfWork.Companies.GetByIdAsync(model.CompanyId, ignoreQueryFilters: true);
if (company != null)
{
model.CompanyName = company.CompanyName;
}
return View(model);
}
@@ -976,7 +751,6 @@ public class CompaniesController : Controller
return RedirectToAction(nameof(Index));
}
// Check if user already exists
var existingUser = await _userManager.FindByEmailAsync(model.Email);
if (existingUser != null)
{
@@ -985,7 +759,6 @@ public class CompaniesController : Controller
return View(model);
}
// Create admin user for the company
var adminUser = new ApplicationUser
{
UserName = model.Email,
@@ -1018,9 +791,7 @@ public class CompaniesController : Controller
else
{
foreach (var error in result.Errors)
{
ModelState.AddModelError("", error.Description);
}
model.CompanyName = company.CompanyName;
return View(model);
}
@@ -1054,21 +825,13 @@ public class CompaniesController : Controller
return NotFound(new { error = "User not found." });
// Use the viewed company's timezone so timestamps match the tenant's local time
var tz = await _context.Companies
.Where(c => c.Id == companyId)
.Select(c => c.TimeZone)
.FirstOrDefaultAsync();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
var tz = company?.TimeZone;
var logs = new List<dynamic>();
try
{
var rawLogs = await _context.AuditLogs
.AsNoTracking()
.Where(l => l.UserId == userId && l.EntityType == "ApplicationUser")
.OrderByDescending(l => l.Timestamp)
.Take(50)
.Select(l => new { l.Action, l.IpAddress, l.Timestamp, l.NewValues })
.ToListAsync();
var rawLogs = await _auditLog.GetUserActivityAsync(userId);
logs = rawLogs.Select(l => (dynamic)new
{