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 _userManager; private readonly IEmailService _emailService; private readonly IAdminNotificationService _adminNotification; private readonly IAzureBlobStorageService _blobService; private readonly StorageSettings _storageSettings; private readonly ILogger _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 userManager, IEmailService emailService, IAdminNotificationService adminNotification, IAzureBlobStorageService blobService, IOptions storageSettings, ILogger logger) { _unitOfWork = unitOfWork; _mapper = mapper; _tenantContext = tenantContext; _userManager = userManager; _emailService = emailService; _adminNotification = adminNotification; _blobService = blobService; _storageSettings = storageSettings.Value; _logger = logger; } /// /// Renders the bug-report submission form for the currently authenticated user. /// Returns an empty so the view has a bound model /// with sensible defaults (e.g. Priority pre-set to Normal). /// // GET: /BugReport/Submit [HttpGet] public IActionResult Submit() { return View(new CreateBugReportDto()); } /// /// Accepts a bug report form submission, persists the report, uploads any media /// attachments to Azure Blob Storage, and notifies platform admins. /// /// Design decisions: /// /// 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. /// The bug report record is saved to the database before attachment /// upload so the auto-generated BugReport.Id is available as the blob /// folder prefix: {companyId}/{bugReportId}/{guid}{ext}. /// Individual upload failures are logged as warnings but do not abort the /// overall submission — a partial upload is still a valid report. /// Attachments are tracked in BugReportAttachment rows via the raw /// ApplicationDbContext (not IUnitOfWork) because they need a /// separate SaveChangesAsync() call after all blobs are uploaded. /// /// /// // POST: /BugReport/Submit [HttpPost] [ValidateAntiForgeryToken] [RequestSizeLimit(110 * 1024 * 1024)] public async Task Submit(CreateBugReportDto dto, List? 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(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); } } /// /// Displays a paginated, filterable list of all bug reports across every tenant /// (SuperAdmin only). /// /// Uses IgnoreQueryFilters() to bypass the global soft-delete and /// multi-tenancy filters so SuperAdmins see reports from all companies. /// The manual !r.IsDeleted 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 CreatedAt. /// /// // GET: /BugReport/Index — SuperAdmin only [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public async Task 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(statusFilter, out var status)) allReports = allReports.Where(r => r.Status == status); if (!string.IsNullOrWhiteSpace(priorityFilter) && Enum.TryParse(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>(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); } /// /// Loads a bug report and its attachments for editing by a SuperAdmin. /// /// Both the report and its attachment rows are fetched with /// IgnoreQueryFilters() because SuperAdmins need visibility into /// cross-tenant records. The explicit IsDeleted 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. /// /// // GET: /BugReport/Edit/5 — SuperAdmin only [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] [HttpGet] public async Task Edit(int id) { var bugReport = await _unitOfWork.BugReports.GetByIdAsync(id, ignoreQueryFilters: true); if (bugReport == null || bugReport.IsDeleted) return NotFound(); var dto = _mapper.Map(bugReport); var attachments = (await _unitOfWork.BugReportAttachments.FindAsync( a => a.BugReportId == id && !a.IsDeleted, ignoreQueryFilters: true)) .OrderBy(a => a.CreatedAt) .ToList(); dto.Attachments = _mapper.Map>(attachments); return View(dto); } /// /// Streams a bug-report attachment from Azure Blob Storage directly to the browser. /// Returns a FileResult with the original filename and content-type so the /// browser can render images inline or prompt a download for other media types. /// /// 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 BugReportAttachment record (not from a /// user-supplied path) to prevent path-traversal attacks. /// /// // GET: /BugReport/Attachment/5 — SuperAdmin only — streams blob to browser [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] [HttpGet] public async Task 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); } /// /// Persists status/priority/resolution edits to a bug report and, when the report /// transitions to for the first time, /// emails the original submitter with the resolution notes. /// /// Key business rules: /// /// ResolvedAt / ResolvedBy are stamped only on the first /// transition to Completed or Cancelled — subsequent saves /// do not overwrite those audit fields. /// The resolution email is gated on three conditions: the status just /// changed to Completed, 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. /// The route-id / dto.Id equality check prevents CSRF-style ID-swap /// attacks where a forged form targets a different record. /// /// /// // POST: /BugReport/Edit/5 — SuperAdmin only [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] [HttpPost] [ValidateAntiForgeryToken] public async Task 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 = $"""

Your Bug Report Has Been Resolved

Hi {System.Net.WebUtility.HtmlEncode(bugReport.SubmittedByUserName)},

We wanted to let you know that your bug report has been marked as completed.

Report:{System.Net.WebUtility.HtmlEncode(bugReport.Title)}
Resolved By:{System.Net.WebUtility.HtmlEncode(resolvedBy)}
Resolved At:{bugReport.ResolvedAt:MM/dd/yyyy h:mm tt} UTC

Resolution Notes

{System.Net.WebUtility.HtmlEncode(dto.ResolutionNotes)}

Thank you for helping us improve the platform!

"""; 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); } } }