Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,196 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Notification;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Company-scoped notification log viewer accessible to users with the
/// <c>CanManageJobs</c> policy (Managers and above within a company).
/// The platform-wide equivalent for SuperAdmins lives in
/// <see cref="PlatformNotificationsController"/>.
/// Uses <see cref="ApplicationDbContext"/> directly to enable LINQ projections
/// that avoid loading full message bodies into memory on the list page.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public class NotificationLogsController : Controller
{
private readonly ApplicationDbContext _context;
private readonly ILogger<NotificationLogsController> _logger;
public NotificationLogsController(ApplicationDbContext context, ILogger<NotificationLogsController> logger)
{
_context = context;
_logger = logger;
}
/// <summary>
/// Displays a paginated, filterable list of notification log entries for the
/// current company. Supports filtering by free-text search, channel (Email/SMS),
/// delivery status, notification type, and an optional job context.
/// <para>
/// The <c>pageSize</c> is validated against an allowlist (10/25/50/100) and
/// defaults to 25 to prevent callers from requesting arbitrarily large result sets.
/// Sorting defaults to most-recent-first (<c>SentAt DESC</c>) because operators
/// almost always want to see the latest delivery attempts first.
/// </para>
/// </summary>
// GET: /NotificationLogs
public async Task<IActionResult> Index(
string? searchTerm,
string? channelFilter,
string? statusFilter,
string? typeFilter,
int? jobId,
string? sortColumn,
string sortDirection = "desc",
int pageNumber = 1,
int pageSize = 25)
{
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
var query = _context.NotificationLogs
.AsNoTracking()
.Include(n => n.Customer)
.Include(n => n.Job)
.Include(n => n.Quote)
.AsQueryable();
// Filters
if (jobId.HasValue)
query = query.Where(n => n.JobId == jobId.Value);
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
query = query.Where(n =>
n.RecipientName.ToLower().Contains(search) ||
n.Recipient.ToLower().Contains(search) ||
(n.Subject != null && n.Subject.ToLower().Contains(search)) ||
(n.Job != null && n.Job.JobNumber.ToLower().Contains(search)) ||
(n.Quote != null && n.Quote.QuoteNumber.ToLower().Contains(search)));
}
if (!string.IsNullOrWhiteSpace(channelFilter) && Enum.TryParse<NotificationChannel>(channelFilter, out var channel))
query = query.Where(n => n.Channel == channel);
if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse<NotificationStatus>(statusFilter, out var status))
query = query.Where(n => n.Status == status);
if (!string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<NotificationType>(typeFilter, out var type))
query = query.Where(n => n.NotificationType == type);
// Sorting
query = (sortColumn ?? "SentAt") switch
{
"RecipientName" => sortDirection == "asc" ? query.OrderBy(n => n.RecipientName) : query.OrderByDescending(n => n.RecipientName),
"Channel" => sortDirection == "asc" ? query.OrderBy(n => n.Channel) : query.OrderByDescending(n => n.Channel),
"Status" => sortDirection == "asc" ? query.OrderBy(n => n.Status) : query.OrderByDescending(n => n.Status),
"Type" => sortDirection == "asc" ? query.OrderBy(n => n.NotificationType) : query.OrderByDescending(n => n.NotificationType),
_ => sortDirection == "asc" ? query.OrderBy(n => n.SentAt) : query.OrderByDescending(n => n.SentAt)
};
var totalCount = await query.CountAsync();
var items = await query
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(n => new NotificationLogDto
{
Id = n.Id,
Channel = n.Channel,
NotificationType = n.NotificationType,
Status = n.Status,
RecipientName = n.RecipientName,
Recipient = n.Recipient,
Subject = n.Subject,
Message = n.Message,
ErrorMessage = n.ErrorMessage,
SentAt = n.SentAt,
CustomerId = n.CustomerId,
JobId = n.JobId,
QuoteId = n.QuoteId,
JobNumber = n.Job != null ? n.Job.JobNumber : null,
QuoteNumber = n.Quote != null ? n.Quote.QuoteNumber : null,
CustomerName = n.Customer != null
? (n.Customer.CompanyName ?? $"{n.Customer.ContactFirstName} {n.Customer.ContactLastName}".Trim())
: null
})
.ToListAsync();
var pagedResult = new PagedResult<NotificationLogDto>
{
Items = items,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
ViewBag.SearchTerm = searchTerm;
ViewBag.ChannelFilter = channelFilter;
ViewBag.StatusFilter = statusFilter;
ViewBag.TypeFilter = typeFilter;
ViewBag.JobId = jobId;
ViewBag.SortColumn = sortColumn ?? "SentAt";
ViewBag.SortDirection = sortDirection;
return View(pagedResult);
}
/// <summary>
/// Displays the full details of a single notification log entry including the
/// complete message body and any error message, which are omitted from the list
/// view to keep response sizes small. Loads related Customer, Job, and Quote
/// navigation properties to resolve display names.
/// </summary>
// GET: /NotificationLogs/Details/5
public async Task<IActionResult> Details(int id)
{
try
{
var log = await _context.NotificationLogs
.AsNoTracking()
.Include(n => n.Customer)
.Include(n => n.Job)
.Include(n => n.Quote)
.FirstOrDefaultAsync(n => n.Id == id);
if (log == null) return NotFound();
var dto = new NotificationLogDto
{
Id = log.Id,
Channel = log.Channel,
NotificationType = log.NotificationType,
Status = log.Status,
RecipientName = log.RecipientName,
Recipient = log.Recipient,
Subject = log.Subject,
Message = log.Message,
ErrorMessage = log.ErrorMessage,
SentAt = log.SentAt,
CustomerId = log.CustomerId,
JobId = log.JobId,
QuoteId = log.QuoteId,
JobNumber = log.Job?.JobNumber,
QuoteNumber = log.Quote?.QuoteNumber,
CustomerName = log.Customer != null
? (log.Customer.CompanyName ?? $"{log.Customer.ContactFirstName} {log.Customer.ContactLastName}".Trim())
: null
};
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading notification log {Id}", id);
return RedirectToAction(nameof(Index));
}
}
}