Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/BugReportController.cs
T
spouliot 1cb7a8ca4a Phases 3 & 4: Complete data access architecture migration
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>
2026-04-28 09:17:29 -04:00

438 lines
19 KiB
C#

using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.DTOs.BugReport;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize]
public class BugReportController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IEmailService _emailService;
private readonly IAdminNotificationService _adminNotification;
private readonly IAzureBlobStorageService _blobService;
private readonly StorageSettings _storageSettings;
private readonly ILogger<BugReportController> _logger;
private static readonly string[] AllowedMediaTypes = [
".jpg", ".jpeg", ".png", ".gif", ".webp",
".mp4", ".mov", ".avi", ".mkv", ".webm"
];
private const long MaxMediaSizeBytes = 100 * 1024 * 1024; // 100 MB
public BugReportController(
IUnitOfWork unitOfWork,
IMapper mapper,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
IEmailService emailService,
IAdminNotificationService adminNotification,
IAzureBlobStorageService blobService,
IOptions<StorageSettings> storageSettings,
ILogger<BugReportController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_tenantContext = tenantContext;
_userManager = userManager;
_emailService = emailService;
_adminNotification = adminNotification;
_blobService = blobService;
_storageSettings = storageSettings.Value;
_logger = logger;
}
/// <summary>
/// Renders the bug-report submission form for the currently authenticated user.
/// Returns an empty <see cref="CreateBugReportDto"/> so the view has a bound model
/// with sensible defaults (e.g. Priority pre-set to Normal).
/// </summary>
// GET: /BugReport/Submit
[HttpGet]
public IActionResult Submit()
{
return View(new CreateBugReportDto());
}
/// <summary>
/// Accepts a bug report form submission, persists the report, uploads any media
/// attachments to Azure Blob Storage, and notifies platform admins.
/// <para>
/// Design decisions:
/// <list type="bullet">
/// <item>The request-size limit is set to 110 MB (slightly above the 100 MB per-file
/// cap) to allow multi-file payloads while still enforcing a hard ceiling.</item>
/// <item>The bug report record is saved to the database <em>before</em> attachment
/// upload so the auto-generated <c>BugReport.Id</c> is available as the blob
/// folder prefix: <c>{companyId}/{bugReportId}/{guid}{ext}</c>.</item>
/// <item>Individual upload failures are logged as warnings but do not abort the
/// overall submission — a partial upload is still a valid report.</item>
/// <item>Attachments are tracked in <c>BugReportAttachment</c> rows via the raw
/// <c>ApplicationDbContext</c> (not IUnitOfWork) because they need a
/// separate <c>SaveChangesAsync()</c> call after all blobs are uploaded.</item>
/// </list>
/// </para>
/// </summary>
// POST: /BugReport/Submit
[HttpPost]
[ValidateAntiForgeryToken]
[RequestSizeLimit(110 * 1024 * 1024)]
public async Task<IActionResult> Submit(CreateBugReportDto dto, List<IFormFile>? attachments)
{
if (!ModelState.IsValid)
return View(dto);
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Your account is not associated with a company. Please contact support.";
return RedirectToAction("Index", "Home");
}
var user = await _userManager.GetUserAsync(User);
var userName = user != null ? user.FullName : User.Identity?.Name ?? "Unknown";
// Resolve company name
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
var companyName = company?.CompanyName ?? $"Company #{companyId}";
var bugReport = _mapper.Map<BugReport>(dto);
bugReport.CompanyId = companyId.Value;
bugReport.CompanyName = companyName;
bugReport.SubmittedByUserId = user?.Id ?? string.Empty;
bugReport.SubmittedByUserName = userName;
bugReport.Status = BugReportStatus.New;
await _unitOfWork.BugReports.AddAsync(bugReport);
await _unitOfWork.CompleteAsync();
// Upload attachments
var uploadedCount = 0;
if (attachments != null && attachments.Count > 0)
{
foreach (var file in attachments)
{
if (file == null || file.Length == 0) continue;
if (file.Length > MaxMediaSizeBytes) continue;
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedMediaTypes.Contains(ext)) continue;
var blobPath = $"{companyId}/{bugReport.Id}/{Guid.NewGuid():N}{ext}";
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(
_storageSettings.Containers.BugReportMedia, blobPath, stream, file.ContentType);
if (result.Success)
{
var attachment = new BugReportAttachment
{
BugReportId = bugReport.Id,
CompanyId = companyId.Value,
BlobPath = blobPath,
FileName = file.FileName,
ContentType = file.ContentType,
FileSizeBytes = file.Length
};
await _unitOfWork.BugReportAttachments.AddAsync(attachment);
uploadedCount++;
}
else
{
_logger.LogWarning("Failed to upload bug report attachment {FileName}: {Error}",
file.FileName, result.ErrorMessage);
}
}
if (uploadedCount > 0)
await _unitOfWork.CompleteAsync();
}
_logger.LogInformation("Bug report #{Id} submitted by {UserName} ({Company}): {Title} with {AttachmentCount} attachment(s)",
bugReport.Id, userName, companyName, dto.Title, uploadedCount);
await _adminNotification.NotifyBugReportSubmittedAsync(
bugReport.Id, dto.Title, dto.Description,
dto.Priority.ToString(), userName, companyName);
TempData["SuccessMessage"] = "Your bug report has been submitted. Thank you for helping us improve!";
return RedirectToAction(nameof(Submit));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error submitting bug report");
TempData["ErrorMessage"] = "An error occurred while submitting your report. Please try again.";
return View(dto);
}
}
/// <summary>
/// Displays a paginated, filterable list of all bug reports across every tenant
/// (SuperAdmin only).
/// <para>
/// Uses <c>IgnoreQueryFilters()</c> to bypass the global soft-delete and
/// multi-tenancy filters so SuperAdmins see reports from all companies.
/// The manual <c>!r.IsDeleted</c> predicate is intentionally re-added so that
/// physically deleted records (which should be rare) are still excluded.
/// Sorting is resolved with a switch expression covering the five UI columns;
/// any unrecognised column falls back to descending <c>CreatedAt</c>.
/// </para>
/// </summary>
// GET: /BugReport/Index — SuperAdmin only
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> Index(
string? searchTerm,
string? statusFilter,
string? priorityFilter,
string sortColumn = "CreatedAt",
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 allReports = (await _unitOfWork.BugReports.GetAllAsync(ignoreQueryFilters: true))
.AsEnumerable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
allReports = allReports.Where(r =>
r.Title.ToLower().Contains(search) ||
r.Description.ToLower().Contains(search) ||
r.SubmittedByUserName.ToLower().Contains(search));
}
if (!string.IsNullOrWhiteSpace(statusFilter) &&
Enum.TryParse<BugReportStatus>(statusFilter, out var status))
allReports = allReports.Where(r => r.Status == status);
if (!string.IsNullOrWhiteSpace(priorityFilter) &&
Enum.TryParse<BugReportPriority>(priorityFilter, out var priority))
allReports = allReports.Where(r => r.Priority == priority);
allReports = (sortColumn, sortDirection == "asc") switch
{
("Title", true) => allReports.OrderBy(r => r.Title),
("Title", false) => allReports.OrderByDescending(r => r.Title),
("Status", true) => allReports.OrderBy(r => r.Status),
("Status", false) => allReports.OrderByDescending(r => r.Status),
("Priority", true) => allReports.OrderBy(r => r.Priority),
("Priority", false) => allReports.OrderByDescending(r => r.Priority),
("Submitted", true) => allReports.OrderBy(r => r.SubmittedByUserName),
("Submitted", false) => allReports.OrderByDescending(r => r.SubmittedByUserName),
(_, true) => allReports.OrderBy(r => r.CreatedAt),
_ => allReports.OrderByDescending(r => r.CreatedAt)
};
var totalCount = allReports.Count();
var items = allReports
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToList();
var dtos = _mapper.Map<List<BugReportDto>>(items);
ViewBag.SearchTerm = searchTerm;
ViewBag.StatusFilter = statusFilter;
ViewBag.PriorityFilter = priorityFilter;
ViewBag.SortColumn = sortColumn;
ViewBag.SortDirection = sortDirection;
ViewBag.TotalCount = totalCount;
ViewBag.PageNumber = pageNumber;
ViewBag.PageSize = pageSize;
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
return View(dtos);
}
/// <summary>
/// Loads a bug report and its attachments for editing by a SuperAdmin.
/// <para>
/// Both the report and its attachment rows are fetched with
/// <c>IgnoreQueryFilters()</c> because SuperAdmins need visibility into
/// cross-tenant records. The explicit <c>IsDeleted</c> guard prevents
/// editing a report that has been soft-deleted.
/// Attachments are loaded separately (not via EF navigation) so they can be
/// displayed in the Edit view without requiring a full Include chain.
/// </para>
/// </summary>
// GET: /BugReport/Edit/5 — SuperAdmin only
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
[HttpGet]
public async Task<IActionResult> Edit(int id)
{
var bugReport = await _unitOfWork.BugReports.GetByIdAsync(id, ignoreQueryFilters: true);
if (bugReport == null || bugReport.IsDeleted)
return NotFound();
var dto = _mapper.Map<EditBugReportDto>(bugReport);
var attachments = (await _unitOfWork.BugReportAttachments.FindAsync(
a => a.BugReportId == id && !a.IsDeleted, ignoreQueryFilters: true))
.OrderBy(a => a.CreatedAt)
.ToList();
dto.Attachments = _mapper.Map<List<BugReportAttachmentDto>>(attachments);
return View(dto);
}
/// <summary>
/// Streams a bug-report attachment from Azure Blob Storage directly to the browser.
/// Returns a <c>FileResult</c> with the original filename and content-type so the
/// browser can render images inline or prompt a download for other media types.
/// <para>
/// Access is restricted to SuperAdmin because attachment blobs are stored outside
/// the tenant-scoped container and could contain sensitive reproduction screenshots.
/// The blob path is resolved from the <c>BugReportAttachment</c> record (not from a
/// user-supplied path) to prevent path-traversal attacks.
/// </para>
/// </summary>
// GET: /BugReport/Attachment/5 — SuperAdmin only — streams blob to browser
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
[HttpGet]
public async Task<IActionResult> Attachment(int id)
{
var attachment = await _unitOfWork.BugReportAttachments.GetByIdAsync(id, ignoreQueryFilters: true);
if (attachment == null)
return NotFound();
var download = await _blobService.DownloadAsync(
_storageSettings.Containers.BugReportMedia, attachment.BlobPath);
if (!download.Success)
return NotFound();
return File(download.Content, download.ContentType, attachment.FileName);
}
/// <summary>
/// Persists status/priority/resolution edits to a bug report and, when the report
/// transitions to <see cref="BugReportStatus.Completed"/> for the first time,
/// emails the original submitter with the resolution notes.
/// <para>
/// Key business rules:
/// <list type="bullet">
/// <item><c>ResolvedAt</c> / <c>ResolvedBy</c> are stamped only on the first
/// transition to <c>Completed</c> or <c>Cancelled</c> — subsequent saves
/// do not overwrite those audit fields.</item>
/// <item>The resolution email is gated on three conditions: the status just
/// changed to <c>Completed</c>, resolution notes are non-empty, and the
/// submitter's user ID is still resolvable in Identity. Email failures
/// are logged as warnings but do not roll back the save.</item>
/// <item>The route-id / dto.Id equality check prevents CSRF-style ID-swap
/// attacks where a forged form targets a different record.</item>
/// </list>
/// </para>
/// </summary>
// POST: /BugReport/Edit/5 — SuperAdmin only
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, EditBugReportDto dto)
{
if (id != dto.Id)
return BadRequest();
if (!ModelState.IsValid)
return View(dto);
try
{
var bugReport = await _unitOfWork.BugReports.GetByIdAsync(id, ignoreQueryFilters: true);
if (bugReport == null || bugReport.IsDeleted)
return NotFound();
var previousStatus = bugReport.Status;
bugReport.Title = dto.Title;
bugReport.Description = dto.Description;
bugReport.Priority = dto.Priority;
bugReport.Status = dto.Status;
bugReport.ResolutionNotes = dto.ResolutionNotes;
bugReport.UpdatedAt = DateTime.UtcNow;
bugReport.UpdatedBy = User.Identity?.Name;
var justCompleted = dto.Status == BugReportStatus.Completed
&& previousStatus != BugReportStatus.Completed;
if (justCompleted || (dto.Status == BugReportStatus.Cancelled
&& previousStatus is not BugReportStatus.Completed and not BugReportStatus.Cancelled))
{
bugReport.ResolvedAt = DateTime.UtcNow;
bugReport.ResolvedBy = User.Identity?.Name;
}
await _unitOfWork.BugReports.UpdateAsync(bugReport);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Bug report {Id} updated by {UserName}", id, User.Identity?.Name);
// Email submitter when their report is completed and resolution notes are provided
if (justCompleted && !string.IsNullOrWhiteSpace(dto.ResolutionNotes)
&& !string.IsNullOrWhiteSpace(bugReport.SubmittedByUserId))
{
var submitter = await _userManager.FindByIdAsync(bugReport.SubmittedByUserId);
if (submitter != null && !string.IsNullOrWhiteSpace(submitter.Email))
{
var resolvedBy = User.Identity?.Name ?? "Support";
var htmlBody = $"""
<h2>Your Bug Report Has Been Resolved</h2>
<p>Hi {System.Net.WebUtility.HtmlEncode(bugReport.SubmittedByUserName)},</p>
<p>We wanted to let you know that your bug report has been marked as completed.</p>
<table cellpadding="6" style="border-collapse:collapse;">
<tr><td><strong>Report:</strong></td><td>{System.Net.WebUtility.HtmlEncode(bugReport.Title)}</td></tr>
<tr><td><strong>Resolved By:</strong></td><td>{System.Net.WebUtility.HtmlEncode(resolvedBy)}</td></tr>
<tr><td><strong>Resolved At:</strong></td><td>{bugReport.ResolvedAt:MM/dd/yyyy h:mm tt} UTC</td></tr>
</table>
<h3>Resolution Notes</h3>
<p style="white-space:pre-wrap;">{System.Net.WebUtility.HtmlEncode(dto.ResolutionNotes)}</p>
<p>Thank you for helping us improve the platform!</p>
""";
var plainBody = $"Hi {bugReport.SubmittedByUserName},\n\nYour bug report \"{bugReport.Title}\" has been marked as completed.\n\nResolution Notes:\n{dto.ResolutionNotes}\n\nResolved by: {resolvedBy}\n\nThank you for helping us improve the platform!";
var emailResult = await _emailService.SendEmailAsync(
toEmail: submitter.Email,
toName: bugReport.SubmittedByUserName,
subject: $"[Resolved] {bugReport.Title}",
plainTextBody: plainBody,
htmlBody: htmlBody);
if (emailResult.Success)
_logger.LogInformation("Resolution email sent to {Email} for bug report {Id}", submitter.Email, id);
else
_logger.LogWarning("Failed to send resolution email for bug report {Id}: {Error}", id, emailResult.ErrorMessage);
}
}
TempData["SuccessMessage"] = "Bug report updated successfully.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating bug report {Id}", id);
TempData["ErrorMessage"] = "An error occurred while saving the changes.";
return View(dto);
}
}
}