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.Core.Interfaces.Services; using PowderCoating.Shared.Constants; using PowderCoating.Web.Extensions; using System.Security.Claims; namespace PowderCoating.Web.Controllers; /// /// Controller for managing companies (SuperAdmin only) /// [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class CompaniesController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly UserManager _userManager; private readonly ISeedDataService _seedDataService; private readonly ICompanyListService _companyList; private readonly ICompanyDataPurgeService _companyPurge; private readonly IAuditLogService _auditLog; private readonly IInAppNotificationService _inApp; private readonly ILogger _logger; public CompaniesController( IUnitOfWork unitOfWork, IMapper mapper, UserManager userManager, ISeedDataService seedDataService, ICompanyListService companyList, ICompanyDataPurgeService companyPurge, IAuditLogService auditLog, IInAppNotificationService inApp, ILogger logger) { _unitOfWork = unitOfWork; _mapper = mapper; _userManager = userManager; _seedDataService = seedDataService; _companyList = companyList; _companyPurge = companyPurge; _auditLog = auditLog; _inApp = inApp; _logger = logger; } /// /// Lists all non-deleted tenant companies with search, sort, and pagination. Bypasses the /// global multi-tenancy query filter with IgnoreQueryFilters() because SuperAdmins /// need a cross-company view. Job, quote, and customer counts are fetched in three separate /// GROUP BY 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. /// // GET: Companies public async Task 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 (companies, totalCount) = await _companyList.GetPagedAsync( searchTerm, sortColumn, sortDirection, pageNumber, pageSize); var companyDtos = _mapper.Map>(companies); if (companyDtos.Count > 0) { var ids = companyDtos.Select(c => c.Id).ToList(); var summary = await _companyList.GetCountSummaryAsync(ids); foreach (var dto in companyDtos) { 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 (summary.WizardInfo.TryGetValue(dto.Id, out var w)) { dto.WizardCompleted = true; dto.WizardCompletedAt = w.CompletedAt; dto.WizardCompletedByName = w.CompletedByName; } } } 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()); } } /// /// Begins a SuperAdmin impersonation session for the specified company. Stores the target /// company ID and name in the server-side session so that 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. /// // POST: Companies/StartImpersonating [HttpPost] [ValidateAntiForgeryToken] public async Task 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"); } /// /// 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. /// // 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)); } /// /// Shows the full details page for a single tenant company, including its user list, /// customers, and jobs. Uses ignoreQueryFilters: true so deleted companies can /// still be inspected by a SuperAdmin without needing to manually bypass the soft-delete /// filter. /// // GET: Companies/Details/5 public async Task 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(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)); } } /// /// Renders the new-company form pre-filled with today's subscription start date and /// IsActive = true. Available subscription plans are loaded from /// SubscriptionPlanConfig so the dropdown stays in sync with whatever plans are /// currently active in the database. /// // GET: Companies/Create public async Task 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); } /// /// 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 Company.CompanyId /// (a self-referencing FK used internally). Company-specific lookup tables (job statuses, /// priorities, etc.) are seeded immediately via /// 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. /// // POST: Companies/Create [HttpPost] [ValidateAntiForgeryToken] public async Task 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(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 { _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); } } /// /// Loads the company edit form. Uses ignoreQueryFilters: true so SuperAdmins can /// edit a company that has been soft-deleted (e.g., to reactivate it). /// // GET: Companies/Edit/5 public async Task 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(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)); } } /// /// 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. /// // POST: Companies/Edit/5 [HttpPost] [ValidateAntiForgeryToken] public async Task 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); } } _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); } } /// /// Flips the IsActive flag on a company. Deactivating a company prevents its users /// from logging in (the app checks IsActive during sign-in) but keeps all data intact /// so the company can be reactivated later without data loss. For a more permanent action /// see . /// // POST: Companies/ToggleActive/5 [HttpPost] [ValidateAntiForgeryToken] public async Task 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)); } /// /// Soft-deletes a company by setting IsDeleted = true and IsActive = false, /// 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 AuditLog /// recording who performed the action and how many users were affected. This is less /// destructive than and should be the default deactivation path. /// // POST: Companies/SoftDelete/5 [HttpPost] [ValidateAntiForgeryToken] public async Task 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; 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(); await _auditLog.LogAsync(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() }); _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)); } } /// /// 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 UserManager.DeleteAsync 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 for normal deactivations. /// // POST: Companies/HardDelete/5 [HttpPost] [ValidateAntiForgeryToken] public async Task HardDelete(int id, string confirmation) { if (confirmation != "DELETE") { TempData["Error"] = "Hard delete requires typing DELETE to confirm."; return RedirectToAction(nameof(Index)); } var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true); 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 { // 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(); // Tiers 1-4: bulk delete all business data (service mirrors the original tier ordering) await _companyPurge.DeleteAllBusinessDataAsync(id, userIds); // 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: delete company record await _unitOfWork.Companies.DeleteAsync(company); await _unitOfWork.CompleteAsync(); await _auditLog.LogAsync(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() }); _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)); } } /// /// 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. /// // POST: Companies/ResetData/5 [HttpPost] [ValidateAntiForgeryToken] public async Task 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 _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true); 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 { await _companyPurge.ResetBusinessDataAsync(id); await _auditLog.LogAsync(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() }); _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 }); } } /// /// 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 CompanyUsersController. /// // GET: Companies/CreateCompanyAdmin/5 public async Task 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)); } } /// /// 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 /// ) and assigns the ASP.NET Identity /// Administrator 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. /// // POST: Companies/CreateCompanyAdmin [HttpPost] [ValidateAntiForgeryToken] public async Task 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)); } 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); } 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 ────────────────────────────────────────────────── /// /// 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. /// [HttpGet] public async Task 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 company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true); var tz = company?.TimeZone; var logs = new List(); try { var rawLogs = await _auditLog.GetUserActivityAsync(userId); 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 }); } } /// /// Safely extracts the "note" string from the JSON stored in AuditLog.NewValues /// (e.g., {"note":"Company 'Acme' is deactivated"}). Returns null if the field is /// absent or the JSON cannot be parsed, so the UI can omit the column cleanly. /// 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; } } }