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