Initial commit
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
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.Infrastructure.Data;
|
||||
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 ApplicationDbContext _context;
|
||||
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,
|
||||
ApplicationDbContext context,
|
||||
IEmailService emailService,
|
||||
IAdminNotificationService adminNotification,
|
||||
IAzureBlobStorageService blobService,
|
||||
IOptions<StorageSettings> storageSettings,
|
||||
ILogger<BugReportController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_context = context;
|
||||
_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
|
||||
};
|
||||
_context.BugReportAttachments.Add(attachment);
|
||||
uploadedCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to upload bug report attachment {FileName}: {Error}",
|
||||
file.FileName, result.ErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadedCount > 0)
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
_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 query = _context.BugReports
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(r => !r.IsDeleted)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var search = searchTerm.ToLower();
|
||||
query = query.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))
|
||||
query = query.Where(r => r.Status == status);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(priorityFilter) &&
|
||||
Enum.TryParse<BugReportPriority>(priorityFilter, out var priority))
|
||||
query = query.Where(r => r.Priority == priority);
|
||||
|
||||
query = (sortColumn, sortDirection == "asc") switch
|
||||
{
|
||||
("Title", true) => query.OrderBy(r => r.Title),
|
||||
("Title", false) => query.OrderByDescending(r => r.Title),
|
||||
("Status", true) => query.OrderBy(r => r.Status),
|
||||
("Status", false) => query.OrderByDescending(r => r.Status),
|
||||
("Priority", true) => query.OrderBy(r => r.Priority),
|
||||
("Priority", false) => query.OrderByDescending(r => r.Priority),
|
||||
("Submitted", true) => query.OrderBy(r => r.SubmittedByUserName),
|
||||
("Submitted", false) => query.OrderByDescending(r => r.SubmittedByUserName),
|
||||
(_, true) => query.OrderBy(r => r.CreatedAt),
|
||||
_ => query.OrderByDescending(r => r.CreatedAt)
|
||||
};
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var items = await query
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
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 _context.BugReportAttachments
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(a => a.BugReportId == id && !a.IsDeleted)
|
||||
.OrderBy(a => a.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
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 _context.BugReportAttachments
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(a => a.Id == id && !a.IsDeleted);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user