1cb7a8ca4a
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>
127 lines
5.6 KiB
C#
127 lines
5.6 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Infrastructure.Data;
|
|
using PowderCoating.Shared.Constants;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
/// <summary>
|
|
/// SuperAdmin-only dashboard for inspecting Stripe webhook events logged by
|
|
/// <c>StripeWebhookController</c>. Provides filtering, search, and pagination over the
|
|
/// <c>StripeWebhookEvents</c> table so platform operators can diagnose failed events, confirm
|
|
/// delivery, and view the raw JSON payload Stripe sent. Restricted to SuperAdmin because the raw
|
|
/// event payloads may contain sensitive subscription and billing information.
|
|
/// </summary>
|
|
// Intentional exception: StripeWebhookEvents is a platform infrastructure table (not a business entity); same reasoning as StripeWebhookController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
|
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
|
public class StripeEventsController : Controller
|
|
{
|
|
private readonly ApplicationDbContext _db;
|
|
private readonly ILogger<StripeEventsController> _logger;
|
|
|
|
public StripeEventsController(ApplicationDbContext db, ILogger<StripeEventsController> logger)
|
|
{
|
|
_db = db;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lists Stripe webhook events with optional filtering by event type, status, free-text search
|
|
/// (event ID or error message), and date range. Page size is validated against an allowlist
|
|
/// (25/50/100) to prevent unbounded queries. Summary counts (total, processed, failed, last 24h)
|
|
/// are computed with separate COUNT queries rather than in-memory aggregation so they reflect
|
|
/// the unfiltered totals regardless of the current filter state — this gives operators a quick
|
|
/// health snapshot even when the filter is narrowed. The distinct event-type list for the filter
|
|
/// dropdown is also fetched from the DB so it always reflects what has actually been received
|
|
/// rather than a hardcoded enum.
|
|
/// </summary>
|
|
public async Task<IActionResult> Index(
|
|
string? eventType,
|
|
string? status,
|
|
string? search,
|
|
DateTime? from,
|
|
DateTime? to,
|
|
int page = 1,
|
|
int pageSize = 50)
|
|
{
|
|
pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50;
|
|
page = Math.Max(1, page);
|
|
|
|
var query = _db.StripeWebhookEvents.AsNoTracking().AsQueryable();
|
|
|
|
if (!string.IsNullOrWhiteSpace(eventType))
|
|
query = query.Where(e => e.EventType == eventType);
|
|
|
|
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<StripeWebhookEventStatus>(status, out var statusEnum))
|
|
query = query.Where(e => e.Status == statusEnum);
|
|
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
query = query.Where(e => e.EventId.Contains(search) || (e.ErrorMessage != null && e.ErrorMessage.Contains(search)));
|
|
|
|
if (from.HasValue)
|
|
query = query.Where(e => e.ReceivedAt >= from.Value.Date);
|
|
|
|
if (to.HasValue)
|
|
query = query.Where(e => e.ReceivedAt < to.Value.Date.AddDays(1));
|
|
|
|
query = query.OrderByDescending(e => e.ReceivedAt);
|
|
|
|
var totalCount = await query.CountAsync();
|
|
var events = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
|
|
|
// Distinct event types for filter dropdown
|
|
var eventTypes = await _db.StripeWebhookEvents.AsNoTracking()
|
|
.Select(e => e.EventType)
|
|
.Where(t => t != string.Empty)
|
|
.Distinct()
|
|
.OrderBy(t => t)
|
|
.ToListAsync();
|
|
|
|
// Summary counts
|
|
ViewBag.TotalCount = await _db.StripeWebhookEvents.CountAsync();
|
|
ViewBag.ProcessedCount = await _db.StripeWebhookEvents.CountAsync(e => e.Status == StripeWebhookEventStatus.Processed);
|
|
ViewBag.FailedCount = await _db.StripeWebhookEvents.CountAsync(e => e.Status == StripeWebhookEventStatus.Failed);
|
|
ViewBag.Last24hCount = await _db.StripeWebhookEvents.CountAsync(e => e.ReceivedAt >= DateTime.UtcNow.AddHours(-24));
|
|
|
|
ViewBag.EventTypes = eventTypes;
|
|
ViewBag.EventTypeFilter = eventType;
|
|
ViewBag.StatusFilter = status;
|
|
ViewBag.Search = search;
|
|
ViewBag.From = from?.ToString("yyyy-MM-dd");
|
|
ViewBag.To = to?.ToString("yyyy-MM-dd");
|
|
ViewBag.Page = page;
|
|
ViewBag.PageSize = pageSize;
|
|
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
|
ViewBag.FilteredCount = totalCount;
|
|
|
|
return View(events);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shows the full details of a single Stripe webhook event including the raw JSON payload.
|
|
/// Pretty-prints the JSON using <c>System.Text.Json.JsonDocument</c> for readability; falls
|
|
/// back to the original raw string if parsing fails (e.g. for events logged before JSON
|
|
/// validation was added). Returns 404 for unknown event IDs rather than throwing.
|
|
/// </summary>
|
|
public async Task<IActionResult> Details(long id)
|
|
{
|
|
var evt = await _db.StripeWebhookEvents.AsNoTracking().FirstOrDefaultAsync(e => e.Id == id);
|
|
if (evt == null) return NotFound();
|
|
|
|
// Try to format the JSON for display
|
|
string formattedJson = evt.RawJson;
|
|
try
|
|
{
|
|
var doc = System.Text.Json.JsonDocument.Parse(evt.RawJson);
|
|
formattedJson = System.Text.Json.JsonSerializer.Serialize(doc.RootElement,
|
|
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
|
}
|
|
catch { /* keep original if parse fails */ }
|
|
|
|
ViewBag.FormattedJson = formattedJson;
|
|
return View(evt);
|
|
}
|
|
}
|