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>
438 lines
19 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|