From 1263e28c001aa951ab945011bb6219e3bce8291e Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Thu, 30 Apr 2026 13:24:23 -0400 Subject: [PATCH] feat: add feedback comments activity notifications --- .../src/Socialize.Api/Data/AppDbContext.cs | 2 + ...59_AddFeedbackCommentsActivity.Designer.cs | 1292 +++++++++++++++++ ...60430171959_AddFeedbackCommentsActivity.cs | 105 ++ .../Migrations/AppDbContextModelSnapshot.cs | 129 ++ .../Feedback/Contracts/FeedbackDtos.cs | 65 + .../Feedback/Data/FeedbackActivityEntry.cs | 17 + .../Modules/Feedback/Data/FeedbackComment.cs | 15 + .../Data/FeedbackModelConfiguration.cs | 38 + .../Modules/Feedback/Data/FeedbackReport.cs | 2 + .../Modules/Feedback/DependencyInjection.cs | 2 + .../Handlers/AddDeveloperFeedbackComment.cs | 56 + .../Feedback/Handlers/AddMyFeedbackComment.cs | 71 + .../Feedback/Handlers/CancelMyFeedback.cs | 14 + .../Feedback/Handlers/GetDeveloperFeedback.cs | 11 +- .../Handlers/GetDeveloperFeedbackTimeline.cs | 34 + .../Feedback/Handlers/GetMyFeedback.cs | 11 +- .../Handlers/GetMyFeedbackTimeline.cs | 61 + .../Feedback/Handlers/SubmitFeedback.cs | 5 +- .../Handlers/UpdateDeveloperFeedback.cs | 78 +- .../Feedback/Services/FeedbackAccessRules.cs | 10 + .../Services/FeedbackActivityTypes.cs | 9 + .../Services/FeedbackNotificationRoutes.cs | 26 + .../Services/FeedbackNotificationService.cs | 121 ++ .../Handlers/GetNotifications.cs | 9 +- .../Handlers/MarkNotificationAsRead.cs | 4 +- .../Feedback/FeedbackRulesTests.cs | 86 ++ 26 files changed, 2255 insertions(+), 18 deletions(-) create mode 100644 backend/src/Socialize.Api/Migrations/20260430171959_AddFeedbackCommentsActivity.Designer.cs create mode 100644 backend/src/Socialize.Api/Migrations/20260430171959_AddFeedbackCommentsActivity.cs create mode 100644 backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackActivityEntry.cs create mode 100644 backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackComment.cs create mode 100644 backend/src/Socialize.Api/Modules/Feedback/Handlers/AddDeveloperFeedbackComment.cs create mode 100644 backend/src/Socialize.Api/Modules/Feedback/Handlers/AddMyFeedbackComment.cs create mode 100644 backend/src/Socialize.Api/Modules/Feedback/Handlers/GetDeveloperFeedbackTimeline.cs create mode 100644 backend/src/Socialize.Api/Modules/Feedback/Handlers/GetMyFeedbackTimeline.cs create mode 100644 backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackActivityTypes.cs create mode 100644 backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackNotificationRoutes.cs create mode 100644 backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackNotificationService.cs diff --git a/backend/src/Socialize.Api/Data/AppDbContext.cs b/backend/src/Socialize.Api/Data/AppDbContext.cs index 9a43816..d66ee84 100644 --- a/backend/src/Socialize.Api/Data/AppDbContext.cs +++ b/backend/src/Socialize.Api/Data/AppDbContext.cs @@ -32,6 +32,8 @@ public class AppDbContext( public DbSet FeedbackReports => Set(); public DbSet FeedbackTags => Set(); public DbSet FeedbackScreenshots => Set(); + public DbSet FeedbackComments => Set(); + public DbSet FeedbackActivityEntries => Set(); protected override void OnModelCreating(ModelBuilder builder) { diff --git a/backend/src/Socialize.Api/Migrations/20260430171959_AddFeedbackCommentsActivity.Designer.cs b/backend/src/Socialize.Api/Migrations/20260430171959_AddFeedbackCommentsActivity.Designer.cs new file mode 100644 index 0000000..8ee4364 --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260430171959_AddFeedbackCommentsActivity.Designer.cs @@ -0,0 +1,1292 @@ +// +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("20260430171959_AddFeedbackCommentsActivity")] + partial class AddFeedbackCommentsActivity + { + /// + 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.FeedbackActivityEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("FromValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Note") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ToValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("FeedbackReportId"); + + b.ToTable("FeedbackActivityEntries", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackComment", 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("AuthorRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AuthorUserId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AuthorUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("FeedbackReportId"); + + b.ToTable("FeedbackComments", (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.FeedbackActivityEntry", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithMany("ActivityEntries") + .HasForeignKey("FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackComment", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithMany("Comments") + .HasForeignKey("FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + 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("ActivityEntries"); + + b.Navigation("Comments"); + + b.Navigation("Screenshot"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/20260430171959_AddFeedbackCommentsActivity.cs b/backend/src/Socialize.Api/Migrations/20260430171959_AddFeedbackCommentsActivity.cs new file mode 100644 index 0000000..1a71faf --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260430171959_AddFeedbackCommentsActivity.cs @@ -0,0 +1,105 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + /// + public partial class AddFeedbackCommentsActivity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "FeedbackActivityEntries", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FeedbackReportId = table.Column(type: "uuid", nullable: false), + ActorUserId = table.Column(type: "uuid", nullable: false), + ActorDisplayName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + ActorEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + ActivityType = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + FromValue = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + ToValue = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + Note = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_FeedbackActivityEntries", x => x.Id); + table.ForeignKey( + name: "FK_FeedbackActivityEntries_FeedbackReports_FeedbackReportId", + column: x => x.FeedbackReportId, + principalTable: "FeedbackReports", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "FeedbackComments", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FeedbackReportId = table.Column(type: "uuid", nullable: false), + AuthorUserId = table.Column(type: "uuid", nullable: false), + AuthorDisplayName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + AuthorEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + AuthorRole = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Body = table.Column(type: "character varying(8000)", maxLength: 8000, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_FeedbackComments", x => x.Id); + table.ForeignKey( + name: "FK_FeedbackComments_FeedbackReports_FeedbackReportId", + column: x => x.FeedbackReportId, + principalTable: "FeedbackReports", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackActivityEntries_ActorUserId", + table: "FeedbackActivityEntries", + column: "ActorUserId"); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackActivityEntries_CreatedAt", + table: "FeedbackActivityEntries", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackActivityEntries_FeedbackReportId", + table: "FeedbackActivityEntries", + column: "FeedbackReportId"); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackComments_AuthorUserId", + table: "FeedbackComments", + column: "AuthorUserId"); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackComments_CreatedAt", + table: "FeedbackComments", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackComments_FeedbackReportId", + table: "FeedbackComments", + column: "FeedbackReportId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FeedbackActivityEntries"); + + migrationBuilder.DropTable( + name: "FeedbackComments"); + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs index 2537c64..f9b770b 100644 --- a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs @@ -558,6 +558,109 @@ namespace Socialize.Api.Migrations b.ToTable("ContentItemRevisions", (string)null); }); + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("FromValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Note") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ToValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("FeedbackReportId"); + + b.ToTable("FeedbackActivityEntries", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackComment", 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("AuthorRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AuthorUserId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AuthorUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("FeedbackReportId"); + + b.ToTable("FeedbackComments", (string)null); + }); + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => { b.Property("Id") @@ -1126,6 +1229,28 @@ namespace Socialize.Api.Migrations .IsRequired(); }); + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithMany("ActivityEntries") + .HasForeignKey("FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackComment", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithMany("Comments") + .HasForeignKey("FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b => { b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") @@ -1150,6 +1275,10 @@ namespace Socialize.Api.Migrations modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => { + b.Navigation("ActivityEntries"); + + b.Navigation("Comments"); + b.Navigation("Screenshot"); b.Navigation("Tags"); diff --git a/backend/src/Socialize.Api/Modules/Feedback/Contracts/FeedbackDtos.cs b/backend/src/Socialize.Api/Modules/Feedback/Contracts/FeedbackDtos.cs index c540afe..18814b5 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Contracts/FeedbackDtos.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Contracts/FeedbackDtos.cs @@ -39,11 +39,26 @@ public record FeedbackReportDto( FeedbackContextDto Context, FeedbackScreenshotDto? Screenshot, IReadOnlyCollection Tags, + IReadOnlyCollection Timeline, DateTimeOffset CreatedAt, DateTimeOffset LastActivityAt, DateTimeOffset? CancelledAt, string? CancellationReason); +public record FeedbackTimelineItemDto( + Guid Id, + string Kind, + Guid ActorUserId, + string ActorDisplayName, + string ActorEmail, + string? ActorRole, + string? Body, + string? ActivityType, + string? FromValue, + string? ToValue, + string? Note, + DateTimeOffset CreatedAt); + public static class FeedbackDtoMapper { public static FeedbackReportDto ToDto(this FeedbackReport report) @@ -81,6 +96,12 @@ public static class FeedbackDtoMapper $"/api/feedback/{report.Id}/screenshot", report.Screenshot.CreatedAt), report.Tags.OrderBy(tag => tag.Name).Select(tag => tag.Name).ToArray(), + report.Comments + .Select(comment => comment.ToTimelineDto()) + .Concat(report.ActivityEntries.Select(activity => activity.ToTimelineDto())) + .OrderBy(item => item.CreatedAt) + .ThenBy(item => item.Kind) + .ToArray(), report.CreatedAt, report.LastActivityAt, report.CancelledAt, @@ -96,4 +117,48 @@ public static class FeedbackDtoMapper { return status == FeedbackStatus.WontDo ? "Won't Do" : status.ToString(); } + + public static FeedbackTimelineItemDto ToTimelineDto(this FeedbackComment comment) + { + return new FeedbackTimelineItemDto( + comment.Id, + "Comment", + comment.AuthorUserId, + comment.AuthorDisplayName, + comment.AuthorEmail, + comment.AuthorRole, + comment.Body, + null, + null, + null, + null, + comment.CreatedAt); + } + + public static FeedbackTimelineItemDto ToTimelineDto(this FeedbackActivityEntry activity) + { + return new FeedbackTimelineItemDto( + activity.Id, + "Activity", + activity.ActorUserId, + activity.ActorDisplayName, + activity.ActorEmail, + null, + null, + activity.ActivityType, + activity.FromValue, + activity.ToValue, + activity.Note, + activity.CreatedAt); + } + + public static string ToFeedbackDisplayString(this FeedbackType type) + { + return ToDisplayString(type); + } + + public static string ToFeedbackDisplayString(this FeedbackStatus status) + { + return ToDisplayString(status); + } } diff --git a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackActivityEntry.cs b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackActivityEntry.cs new file mode 100644 index 0000000..e5fbf22 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackActivityEntry.cs @@ -0,0 +1,17 @@ +namespace Socialize.Api.Modules.Feedback.Data; + +public class FeedbackActivityEntry +{ + public Guid Id { get; set; } + public Guid FeedbackReportId { get; set; } + public Guid ActorUserId { get; set; } + public string ActorDisplayName { get; set; } = string.Empty; + public string ActorEmail { get; set; } = string.Empty; + public string ActivityType { get; set; } = string.Empty; + public string? FromValue { get; set; } + public string? ToValue { get; set; } + public string? Note { get; set; } + public DateTimeOffset CreatedAt { get; set; } + + public FeedbackReport? FeedbackReport { get; set; } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackComment.cs b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackComment.cs new file mode 100644 index 0000000..7505986 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackComment.cs @@ -0,0 +1,15 @@ +namespace Socialize.Api.Modules.Feedback.Data; + +public class FeedbackComment +{ + public Guid Id { get; set; } + public Guid FeedbackReportId { get; set; } + public Guid AuthorUserId { get; set; } + public string AuthorDisplayName { get; set; } = string.Empty; + public string AuthorEmail { get; set; } = string.Empty; + public string AuthorRole { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } + + public FeedbackReport? FeedbackReport { get; set; } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackModelConfiguration.cs b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackModelConfiguration.cs index f6f7e07..d56538a 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackModelConfiguration.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackModelConfiguration.cs @@ -61,6 +61,44 @@ public static class FeedbackModelConfiguration .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity(comment => + { + comment.ToTable("FeedbackComments"); + comment.HasKey(x => x.Id); + comment.Property(x => x.AuthorDisplayName).HasMaxLength(256).IsRequired(); + comment.Property(x => x.AuthorEmail).HasMaxLength(256).IsRequired(); + comment.Property(x => x.AuthorRole).HasMaxLength(32).IsRequired(); + comment.Property(x => x.Body).HasMaxLength(8000).IsRequired(); + comment.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP"); + comment.HasIndex(x => x.FeedbackReportId); + comment.HasIndex(x => x.AuthorUserId); + comment.HasIndex(x => x.CreatedAt); + comment.HasOne(x => x.FeedbackReport) + .WithMany(x => x.Comments) + .HasForeignKey(x => x.FeedbackReportId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(activity => + { + activity.ToTable("FeedbackActivityEntries"); + activity.HasKey(x => x.Id); + activity.Property(x => x.ActorDisplayName).HasMaxLength(256).IsRequired(); + activity.Property(x => x.ActorEmail).HasMaxLength(256).IsRequired(); + activity.Property(x => x.ActivityType).HasMaxLength(64).IsRequired(); + activity.Property(x => x.FromValue).HasMaxLength(512); + activity.Property(x => x.ToValue).HasMaxLength(512); + activity.Property(x => x.Note).HasMaxLength(2000); + activity.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP"); + activity.HasIndex(x => x.FeedbackReportId); + activity.HasIndex(x => x.ActorUserId); + activity.HasIndex(x => x.CreatedAt); + activity.HasOne(x => x.FeedbackReport) + .WithMany(x => x.ActivityEntries) + .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 0943879..ee9cd7f 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackReport.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackReport.cs @@ -28,5 +28,7 @@ public class FeedbackReport public Guid? CancelledByUserId { get; set; } public string? CancellationReason { get; set; } public ICollection Tags { get; } = new List(); + public ICollection Comments { get; } = new List(); + public ICollection ActivityEntries { get; } = new List(); public FeedbackScreenshot? Screenshot { get; set; } } diff --git a/backend/src/Socialize.Api/Modules/Feedback/DependencyInjection.cs b/backend/src/Socialize.Api/Modules/Feedback/DependencyInjection.cs index 17cc920..1712db5 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/DependencyInjection.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/DependencyInjection.cs @@ -4,6 +4,8 @@ public static class DependencyInjection { public static WebApplicationBuilder AddFeedbackModule(this WebApplicationBuilder builder) { + builder.Services.AddScoped(); + return builder; } } diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/AddDeveloperFeedbackComment.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/AddDeveloperFeedbackComment.cs new file mode 100644 index 0000000..3ee7e3a --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/AddDeveloperFeedbackComment.cs @@ -0,0 +1,56 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.Feedback.Contracts; +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 AddDeveloperFeedbackCommentHandler( + AppDbContext dbContext, + FeedbackNotificationService notificationService) + : Endpoint +{ + public override void Configure() + { + Post("/api/feedback/{id}/comments"); + Roles(KnownRoles.Developer); + Options(o => o.WithTags("Feedback")); + } + + public override async Task HandleAsync(AddFeedbackCommentRequest request, CancellationToken ct) + { + Guid id = Route("id"); + FeedbackReport? report = await dbContext.FeedbackReports.SingleOrDefaultAsync(candidate => candidate.Id == id, ct); + + if (report is null) + { + await SendNotFoundAsync(ct); + return; + } + + Guid developerUserId = User.GetUserId(); + DateTimeOffset now = DateTimeOffset.UtcNow; + FeedbackComment comment = new() + { + Id = Guid.NewGuid(), + FeedbackReportId = report.Id, + AuthorUserId = developerUserId, + AuthorDisplayName = User.GetAlias() ?? User.GetName(), + AuthorEmail = User.GetEmail(), + AuthorRole = "Developer", + Body = request.Body.Trim(), + CreatedAt = now, + }; + + report.LastActivityAt = now; + dbContext.FeedbackComments.Add(comment); + notificationService.AddDeveloperCommentNotification(report, developerUserId); + await dbContext.SaveChangesAsync(ct); + + await SendAsync(comment.ToTimelineDto(), StatusCodes.Status201Created, ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/AddMyFeedbackComment.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/AddMyFeedbackComment.cs new file mode 100644 index 0000000..22982eb --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/AddMyFeedbackComment.cs @@ -0,0 +1,71 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +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 AddFeedbackCommentRequest(string Body); + +public class AddFeedbackCommentRequestValidator + : Validator +{ + public AddFeedbackCommentRequestValidator() + { + RuleFor(x => x.Body).NotEmpty().MaximumLength(8000); + } +} + +public class AddMyFeedbackCommentHandler( + AppDbContext dbContext, + FeedbackNotificationService notificationService) + : Endpoint +{ + public override void Configure() + { + Post("/api/my-feedback/{id}/comments"); + Options(o => o.WithTags("Feedback")); + } + + public override async Task HandleAsync(AddFeedbackCommentRequest request, CancellationToken ct) + { + Guid id = Route("id"); + Guid reporterUserId = User.GetUserId(); + + FeedbackReport? report = await dbContext.FeedbackReports.SingleOrDefaultAsync(candidate => candidate.Id == id, ct); + + if (report is null || !FeedbackAccessRules.CanReporterComment(report, reporterUserId)) + { + await SendNotFoundAsync(ct); + return; + } + + DateTimeOffset now = DateTimeOffset.UtcNow; + FeedbackComment comment = CreateComment(report.Id, reporterUserId, "Reporter", request.Body.Trim(), now); + report.LastActivityAt = now; + + dbContext.FeedbackComments.Add(comment); + await notificationService.AddReporterCommentNotificationsAsync(report, reporterUserId, ct); + await dbContext.SaveChangesAsync(ct); + + await SendAsync(comment.ToTimelineDto(), StatusCodes.Status201Created, ct); + } + + private FeedbackComment CreateComment(Guid reportId, Guid userId, string authorRole, string body, DateTimeOffset now) + { + return new FeedbackComment + { + Id = Guid.NewGuid(), + FeedbackReportId = reportId, + AuthorUserId = userId, + AuthorDisplayName = User.GetAlias() ?? User.GetName(), + AuthorEmail = User.GetEmail(), + AuthorRole = authorRole, + Body = body, + CreatedAt = now, + }; + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/CancelMyFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/CancelMyFeedback.cs index 4642506..4bc1466 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Handlers/CancelMyFeedback.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/CancelMyFeedback.cs @@ -54,11 +54,25 @@ public class CancelMyFeedbackHandler(AppDbContext dbContext) } DateTimeOffset now = DateTimeOffset.UtcNow; + FeedbackStatus previousStatus = report.Status; report.Status = FeedbackStatus.Cancelled; report.CancelledAt = now; report.CancelledByUserId = reporterUserId; report.CancellationReason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim(); report.LastActivityAt = now; + report.ActivityEntries.Add(new FeedbackActivityEntry + { + Id = Guid.NewGuid(), + FeedbackReportId = report.Id, + ActorUserId = reporterUserId, + ActorDisplayName = User.GetAlias() ?? User.GetName(), + ActorEmail = User.GetEmail(), + ActivityType = FeedbackActivityTypes.Cancelled, + FromValue = previousStatus.ToFeedbackDisplayString(), + ToValue = FeedbackStatus.Cancelled.ToFeedbackDisplayString(), + Note = report.CancellationReason, + CreatedAt = now, + }); await dbContext.SaveChangesAsync(ct); await SendOkAsync(report.ToDto(), ct); diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetDeveloperFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetDeveloperFeedback.cs index 8ef8f1b..2a0426f 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetDeveloperFeedback.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetDeveloperFeedback.cs @@ -2,6 +2,7 @@ using FastEndpoints; using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; using Socialize.Api.Modules.Feedback.Contracts; +using Socialize.Api.Modules.Feedback.Data; using Socialize.Api.Modules.Identity.Contracts; namespace Socialize.Api.Modules.Feedback.Handlers; @@ -19,12 +20,12 @@ public class GetDeveloperFeedbackHandler(AppDbContext dbContext) public override async Task HandleAsync(CancellationToken ct) { Guid id = Route("id"); - FeedbackReportDto? report = await dbContext.FeedbackReports + FeedbackReport? report = await dbContext.FeedbackReports .Include(candidate => candidate.Tags) .Include(candidate => candidate.Screenshot) - .Where(candidate => candidate.Id == id) - .Select(candidate => candidate.ToDto()) - .SingleOrDefaultAsync(ct); + .Include(candidate => candidate.Comments) + .Include(candidate => candidate.ActivityEntries) + .SingleOrDefaultAsync(candidate => candidate.Id == id, ct); if (report is null) { @@ -32,6 +33,6 @@ public class GetDeveloperFeedbackHandler(AppDbContext dbContext) return; } - await SendOkAsync(report, ct); + await SendOkAsync(report.ToDto(), ct); } } diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetDeveloperFeedbackTimeline.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetDeveloperFeedbackTimeline.cs new file mode 100644 index 0000000..846caff --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetDeveloperFeedbackTimeline.cs @@ -0,0 +1,34 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Modules.Feedback.Contracts; +using Socialize.Api.Modules.Identity.Contracts; + +namespace Socialize.Api.Modules.Feedback.Handlers; + +public class GetDeveloperFeedbackTimelineHandler(AppDbContext dbContext) + : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/api/feedback/{id}/timeline"); + Roles(KnownRoles.Developer); + Options(o => o.WithTags("Feedback")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid id = Route("id"); + bool exists = await dbContext.FeedbackReports.AnyAsync(candidate => candidate.Id == id, ct); + if (!exists) + { + await SendNotFoundAsync(ct); + return; + } + + IReadOnlyCollection timeline = + await GetMyFeedbackTimelineHandler.LoadTimelineAsync(dbContext, id, ct); + + await SendOkAsync(timeline, ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetMyFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetMyFeedback.cs index b9511bd..41c4312 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetMyFeedback.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetMyFeedback.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; using Socialize.Api.Infrastructure.Security; using Socialize.Api.Modules.Feedback.Contracts; +using Socialize.Api.Modules.Feedback.Data; namespace Socialize.Api.Modules.Feedback.Handlers; @@ -20,12 +21,12 @@ public class GetMyFeedbackHandler(AppDbContext dbContext) Guid id = Route("id"); Guid reporterUserId = User.GetUserId(); - FeedbackReportDto? report = await dbContext.FeedbackReports + FeedbackReport? 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); + .Include(candidate => candidate.Comments) + .Include(candidate => candidate.ActivityEntries) + .SingleOrDefaultAsync(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId, ct); if (report is null) { @@ -33,6 +34,6 @@ public class GetMyFeedbackHandler(AppDbContext dbContext) return; } - await SendOkAsync(report, ct); + await SendOkAsync(report.ToDto(), ct); } } diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetMyFeedbackTimeline.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetMyFeedbackTimeline.cs new file mode 100644 index 0000000..9118f61 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetMyFeedbackTimeline.cs @@ -0,0 +1,61 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.Feedback.Contracts; + +namespace Socialize.Api.Modules.Feedback.Handlers; + +public class GetMyFeedbackTimelineHandler(AppDbContext dbContext) + : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/api/my-feedback/{id}/timeline"); + Options(o => o.WithTags("Feedback")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid id = Route("id"); + Guid reporterUserId = User.GetUserId(); + + bool canAccess = await dbContext.FeedbackReports + .AnyAsync(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId, ct); + + if (!canAccess) + { + await SendNotFoundAsync(ct); + return; + } + + await SendOkAsync(await LoadTimelineAsync(id, ct), ct); + } + + internal static async Task> LoadTimelineAsync( + AppDbContext dbContext, + Guid feedbackReportId, + CancellationToken ct) + { + List comments = await dbContext.FeedbackComments + .Where(comment => comment.FeedbackReportId == feedbackReportId) + .Select(comment => comment.ToTimelineDto()) + .ToListAsync(ct); + + List activity = await dbContext.FeedbackActivityEntries + .Where(entry => entry.FeedbackReportId == feedbackReportId) + .Select(entry => entry.ToTimelineDto()) + .ToListAsync(ct); + + return comments + .Concat(activity) + .OrderBy(item => item.CreatedAt) + .ThenBy(item => item.Kind) + .ToArray(); + } + + private Task> LoadTimelineAsync(Guid feedbackReportId, CancellationToken ct) + { + return LoadTimelineAsync(dbContext, feedbackReportId, ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/SubmitFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/SubmitFeedback.cs index f7b8b41..fc3c671 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Handlers/SubmitFeedback.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/SubmitFeedback.cs @@ -43,7 +43,9 @@ public class SubmitFeedbackRequestValidator } } -public class SubmitFeedbackHandler(AppDbContext dbContext) +public class SubmitFeedbackHandler( + AppDbContext dbContext, + FeedbackNotificationService notificationService) : Endpoint { public override void Configure() @@ -89,6 +91,7 @@ public class SubmitFeedbackHandler(AppDbContext dbContext) }; dbContext.FeedbackReports.Add(report); + await notificationService.AddNewReportNotificationsAsync(report, ct); await dbContext.SaveChangesAsync(ct); await SendAsync(report.ToDto(), StatusCodes.Status201Created, ct); diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/UpdateDeveloperFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/UpdateDeveloperFeedback.cs index 85d2ae7..868125c 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Handlers/UpdateDeveloperFeedback.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/UpdateDeveloperFeedback.cs @@ -1,6 +1,7 @@ using FastEndpoints; using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; using Socialize.Api.Modules.Feedback.Contracts; using Socialize.Api.Modules.Feedback.Data; using Socialize.Api.Modules.Feedback.Services; @@ -24,7 +25,9 @@ public class UpdateDeveloperFeedbackRequestValidator } } -public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext) +public class UpdateDeveloperFeedbackHandler( + AppDbContext dbContext, + FeedbackNotificationService notificationService) : Endpoint { public override void Configure() @@ -49,6 +52,8 @@ public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext) } bool changed = false; + Guid developerUserId = User.GetUserId(); + DateTimeOffset now = DateTimeOffset.UtcNow; if (!string.IsNullOrWhiteSpace(request.Type)) { if (!FeedbackRules.TryParseType(request.Type, out FeedbackType nextType)) @@ -60,6 +65,14 @@ public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext) if (report.Type != nextType) { + AddActivity( + report, + developerUserId, + FeedbackActivityTypes.TypeChanged, + report.Type.ToFeedbackDisplayString(), + nextType.ToFeedbackDisplayString(), + null, + now); report.Type = nextType; changed = true; } @@ -83,7 +96,16 @@ public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext) if (report.Status != nextStatus) { + AddActivity( + report, + developerUserId, + FeedbackActivityTypes.StatusChanged, + report.Status.ToFeedbackDisplayString(), + nextStatus.ToFeedbackDisplayString(), + null, + now); report.Status = nextStatus; + notificationService.AddDeveloperStatusNotification(report, developerUserId, nextStatus); changed = true; } } @@ -91,30 +113,68 @@ public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext) if (request.Tags is not null) { IReadOnlyCollection normalizedTags = FeedbackRules.NormalizeTags(request.Tags); - ApplyTags(report, normalizedTags); - changed = true; + string beforeTags = FormatTags(report.Tags.Select(tag => tag.Name)); + bool tagsChanged = ApplyTags(report, normalizedTags); + if (tagsChanged) + { + AddActivity( + report, + developerUserId, + FeedbackActivityTypes.TagsChanged, + beforeTags, + FormatTags(normalizedTags), + null, + now); + changed = true; + } } if (changed) { - report.LastActivityAt = DateTimeOffset.UtcNow; + report.LastActivityAt = now; await dbContext.SaveChangesAsync(ct); } await SendOkAsync(report.ToDto(), ct); } - private static void ApplyTags(FeedbackReport report, IReadOnlyCollection tags) + private void AddActivity( + FeedbackReport report, + Guid actorUserId, + string activityType, + string? fromValue, + string? toValue, + string? note, + DateTimeOffset now) + { + report.ActivityEntries.Add(new FeedbackActivityEntry + { + Id = Guid.NewGuid(), + FeedbackReportId = report.Id, + ActorUserId = actorUserId, + ActorDisplayName = User.GetAlias() ?? User.GetName(), + ActorEmail = User.GetEmail(), + ActivityType = activityType, + FromValue = fromValue, + ToValue = toValue, + Note = note, + CreatedAt = now, + }); + } + + private static bool ApplyTags(FeedbackReport report, IReadOnlyCollection tags) { HashSet requestedKeys = tags .Select(FeedbackRules.NormalizeTagKey) .ToHashSet(StringComparer.Ordinal); + bool changed = false; foreach (FeedbackTag existingTag in report.Tags.ToArray()) { if (!requestedKeys.Contains(existingTag.NormalizedName)) { report.Tags.Remove(existingTag); + changed = true; } } @@ -137,6 +197,14 @@ public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext) Name = tag, NormalizedName = key, }); + changed = true; } + + return changed; + } + + private static string FormatTags(IEnumerable tags) + { + return string.Join(", ", tags.Order(StringComparer.OrdinalIgnoreCase)); } } diff --git a/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackAccessRules.cs b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackAccessRules.cs index 6e2f83b..fe0f992 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackAccessRules.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackAccessRules.cs @@ -14,6 +14,16 @@ public static class FeedbackAccessRules return CanReporterAccess(report, userId) && FeedbackRules.CanReporterCancel(report.Status); } + public static bool CanReporterComment(FeedbackReport report, Guid userId) + { + return CanReporterAccess(report, userId); + } + + public static bool CanDeveloperComment(bool isDeveloper) + { + return isDeveloper; + } + 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/FeedbackActivityTypes.cs b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackActivityTypes.cs new file mode 100644 index 0000000..c38af98 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackActivityTypes.cs @@ -0,0 +1,9 @@ +namespace Socialize.Api.Modules.Feedback.Services; + +public static class FeedbackActivityTypes +{ + public const string StatusChanged = "StatusChanged"; + public const string TypeChanged = "TypeChanged"; + public const string TagsChanged = "TagsChanged"; + public const string Cancelled = "Cancelled"; +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackNotificationRoutes.cs b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackNotificationRoutes.cs new file mode 100644 index 0000000..6cc4ff9 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackNotificationRoutes.cs @@ -0,0 +1,26 @@ +using System.Text.Json; + +namespace Socialize.Api.Modules.Feedback.Services; + +public static class FeedbackNotificationRoutes +{ + public static string ForDeveloper(Guid feedbackReportId) + { + return $"/app/feedback/{feedbackReportId}"; + } + + public static string ForReporter(Guid feedbackReportId) + { + return $"/app/my-feedback/{feedbackReportId}"; + } + + public static string BuildMetadataJson(Guid feedbackReportId, bool developerRoute) + { + return JsonSerializer.Serialize(new + { + route = developerRoute ? ForDeveloper(feedbackReportId) : ForReporter(feedbackReportId), + feedbackReportId, + isFeedbackNotification = true + }); + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackNotificationService.cs b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackNotificationService.cs new file mode 100644 index 0000000..03b5591 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackNotificationService.cs @@ -0,0 +1,121 @@ +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Modules.Feedback.Contracts; +using Socialize.Api.Modules.Feedback.Data; +using Socialize.Api.Modules.Identity.Contracts; +using Socialize.Api.Modules.Notifications.Data; + +namespace Socialize.Api.Modules.Feedback.Services; + +public class FeedbackNotificationService(AppDbContext dbContext) +{ + private const string EntityType = "FeedbackReport"; + + public async Task AddNewReportNotificationsAsync(FeedbackReport report, CancellationToken ct) + { + List developers = await GetDeveloperRecipientsAsync(ct); + + foreach (FeedbackNotificationRecipient developer in developers.Where(developer => developer.UserId != report.ReporterUserId)) + { + AddNotification( + report, + "Feedback.ReportCreated", + $"New feedback from {report.ReporterDisplayName}", + developer.UserId, + developer.Email, + developerRoute: true); + } + } + + public void AddDeveloperCommentNotification(FeedbackReport report, Guid developerUserId) + { + if (report.ReporterUserId == developerUserId) + { + return; + } + + AddNotification( + report, + "Feedback.DeveloperCommented", + $"A developer commented on your feedback", + report.ReporterUserId, + report.ReporterEmail, + developerRoute: false); + } + + public void AddDeveloperStatusNotification(FeedbackReport report, Guid developerUserId, FeedbackStatus nextStatus) + { + if (report.ReporterUserId == developerUserId) + { + return; + } + + AddNotification( + report, + "Feedback.StatusChanged", + $"Your feedback status changed to {nextStatus.ToFeedbackDisplayString()}", + report.ReporterUserId, + report.ReporterEmail, + developerRoute: false); + } + + public async Task AddReporterCommentNotificationsAsync(FeedbackReport report, Guid reporterUserId, CancellationToken ct) + { + List developerParticipants = await dbContext.FeedbackComments + .Where(comment => comment.FeedbackReportId == report.Id && + comment.AuthorUserId != reporterUserId && + comment.AuthorRole == "Developer") + .Select(comment => new FeedbackNotificationRecipient(comment.AuthorUserId, comment.AuthorEmail)) + .Distinct() + .ToListAsync(ct); + + foreach (FeedbackNotificationRecipient developer in developerParticipants) + { + AddNotification( + report, + "Feedback.ReporterCommented", + $"{report.ReporterDisplayName} replied to feedback", + developer.UserId, + developer.Email, + developerRoute: true); + } + } + + private async Task> GetDeveloperRecipientsAsync(CancellationToken ct) + { + return await ( + from userRole in dbContext.UserRoles + join role in dbContext.Roles on userRole.RoleId equals role.Id + join user in dbContext.Users on userRole.UserId equals user.Id + where role.Name == KnownRoles.Developer + select new FeedbackNotificationRecipient(user.Id, user.Email ?? string.Empty)) + .Distinct() + .ToListAsync(ct); + } + + private void AddNotification( + FeedbackReport report, + string eventType, + string message, + Guid recipientUserId, + string? recipientEmail, + bool developerRoute) + { + dbContext.NotificationEvents.Add(new NotificationEvent + { + Id = Guid.NewGuid(), + WorkspaceId = report.WorkspaceId ?? Guid.Empty, + ContentItemId = report.ContentItemId, + EventType = eventType, + EntityType = EntityType, + EntityId = report.Id, + Message = message, + RecipientUserId = recipientUserId, + RecipientEmail = recipientEmail, + MetadataJson = FeedbackNotificationRoutes.BuildMetadataJson(report.Id, developerRoute), + CreatedAt = DateTimeOffset.UtcNow, + }); + } + + private sealed record FeedbackNotificationRecipient(Guid UserId, string? Email); +} diff --git a/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs b/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs index 9717743..5fabdb8 100644 --- a/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs +++ b/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs @@ -54,13 +54,20 @@ public class GetNotificationsHandler( } IQueryable query = dbContext.NotificationEvents.AsQueryable(); + Guid currentUserId = User.GetUserId(); if (!accessScopeService.IsManager(User)) { IReadOnlyCollection workspaceScopeIds = User.GetWorkspaceScopeIds(); - query = query.Where(notificationEvent => workspaceScopeIds.Contains(notificationEvent.WorkspaceId)); + query = query.Where(notificationEvent => + workspaceScopeIds.Contains(notificationEvent.WorkspaceId) || + notificationEvent.RecipientUserId == currentUserId); } + query = query.Where(notificationEvent => + notificationEvent.RecipientUserId == null || + notificationEvent.RecipientUserId == currentUserId); + if (request.WorkspaceId.HasValue) { query = query.Where(notificationEvent => notificationEvent.WorkspaceId == request.WorkspaceId.Value); diff --git a/backend/src/Socialize.Api/Modules/Notifications/Handlers/MarkNotificationAsRead.cs b/backend/src/Socialize.Api/Modules/Notifications/Handlers/MarkNotificationAsRead.cs index 1c47893..26e764a 100644 --- a/backend/src/Socialize.Api/Modules/Notifications/Handlers/MarkNotificationAsRead.cs +++ b/backend/src/Socialize.Api/Modules/Notifications/Handlers/MarkNotificationAsRead.cs @@ -28,7 +28,9 @@ public class MarkNotificationAsReadHandler( return; } - if (!accessScopeService.CanAccessWorkspace(User, notificationEvent.WorkspaceId)) + Guid currentUserId = User.GetUserId(); + bool canReadRecipientNotification = notificationEvent.RecipientUserId == currentUserId; + if (!canReadRecipientNotification && !accessScopeService.CanAccessWorkspace(User, notificationEvent.WorkspaceId)) { await SendForbiddenAsync(ct); return; diff --git a/backend/tests/Socialize.Tests/Feedback/FeedbackRulesTests.cs b/backend/tests/Socialize.Tests/Feedback/FeedbackRulesTests.cs index 9240185..52ed3dc 100644 --- a/backend/tests/Socialize.Tests/Feedback/FeedbackRulesTests.cs +++ b/backend/tests/Socialize.Tests/Feedback/FeedbackRulesTests.cs @@ -1,5 +1,7 @@ using Socialize.Api.Modules.Feedback.Data; +using Socialize.Api.Modules.Feedback.Contracts; using Socialize.Api.Modules.Feedback.Services; +using System.Text.Json; namespace Socialize.Tests.Feedback; @@ -105,6 +107,26 @@ public class FeedbackRulesTests Assert.False(otherUserAllowed); } + [Fact] + public void CanReporterComment_requires_report_owner() + { + Guid reporterUserId = Guid.NewGuid(); + FeedbackReport report = new() { ReporterUserId = reporterUserId }; + + bool ownerAllowed = FeedbackAccessRules.CanReporterComment(report, reporterUserId); + bool otherUserAllowed = FeedbackAccessRules.CanReporterComment(report, Guid.NewGuid()); + + Assert.True(ownerAllowed); + Assert.False(otherUserAllowed); + } + + [Fact] + public void CanDeveloperComment_requires_developer_role() + { + Assert.True(FeedbackAccessRules.CanDeveloperComment(isDeveloper: true)); + Assert.False(FeedbackAccessRules.CanDeveloperComment(isDeveloper: false)); + } + [Fact] public void CanAccessScreenshot_allows_report_owner() { @@ -185,4 +207,68 @@ public class FeedbackRulesTests Assert.Equal(["bug", "mobile"], tags); } + + [Fact] + public void Feedback_report_dto_returns_mixed_timeline_in_created_order() + { + Guid reportId = Guid.NewGuid(); + DateTimeOffset now = DateTimeOffset.UtcNow; + FeedbackReport report = new() + { + Id = reportId, + Type = FeedbackType.Bug, + Status = FeedbackStatus.New, + Description = "Broken layout", + ReporterUserId = Guid.NewGuid(), + ReporterDisplayName = "Reporter", + ReporterEmail = "reporter@example.com", + SubmittedPath = "/app/example", + CreatedAt = now, + LastActivityAt = now.AddMinutes(2), + }; + report.ActivityEntries.Add(new FeedbackActivityEntry + { + Id = Guid.NewGuid(), + FeedbackReportId = reportId, + ActorUserId = Guid.NewGuid(), + ActorDisplayName = "Developer", + ActorEmail = "dev@example.com", + ActivityType = FeedbackActivityTypes.StatusChanged, + FromValue = "New", + ToValue = "Planned", + CreatedAt = now.AddMinutes(2), + }); + report.Comments.Add(new FeedbackComment + { + Id = Guid.NewGuid(), + FeedbackReportId = reportId, + AuthorUserId = report.ReporterUserId, + AuthorDisplayName = "Reporter", + AuthorEmail = "reporter@example.com", + AuthorRole = "Reporter", + Body = "More context", + CreatedAt = now.AddMinutes(1), + }); + + FeedbackReportDto dto = report.ToDto(); + + Assert.Equal(["Comment", "Activity"], dto.Timeline.Select(item => item.Kind).ToArray()); + Assert.Equal("More context", dto.Timeline.First().Body); + Assert.Equal(FeedbackActivityTypes.StatusChanged, dto.Timeline.Last().ActivityType); + } + + [Fact] + public void Feedback_notification_metadata_includes_target_route() + { + Guid reportId = Guid.NewGuid(); + + string developerMetadata = FeedbackNotificationRoutes.BuildMetadataJson(reportId, developerRoute: true); + string reporterMetadata = FeedbackNotificationRoutes.BuildMetadataJson(reportId, developerRoute: false); + + using JsonDocument developerDocument = JsonDocument.Parse(developerMetadata); + using JsonDocument reporterDocument = JsonDocument.Parse(reporterMetadata); + Assert.Equal($"/app/feedback/{reportId}", developerDocument.RootElement.GetProperty("route").GetString()); + Assert.Equal($"/app/my-feedback/{reportId}", reporterDocument.RootElement.GetProperty("route").GetString()); + Assert.True(developerDocument.RootElement.GetProperty("isFeedbackNotification").GetBoolean()); + } }