using FastEndpoints; using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; using Socialize.Api.Infrastructure.BlobStorage.Contracts; using Socialize.Api.Infrastructure.Security; using Socialize.Api.Modules.Feedback.Contracts; using Socialize.Api.Modules.Feedback.Data; using Socialize.Api.Modules.Feedback.Services; namespace Socialize.Api.Modules.Feedback.Handlers; public record AttachMyFeedbackScreenshotRequest(IFormFile File); public class AttachMyFeedbackScreenshotRequestValidator : Validator { public AttachMyFeedbackScreenshotRequestValidator() { RuleFor(x => x.File).NotNull().NotEmpty(); } } public class AttachMyFeedbackScreenshotHandler( AppDbContext dbContext, IBlobStorage blobStorage) : Endpoint { public override void Configure() { Post("/api/my-feedback/{id}/screenshot"); Options(o => o.WithTags("Feedback")); AllowFileUploads(); } public override async Task HandleAsync(AttachMyFeedbackScreenshotRequest request, CancellationToken ct) { Guid id = Route("id"); Guid reporterUserId = User.GetUserId(); FeedbackReport? report = await dbContext.FeedbackReports .AsNoTracking() .SingleOrDefaultAsync( candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId, ct); if (report is null) { await SendNotFoundAsync(ct); return; } bool hasScreenshot = await dbContext.FeedbackScreenshots .AsNoTracking() .AnyAsync(candidate => candidate.FeedbackReportId == report.Id, ct); if (hasScreenshot) { AddError("A screenshot is already attached to this feedback report."); await SendErrorsAsync(StatusCodes.Status409Conflict, ct); return; } if (!FeedbackScreenshotRules.IsAllowedSize(request.File.Length)) { AddError( request => request.File, $"The screenshot must be greater than 0 bytes and no larger than {FeedbackScreenshotRules.MaxScreenshotBytes} bytes."); await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); return; } if (!FeedbackScreenshotRules.IsAllowedContentType(request.File.ContentType)) { AddError(request => request.File, "The screenshot must be a PNG or JPEG image."); await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); return; } Guid screenshotId = Guid.NewGuid(); string extension = FeedbackScreenshotRules.GetFileExtension(request.File.ContentType); string blobName = $"{SubDirectoryNames.FeedbackScreenshots}/{report.Id}/{screenshotId}{extension}"; try { await blobStorage.UploadFileAsync( ContainerNames.Feedback, blobName, request.File.OpenReadStream(), request.File.ContentType, ct); } catch (InvalidOperationException) { AddError(request => request.File, "The screenshot file is invalid or unsupported."); await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); return; } DateTimeOffset now = DateTimeOffset.UtcNow; FeedbackScreenshot screenshot = new() { Id = screenshotId, FeedbackReportId = report.Id, FileName = NormalizeFileName(request.File.FileName, extension), ContentType = request.File.ContentType, SizeBytes = request.File.Length, BlobContainerName = ContainerNames.Feedback, BlobName = blobName, CreatedAt = now, }; await using Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction transaction = await dbContext.Database.BeginTransactionAsync(ct); dbContext.FeedbackScreenshots.Add(screenshot); try { await dbContext.SaveChangesAsync(ct); } catch (DbUpdateException) { await transaction.RollbackAsync(ct); AddError("A screenshot is already attached to this feedback report."); await SendErrorsAsync(StatusCodes.Status409Conflict, ct); return; } int updatedRows = await dbContext.FeedbackReports .Where(candidate => candidate.Id == report.Id && candidate.ReporterUserId == reporterUserId) .ExecuteUpdateAsync( setters => setters.SetProperty(candidate => candidate.LastActivityAt, now), ct); if (updatedRows == 0) { await transaction.RollbackAsync(ct); await SendNotFoundAsync(ct); return; } await transaction.CommitAsync(ct); FeedbackReport responseReport = await dbContext.FeedbackReports .AsNoTracking() .Include(candidate => candidate.Tags) .Include(candidate => candidate.Screenshot) .SingleAsync(candidate => candidate.Id == report.Id, ct); await SendOkAsync(responseReport.ToDto(), ct); } private static string NormalizeFileName(string? fileName, string extension) { string normalized = Path.GetFileName(fileName ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(normalized)) { return $"feedback-screenshot{extension}"; } return normalized.Length > 256 ? normalized[..256] : normalized; } }