Initial commit
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user