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; /// /// SuperAdmin-only dashboard for inspecting Stripe webhook events logged by /// StripeWebhookController. Provides filtering, search, and pagination over the /// StripeWebhookEvents 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. /// // 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 _logger; public StripeEventsController(ApplicationDbContext db, ILogger logger) { _db = db; _logger = logger; } /// /// 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. /// public async Task 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(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); } /// /// Shows the full details of a single Stripe webhook event including the raw JSON payload. /// Pretty-prints the JSON using System.Text.Json.JsonDocument 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. /// public async Task 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); } }