From 4873f39192726be0f50effcce8aa576b69a2a4d8 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Thu, 30 Apr 2026 13:15:19 -0400 Subject: [PATCH] feat: protect feedback screenshots --- .../src/Socialize.Api/Data/AppDbContext.cs | 1 + .../BlobStorage/Contracts/ContainerNames.cs | 1 + .../Contracts/SubDirectoryNames.cs | 1 + ...0171123_AddFeedbackScreenshots.Designer.cs | 1163 +++++++++++++++++ .../20260430171123_AddFeedbackScreenshots.cs | 52 + .../Migrations/AppDbContextModelSnapshot.cs | 58 + .../Feedback/Contracts/FeedbackDtos.cs | 18 + .../Data/FeedbackModelConfiguration.cs | 16 + .../Modules/Feedback/Data/FeedbackReport.cs | 1 + .../Feedback/Data/FeedbackScreenshot.cs | 14 + .../Handlers/AttachMyFeedbackScreenshot.cs | 124 ++ .../Feedback/Handlers/CancelMyFeedback.cs | 1 + .../Feedback/Handlers/GetDeveloperFeedback.cs | 1 + .../Handlers/GetFeedbackScreenshot.cs | 65 + .../Feedback/Handlers/GetMyFeedback.cs | 1 + .../Handlers/ListDeveloperFeedback.cs | 1 + .../Feedback/Handlers/ListMyFeedback.cs | 1 + .../Handlers/UpdateDeveloperFeedback.cs | 1 + .../Feedback/Services/FeedbackAccessRules.cs | 5 + .../Services/FeedbackScreenshotRules.cs | 31 + .../Feedback/FeedbackRulesTests.cs | 73 ++ docs/FEATURES/product-feedback.md | 1 + frontend/src/api/schema.d.ts | 119 ++ shared/openapi/openapi.json | 151 +++ 24 files changed, 1900 insertions(+) create mode 100644 backend/src/Socialize.Api/Migrations/20260430171123_AddFeedbackScreenshots.Designer.cs create mode 100644 backend/src/Socialize.Api/Migrations/20260430171123_AddFeedbackScreenshots.cs create mode 100644 backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackScreenshot.cs create mode 100644 backend/src/Socialize.Api/Modules/Feedback/Handlers/AttachMyFeedbackScreenshot.cs create mode 100644 backend/src/Socialize.Api/Modules/Feedback/Handlers/GetFeedbackScreenshot.cs create mode 100644 backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackScreenshotRules.cs diff --git a/backend/src/Socialize.Api/Data/AppDbContext.cs b/backend/src/Socialize.Api/Data/AppDbContext.cs index f925c56..9a43816 100644 --- a/backend/src/Socialize.Api/Data/AppDbContext.cs +++ b/backend/src/Socialize.Api/Data/AppDbContext.cs @@ -31,6 +31,7 @@ public class AppDbContext( public DbSet NotificationEvents => Set(); public DbSet FeedbackReports => Set(); public DbSet FeedbackTags => Set(); + public DbSet FeedbackScreenshots => Set(); protected override void OnModelCreating(ModelBuilder builder) { diff --git a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContainerNames.cs b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContainerNames.cs index 1248878..177cc9c 100644 --- a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContainerNames.cs +++ b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContainerNames.cs @@ -6,4 +6,5 @@ internal static class ContainerNames public const string Clients = "clients"; public const string Workspaces = "workspaces"; public const string Creators = "creators"; + public const string Feedback = "feedback"; } diff --git a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/SubDirectoryNames.cs b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/SubDirectoryNames.cs index b1a999b..0d522a1 100644 --- a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/SubDirectoryNames.cs +++ b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/SubDirectoryNames.cs @@ -5,4 +5,5 @@ public static class SubDirectoryNames public const string Profile = "profile"; public const string Contents = "contents"; public const string Albums = "albums"; + public const string FeedbackScreenshots = "screenshots"; } diff --git a/backend/src/Socialize.Api/Migrations/20260430171123_AddFeedbackScreenshots.Designer.cs b/backend/src/Socialize.Api/Migrations/20260430171123_AddFeedbackScreenshots.Designer.cs new file mode 100644 index 0000000..d0c53b8 --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260430171123_AddFeedbackScreenshots.Designer.cs @@ -0,0 +1,1163 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Socialize.Api.Data; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260430171123_AddFeedbackScreenshots")] + partial class AddFeedbackScreenshots + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalDecision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DecidedByEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByUserId") + .HasColumnType("uuid"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalRequestId"); + + b.ToTable("ApprovalDecisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("DueAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedByUserId") + .HasColumnType("uuid"); + + b.Property("ReviewerEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ReviewerEmail"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ApprovalRequests", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveFileId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveLink") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Assets", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.AssetRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RevisionNumber") + .HasColumnType("integer"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("AssetId", "RevisionNumber") + .IsUnique(); + + b.ToTable("AssetRevisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PrimaryContactEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrimaryContactName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrimaryContactPortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.ToTable("Clients", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Comments.Data.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorUserId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsResolved") + .HasColumnType("boolean"); + + b.Property("ParentCommentId") + .HasColumnType("uuid"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ParentCommentId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Comments", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CurrentRevisionLabel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("integer"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Hashtags") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("PublicationMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PublicationTargets") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ContentItems", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChangeSummary") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Hashtags") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PublicationMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PublicationTargets") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RevisionLabel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RevisionNumber") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ContentItemId", "RevisionNumber") + .IsUnique(); + + b.ToTable("ContentItemRevisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BrowserUserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("CancellationReason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancelledByUserId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("ContentItemTitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("LastActivityAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProjectName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReporterDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReporterEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReporterUserId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubmittedPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ViewportHeight") + .HasColumnType("integer"); + + b.Property("ViewportWidth") + .HasColumnType("integer"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.Property("WorkspaceName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("LastActivityAt"); + + b.HasIndex("ReporterUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("FeedbackReports", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlobContainerName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FeedbackReportId") + .IsUnique(); + + b.ToTable("FeedbackScreenshots", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName"); + + b.HasIndex("FeedbackReportId", "NormalizedName") + .IsUnique(); + + b.ToTable("FeedbackTags", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Alias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BirthDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FacebookId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Firstname") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Lastname") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RefreshToken") + .HasMaxLength(44) + .HasColumnType("character varying(44)"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Notifications.Data.NotificationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MetadataJson") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RecipientEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RecipientUserId") + .HasColumnType("uuid"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RecipientUserId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("NotificationEvents", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Projects.Data.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Notes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ClientId", "Name") + .IsUnique(); + + b.ToTable("Projects", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.WorkspaceInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InvitedByUserId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Email", "Status"); + + b.ToTable("WorkspaceInvites", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithOne("Screenshot") + .HasForeignKey("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", "FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithMany("Tags") + .HasForeignKey("FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => + { + b.Navigation("Screenshot"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/20260430171123_AddFeedbackScreenshots.cs b/backend/src/Socialize.Api/Migrations/20260430171123_AddFeedbackScreenshots.cs new file mode 100644 index 0000000..9ba5b5b --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260430171123_AddFeedbackScreenshots.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + /// + public partial class AddFeedbackScreenshots : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "FeedbackScreenshots", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FeedbackReportId = table.Column(type: "uuid", nullable: false), + FileName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + ContentType = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + SizeBytes = table.Column(type: "bigint", nullable: false), + BlobContainerName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + BlobName = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_FeedbackScreenshots", x => x.Id); + table.ForeignKey( + name: "FK_FeedbackScreenshots_FeedbackReports_FeedbackReportId", + column: x => x.FeedbackReportId, + principalTable: "FeedbackReports", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackScreenshots_FeedbackReportId", + table: "FeedbackScreenshots", + column: "FeedbackReportId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FeedbackScreenshots"); + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs index 9525ae6..2537c64 100644 --- a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs @@ -672,6 +672,51 @@ namespace Socialize.Api.Migrations b.ToTable("FeedbackReports", (string)null); }); + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlobContainerName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FeedbackReportId") + .IsUnique(); + + b.ToTable("FeedbackScreenshots", (string)null); + }); + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b => { b.Property("Id") @@ -1081,6 +1126,17 @@ namespace Socialize.Api.Migrations .IsRequired(); }); + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithOne("Screenshot") + .HasForeignKey("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", "FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b => { b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") @@ -1094,6 +1150,8 @@ namespace Socialize.Api.Migrations modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => { + b.Navigation("Screenshot"); + b.Navigation("Tags"); }); #pragma warning restore 612, 618 diff --git a/backend/src/Socialize.Api/Modules/Feedback/Contracts/FeedbackDtos.cs b/backend/src/Socialize.Api/Modules/Feedback/Contracts/FeedbackDtos.cs index bc68686..c540afe 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Contracts/FeedbackDtos.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Contracts/FeedbackDtos.cs @@ -19,6 +19,14 @@ public record FeedbackMetadataDto( int? ViewportHeight, string? AppVersion); +public record FeedbackScreenshotDto( + Guid Id, + string FileName, + string ContentType, + long SizeBytes, + string DownloadPath, + DateTimeOffset CreatedAt); + public record FeedbackReportDto( Guid Id, string Type, @@ -29,6 +37,7 @@ public record FeedbackReportDto( string ReporterEmail, FeedbackMetadataDto Metadata, FeedbackContextDto Context, + FeedbackScreenshotDto? Screenshot, IReadOnlyCollection Tags, DateTimeOffset CreatedAt, DateTimeOffset LastActivityAt, @@ -62,6 +71,15 @@ public static class FeedbackDtoMapper report.ProjectName, report.ContentItemId, report.ContentItemTitle), + report.Screenshot is null + ? null + : new FeedbackScreenshotDto( + report.Screenshot.Id, + report.Screenshot.FileName, + report.Screenshot.ContentType, + report.Screenshot.SizeBytes, + $"/api/feedback/{report.Id}/screenshot", + report.Screenshot.CreatedAt), report.Tags.OrderBy(tag => tag.Name).Select(tag => tag.Name).ToArray(), report.CreatedAt, report.LastActivityAt, diff --git a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackModelConfiguration.cs b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackModelConfiguration.cs index ddac54f..f6f7e07 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackModelConfiguration.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackModelConfiguration.cs @@ -45,6 +45,22 @@ public static class FeedbackModelConfiguration .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity(screenshot => + { + screenshot.ToTable("FeedbackScreenshots"); + screenshot.HasKey(x => x.Id); + screenshot.Property(x => x.FileName).HasMaxLength(256).IsRequired(); + screenshot.Property(x => x.ContentType).HasMaxLength(128).IsRequired(); + screenshot.Property(x => x.BlobContainerName).HasMaxLength(128).IsRequired(); + screenshot.Property(x => x.BlobName).HasMaxLength(512).IsRequired(); + screenshot.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP"); + screenshot.HasIndex(x => x.FeedbackReportId).IsUnique(); + screenshot.HasOne(x => x.FeedbackReport) + .WithOne(x => x.Screenshot) + .HasForeignKey(x => x.FeedbackReportId) + .OnDelete(DeleteBehavior.Cascade); + }); + return modelBuilder; } } diff --git a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackReport.cs b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackReport.cs index 5e0355f..0943879 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackReport.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackReport.cs @@ -28,4 +28,5 @@ public class FeedbackReport public Guid? CancelledByUserId { get; set; } public string? CancellationReason { get; set; } public ICollection Tags { get; } = new List(); + public FeedbackScreenshot? Screenshot { get; set; } } diff --git a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackScreenshot.cs b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackScreenshot.cs new file mode 100644 index 0000000..828f04e --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackScreenshot.cs @@ -0,0 +1,14 @@ +namespace Socialize.Api.Modules.Feedback.Data; + +public class FeedbackScreenshot +{ + public Guid Id { get; set; } + public Guid FeedbackReportId { get; set; } + public string FileName { get; set; } = string.Empty; + public string ContentType { get; set; } = string.Empty; + public long SizeBytes { get; set; } + public string BlobContainerName { get; set; } = string.Empty; + public string BlobName { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } + public FeedbackReport? FeedbackReport { get; set; } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/AttachMyFeedbackScreenshot.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/AttachMyFeedbackScreenshot.cs new file mode 100644 index 0000000..63e5593 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/AttachMyFeedbackScreenshot.cs @@ -0,0 +1,124 @@ +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 + .Include(candidate => candidate.Tags) + .Include(candidate => candidate.Screenshot) + .SingleOrDefaultAsync( + candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId, + ct); + + if (report is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (report.Screenshot is not null) + { + 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; + report.Screenshot = new FeedbackScreenshot + { + 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, + }; + report.LastActivityAt = now; + + await dbContext.SaveChangesAsync(ct); + await SendOkAsync(report.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; + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/CancelMyFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/CancelMyFeedback.cs index 402f747..4642506 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Handlers/CancelMyFeedback.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/CancelMyFeedback.cs @@ -35,6 +35,7 @@ public class CancelMyFeedbackHandler(AppDbContext dbContext) FeedbackReport? report = await dbContext.FeedbackReports .Include(candidate => candidate.Tags) + .Include(candidate => candidate.Screenshot) .SingleOrDefaultAsync( candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId, ct); diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetDeveloperFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetDeveloperFeedback.cs index 4c93b7a..8ef8f1b 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetDeveloperFeedback.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetDeveloperFeedback.cs @@ -21,6 +21,7 @@ public class GetDeveloperFeedbackHandler(AppDbContext dbContext) Guid id = Route("id"); FeedbackReportDto? report = await dbContext.FeedbackReports .Include(candidate => candidate.Tags) + .Include(candidate => candidate.Screenshot) .Where(candidate => candidate.Id == id) .Select(candidate => candidate.ToDto()) .SingleOrDefaultAsync(ct); diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetFeedbackScreenshot.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetFeedbackScreenshot.cs new file mode 100644 index 0000000..044b511 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetFeedbackScreenshot.cs @@ -0,0 +1,65 @@ +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.Data; +using Socialize.Api.Modules.Feedback.Services; +using Socialize.Api.Modules.Identity.Contracts; + +namespace Socialize.Api.Modules.Feedback.Handlers; + +public class GetFeedbackScreenshotHandler( + AppDbContext dbContext, + IBlobStorage blobStorage) + : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/api/feedback/{id}/screenshot"); + Options(o => o.WithTags("Feedback")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid id = Route("id"); + Guid userId = User.GetUserId(); + + FeedbackReport? report = await dbContext.FeedbackReports + .Include(candidate => candidate.Screenshot) + .SingleOrDefaultAsync(candidate => candidate.Id == id, ct); + + if (report is null || report.Screenshot is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!FeedbackAccessRules.CanAccessScreenshot(report, userId, User.IsInRole(KnownRoles.Developer))) + { + await SendForbiddenAsync(ct); + return; + } + + MemoryStream stream; + try + { + stream = await blobStorage.DownloadFileAsync( + report.Screenshot.BlobContainerName, + report.Screenshot.BlobName, + ct); + } + catch (FileNotFoundException) + { + await SendNotFoundAsync(ct); + return; + } + + await SendStreamAsync( + stream, + fileName: report.Screenshot.FileName, + fileLengthBytes: report.Screenshot.SizeBytes, + contentType: report.Screenshot.ContentType, + cancellation: ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetMyFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetMyFeedback.cs index 000996c..b9511bd 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetMyFeedback.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetMyFeedback.cs @@ -22,6 +22,7 @@ public class GetMyFeedbackHandler(AppDbContext dbContext) FeedbackReportDto? report = await dbContext.FeedbackReports .Include(candidate => candidate.Tags) + .Include(candidate => candidate.Screenshot) .Where(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId) .Select(candidate => candidate.ToDto()) .SingleOrDefaultAsync(ct); diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListDeveloperFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListDeveloperFeedback.cs index a1bbcc3..1cd7bdb 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListDeveloperFeedback.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListDeveloperFeedback.cs @@ -20,6 +20,7 @@ public class ListDeveloperFeedbackHandler(AppDbContext dbContext) { List reports = await dbContext.FeedbackReports .Include(report => report.Tags) + .Include(report => report.Screenshot) .OrderByDescending(report => report.LastActivityAt) .Select(report => report.ToDto()) .ToListAsync(ct); diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListMyFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListMyFeedback.cs index 4a760fb..53eccad 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListMyFeedback.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListMyFeedback.cs @@ -20,6 +20,7 @@ public class ListMyFeedbackHandler(AppDbContext dbContext) Guid reporterUserId = User.GetUserId(); List reports = await dbContext.FeedbackReports .Include(report => report.Tags) + .Include(report => report.Screenshot) .Where(report => report.ReporterUserId == reporterUserId) .OrderByDescending(report => report.LastActivityAt) .Select(report => report.ToDto()) diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/UpdateDeveloperFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/UpdateDeveloperFeedback.cs index 533bef7..85d2ae7 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Handlers/UpdateDeveloperFeedback.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/UpdateDeveloperFeedback.cs @@ -39,6 +39,7 @@ public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext) Guid id = Route("id"); FeedbackReport? report = await dbContext.FeedbackReports .Include(candidate => candidate.Tags) + .Include(candidate => candidate.Screenshot) .SingleOrDefaultAsync(candidate => candidate.Id == id, ct); if (report is null) diff --git a/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackAccessRules.cs b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackAccessRules.cs index 8e76ecf..6e2f83b 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackAccessRules.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackAccessRules.cs @@ -13,4 +13,9 @@ public static class FeedbackAccessRules { return CanReporterAccess(report, userId) && FeedbackRules.CanReporterCancel(report.Status); } + + public static bool CanAccessScreenshot(FeedbackReport report, Guid userId, bool isDeveloper) + { + return isDeveloper || CanReporterAccess(report, userId); + } } diff --git a/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackScreenshotRules.cs b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackScreenshotRules.cs new file mode 100644 index 0000000..3faeaa2 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackScreenshotRules.cs @@ -0,0 +1,31 @@ +namespace Socialize.Api.Modules.Feedback.Services; + +public static class FeedbackScreenshotRules +{ + public const long MaxScreenshotBytes = 5 * 1024 * 1024; + + private static readonly HashSet AllowedContentTypes = + [ + "image/png", + "image/jpeg", + "image/jpg", + ]; + + public static bool IsAllowedContentType(string? contentType) + { + return !string.IsNullOrWhiteSpace(contentType) && + AllowedContentTypes.Contains(contentType.Trim(), StringComparer.OrdinalIgnoreCase); + } + + public static bool IsAllowedSize(long sizeBytes) + { + return sizeBytes is > 0 and <= MaxScreenshotBytes; + } + + public static string GetFileExtension(string contentType) + { + return contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase) + ? ".png" + : ".jpg"; + } +} diff --git a/backend/tests/Socialize.Tests/Feedback/FeedbackRulesTests.cs b/backend/tests/Socialize.Tests/Feedback/FeedbackRulesTests.cs index 4299160..9240185 100644 --- a/backend/tests/Socialize.Tests/Feedback/FeedbackRulesTests.cs +++ b/backend/tests/Socialize.Tests/Feedback/FeedbackRulesTests.cs @@ -105,6 +105,79 @@ public class FeedbackRulesTests Assert.False(otherUserAllowed); } + [Fact] + public void CanAccessScreenshot_allows_report_owner() + { + Guid reporterUserId = Guid.NewGuid(); + FeedbackReport report = new() { ReporterUserId = reporterUserId }; + + bool allowed = FeedbackAccessRules.CanAccessScreenshot(report, reporterUserId, isDeveloper: false); + + Assert.True(allowed); + } + + [Fact] + public void CanAccessScreenshot_allows_developer() + { + FeedbackReport report = new() { ReporterUserId = Guid.NewGuid() }; + + bool allowed = FeedbackAccessRules.CanAccessScreenshot(report, Guid.NewGuid(), isDeveloper: true); + + Assert.True(allowed); + } + + [Fact] + public void CanAccessScreenshot_rejects_unrelated_non_developer() + { + FeedbackReport report = new() { ReporterUserId = Guid.NewGuid() }; + + bool allowed = FeedbackAccessRules.CanAccessScreenshot(report, Guid.NewGuid(), isDeveloper: false); + + Assert.False(allowed); + } + + [Theory] + [InlineData("image/png")] + [InlineData("image/jpeg")] + [InlineData("image/jpg")] + public void Screenshot_content_type_allows_png_and_jpeg(string contentType) + { + bool allowed = FeedbackScreenshotRules.IsAllowedContentType(contentType); + + Assert.True(allowed); + } + + [Theory] + [InlineData("text/html")] + [InlineData("application/pdf")] + [InlineData("")] + public void Screenshot_content_type_rejects_non_images(string contentType) + { + bool allowed = FeedbackScreenshotRules.IsAllowedContentType(contentType); + + Assert.False(allowed); + } + + [Theory] + [InlineData(1)] + [InlineData(FeedbackScreenshotRules.MaxScreenshotBytes)] + public void Screenshot_size_allows_non_empty_files_up_to_limit(long sizeBytes) + { + bool allowed = FeedbackScreenshotRules.IsAllowedSize(sizeBytes); + + Assert.True(allowed); + } + + [Theory] + [InlineData(0)] + [InlineData(FeedbackScreenshotRules.MaxScreenshotBytes + 1)] + public void Screenshot_size_rejects_empty_and_oversized_files(long sizeBytes) + { + bool allowed = FeedbackScreenshotRules.IsAllowedSize(sizeBytes); + + Assert.False(allowed); + } + [Fact] public void NormalizeTags_trims_deduplicates_and_orders() { diff --git a/docs/FEATURES/product-feedback.md b/docs/FEATURES/product-feedback.md index 3855c97..93ba378 100644 --- a/docs/FEATURES/product-feedback.md +++ b/docs/FEATURES/product-feedback.md @@ -72,6 +72,7 @@ This is product-level support data for the SaaS operator. It may capture workspa - Screenshots are uploaded through the blob storage abstraction, not embedded in feedback database rows. - Feedback screenshots should use a dedicated storage area or prefix. +- Feedback screenshot records store blob container/path metadata and expose a protected API download path, not a public blob URL. - Annotated captures are exported as compressed image files. - Backend upload size and content type validation must be enforced. - The UI must show a friendly error when an image is too large or invalid. diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 944b7fb..d6231b2 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -436,6 +436,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/my-feedback/{id}/screenshot": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotHandler"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/my-feedback/{id}/cancel": { parameters: { query?: never; @@ -468,6 +484,22 @@ export interface paths { patch: operations["SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackHandler"]; trace?: never; }; + "/api/feedback/{id}/screenshot": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SocializeApiModulesFeedbackHandlersGetFeedbackScreenshotHandler"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/my-feedback/{id}": { parameters: { query?: never; @@ -998,6 +1030,7 @@ export interface components { reporterEmail?: string; metadata?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackMetadataDto"]; context?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackContextDto"]; + screenshot?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackScreenshotDto"] | null; tags?: string[]; /** Format: date-time */ createdAt?: string; @@ -1030,6 +1063,21 @@ export interface components { contentItemId?: string | null; contentItemTitle?: string | null; }; + SocializeApiModulesFeedbackContractsFeedbackScreenshotDto: { + /** Format: guid */ + id?: string; + fileName?: string; + contentType?: string; + /** Format: int64 */ + sizeBytes?: number; + downloadPath?: string; + /** Format: date-time */ + createdAt?: string; + }; + SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotRequest: { + /** Format: binary */ + file: string; + }; SocializeApiModulesFeedbackHandlersCancelMyFeedbackRequest: { reason?: string | null; }; @@ -2238,6 +2286,48 @@ export interface operations { }; }; }; + SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotHandler: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackReportDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["FastEndpointsErrorResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SocializeApiModulesFeedbackHandlersCancelMyFeedbackHandler: { parameters: { query?: never; @@ -2365,6 +2455,35 @@ export interface operations { }; }; }; + SocializeApiModulesFeedbackHandlersGetFeedbackScreenshotHandler: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SystemIOStream"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SocializeApiModulesFeedbackHandlersGetMyFeedbackHandler: { parameters: { query?: never; diff --git a/shared/openapi/openapi.json b/shared/openapi/openapi.json index 1e5b2b7..326fc83 100644 --- a/shared/openapi/openapi.json +++ b/shared/openapi/openapi.json @@ -1221,6 +1221,68 @@ } } }, + "/api/my-feedback/{id}/screenshot": { + "post": { + "tags": [ + "Feedback", + "Api" + ], + "operationId": "SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotHandler", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "x-name": "AttachMyFeedbackScreenshotRequest", + "description": "", + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackReportDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/FastEndpointsErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "JWTBearerAuth": [] + } + ] + } + }, "/api/my-feedback/{id}/cancel": { "post": { "tags": [ @@ -1392,6 +1454,45 @@ ] } }, + "/api/feedback/{id}/screenshot": { + "get": { + "tags": [ + "Feedback", + "Api" + ], + "operationId": "SocializeApiModulesFeedbackHandlersGetFeedbackScreenshotHandler", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemIOStream" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "JWTBearerAuth": [] + } + ] + } + }, "/api/my-feedback/{id}": { "get": { "tags": [ @@ -3320,6 +3421,14 @@ "context": { "$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackContextDto" }, + "screenshot": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackScreenshotDto" + } + ] + }, "tags": { "type": "array", "items": { @@ -3414,6 +3523,48 @@ } } }, + "SocializeApiModulesFeedbackContractsFeedbackScreenshotDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "format": "guid" + }, + "fileName": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "sizeBytes": { + "type": "integer", + "format": "int64" + }, + "downloadPath": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary", + "minLength": 1, + "nullable": false + } + } + }, "SocializeApiModulesFeedbackHandlersCancelMyFeedbackRequest": { "type": "object", "additionalProperties": false,