diff --git a/backend/src/Socialize.Api/Data/AppDbContext.cs b/backend/src/Socialize.Api/Data/AppDbContext.cs index 700f2e0..f925c56 100644 --- a/backend/src/Socialize.Api/Data/AppDbContext.cs +++ b/backend/src/Socialize.Api/Data/AppDbContext.cs @@ -5,6 +5,7 @@ using Socialize.Api.Modules.Assets.Data; using Socialize.Api.Modules.Clients.Data; using Socialize.Api.Modules.Comments.Data; using Socialize.Api.Modules.ContentItems.Data; +using Socialize.Api.Modules.Feedback.Data; using Socialize.Api.Modules.Identity.Data; using Socialize.Api.Modules.Notifications.Data; using Socialize.Api.Modules.Projects.Data; @@ -28,6 +29,8 @@ public class AppDbContext( public DbSet ApprovalRequests => Set(); public DbSet ApprovalDecisions => Set(); public DbSet NotificationEvents => Set(); + public DbSet FeedbackReports => Set(); + public DbSet FeedbackTags => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -41,5 +44,6 @@ public class AppDbContext( builder.ConfigureCommentsModule(); builder.ConfigureApprovalsModule(); builder.ConfigureNotificationsModule(); + builder.ConfigureFeedbackModule(); } } diff --git a/backend/src/Socialize.Api/Migrations/20260430072517_AddFeedbackFoundation.Designer.cs b/backend/src/Socialize.Api/Migrations/20260430072517_AddFeedbackFoundation.Designer.cs new file mode 100644 index 0000000..9681c4b --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260430072517_AddFeedbackFoundation.Designer.cs @@ -0,0 +1,1105 @@ +// +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("20260430072517_AddFeedbackFoundation")] + partial class AddFeedbackFoundation + { + /// + 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.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.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("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/20260430072517_AddFeedbackFoundation.cs b/backend/src/Socialize.Api/Migrations/20260430072517_AddFeedbackFoundation.cs new file mode 100644 index 0000000..b98397c --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260430072517_AddFeedbackFoundation.cs @@ -0,0 +1,116 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + /// + public partial class AddFeedbackFoundation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "FeedbackReports", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Type = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Description = table.Column(type: "character varying(8000)", maxLength: 8000, nullable: false), + ReporterUserId = table.Column(type: "uuid", nullable: false), + ReporterDisplayName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + ReporterEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + SubmittedPath = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + BrowserUserAgent = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + ViewportWidth = table.Column(type: "integer", nullable: true), + ViewportHeight = table.Column(type: "integer", nullable: true), + AppVersion = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + WorkspaceId = table.Column(type: "uuid", nullable: true), + WorkspaceName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ClientId = table.Column(type: "uuid", nullable: true), + ClientName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ProjectId = table.Column(type: "uuid", nullable: true), + ProjectName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ContentItemId = table.Column(type: "uuid", nullable: true), + ContentItemTitle = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + LastActivityAt = table.Column(type: "timestamp with time zone", nullable: false), + CancelledAt = table.Column(type: "timestamp with time zone", nullable: true), + CancelledByUserId = table.Column(type: "uuid", nullable: true), + CancellationReason = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_FeedbackReports", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "FeedbackTags", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FeedbackReportId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + NormalizedName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FeedbackTags", x => x.Id); + table.ForeignKey( + name: "FK_FeedbackTags_FeedbackReports_FeedbackReportId", + column: x => x.FeedbackReportId, + principalTable: "FeedbackReports", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackReports_LastActivityAt", + table: "FeedbackReports", + column: "LastActivityAt"); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackReports_ReporterUserId", + table: "FeedbackReports", + column: "ReporterUserId"); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackReports_Status", + table: "FeedbackReports", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackReports_Type", + table: "FeedbackReports", + column: "Type"); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackReports_WorkspaceId", + table: "FeedbackReports", + column: "WorkspaceId"); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackTags_FeedbackReportId_NormalizedName", + table: "FeedbackTags", + columns: new[] { "FeedbackReportId", "NormalizedName" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_FeedbackTags_NormalizedName", + table: "FeedbackTags", + column: "NormalizedName"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FeedbackTags"); + + migrationBuilder.DropTable( + name: "FeedbackReports"); + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs index fbc309b..9525ae6 100644 --- a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs @@ -125,7 +125,7 @@ namespace Socialize.Api.Migrations b.ToTable("AspNetUserTokens", (string)null); }); - modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalDecision", b => + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalDecision", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -168,7 +168,7 @@ namespace Socialize.Api.Migrations b.ToTable("ApprovalDecisions", (string)null); }); - modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalRequest", b => + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -230,7 +230,7 @@ namespace Socialize.Api.Migrations b.ToTable("ApprovalRequests", (string)null); }); - modelBuilder.Entity("Socialize.Modules.Assets.Data.Asset", b => + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -286,7 +286,7 @@ namespace Socialize.Api.Migrations b.ToTable("Assets", (string)null); }); - modelBuilder.Entity("Socialize.Modules.Assets.Data.AssetRevision", b => + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.AssetRevision", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -329,7 +329,7 @@ namespace Socialize.Api.Migrations b.ToTable("AssetRevisions", (string)null); }); - modelBuilder.Entity("Socialize.Modules.Clients.Data.Client", b => + modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -379,7 +379,7 @@ namespace Socialize.Api.Migrations b.ToTable("Clients", (string)null); }); - modelBuilder.Entity("Socialize.Modules.Comments.Data.Comment", b => + modelBuilder.Entity("Socialize.Api.Modules.Comments.Data.Comment", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -434,7 +434,7 @@ namespace Socialize.Api.Migrations b.ToTable("Comments", (string)null); }); - modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItem", b => + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItem", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -500,7 +500,7 @@ namespace Socialize.Api.Migrations b.ToTable("ContentItems", (string)null); }); - modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItemRevision", b => + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -558,7 +558,150 @@ namespace Socialize.Api.Migrations b.ToTable("ContentItemRevisions", (string)null); }); - modelBuilder.Entity("Socialize.Modules.Identity.Data.Role", b => + 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.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() @@ -585,7 +728,7 @@ namespace Socialize.Api.Migrations b.ToTable("AspNetRoles", (string)null); }); - modelBuilder.Entity("Socialize.Modules.Identity.Data.User", b => + modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.User", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -688,7 +831,7 @@ namespace Socialize.Api.Migrations b.ToTable("AspNetUsers", (string)null); }); - modelBuilder.Entity("Socialize.Modules.Notifications.Data.NotificationEvent", b => + modelBuilder.Entity("Socialize.Api.Modules.Notifications.Data.NotificationEvent", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -750,7 +893,7 @@ namespace Socialize.Api.Migrations b.ToTable("NotificationEvents", (string)null); }); - modelBuilder.Entity("Socialize.Modules.Projects.Data.Project", b => + modelBuilder.Entity("Socialize.Api.Modules.Projects.Data.Project", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -803,7 +946,7 @@ namespace Socialize.Api.Migrations b.ToTable("Projects", (string)null); }); - modelBuilder.Entity("Socialize.Modules.Workspaces.Data.Workspace", b => + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -814,15 +957,15 @@ namespace Socialize.Api.Migrations .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("LogoUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)"); - b.Property("OwnerUserId") .HasColumnType("uuid"); @@ -846,7 +989,7 @@ namespace Socialize.Api.Migrations b.ToTable("Workspaces", (string)null); }); - modelBuilder.Entity("Socialize.Modules.Workspaces.Data.WorkspaceInvite", b => + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.WorkspaceInvite", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -889,7 +1032,7 @@ namespace Socialize.Api.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { - b.HasOne("Socialize.Modules.Identity.Data.Role", null) + b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -898,7 +1041,7 @@ namespace Socialize.Api.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("Socialize.Modules.Identity.Data.User", null) + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -907,7 +1050,7 @@ namespace Socialize.Api.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("Socialize.Modules.Identity.Data.User", null) + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -916,13 +1059,13 @@ namespace Socialize.Api.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.HasOne("Socialize.Modules.Identity.Data.Role", null) + b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Socialize.Modules.Identity.Data.User", null) + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -931,12 +1074,28 @@ namespace Socialize.Api.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("Socialize.Modules.Identity.Data.User", null) + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + 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("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 new file mode 100644 index 0000000..bc68686 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Contracts/FeedbackDtos.cs @@ -0,0 +1,81 @@ +using Socialize.Api.Modules.Feedback.Data; + +namespace Socialize.Api.Modules.Feedback.Contracts; + +public record FeedbackContextDto( + Guid? WorkspaceId, + string? WorkspaceName, + Guid? ClientId, + string? ClientName, + Guid? ProjectId, + string? ProjectName, + Guid? ContentItemId, + string? ContentItemTitle); + +public record FeedbackMetadataDto( + string SubmittedPath, + string? BrowserUserAgent, + int? ViewportWidth, + int? ViewportHeight, + string? AppVersion); + +public record FeedbackReportDto( + Guid Id, + string Type, + string Status, + string Description, + Guid ReporterUserId, + string ReporterDisplayName, + string ReporterEmail, + FeedbackMetadataDto Metadata, + FeedbackContextDto Context, + IReadOnlyCollection Tags, + DateTimeOffset CreatedAt, + DateTimeOffset LastActivityAt, + DateTimeOffset? CancelledAt, + string? CancellationReason); + +public static class FeedbackDtoMapper +{ + public static FeedbackReportDto ToDto(this FeedbackReport report) + { + return new FeedbackReportDto( + report.Id, + ToDisplayString(report.Type), + ToDisplayString(report.Status), + report.Description, + report.ReporterUserId, + report.ReporterDisplayName, + report.ReporterEmail, + new FeedbackMetadataDto( + report.SubmittedPath, + report.BrowserUserAgent, + report.ViewportWidth, + report.ViewportHeight, + report.AppVersion), + new FeedbackContextDto( + report.WorkspaceId, + report.WorkspaceName, + report.ClientId, + report.ClientName, + report.ProjectId, + report.ProjectName, + report.ContentItemId, + report.ContentItemTitle), + report.Tags.OrderBy(tag => tag.Name).Select(tag => tag.Name).ToArray(), + report.CreatedAt, + report.LastActivityAt, + report.CancelledAt, + report.CancellationReason); + } + + private static string ToDisplayString(FeedbackType type) + { + return type.ToString(); + } + + private static string ToDisplayString(FeedbackStatus status) + { + return status == FeedbackStatus.WontDo ? "Won't Do" : status.ToString(); + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackModelConfiguration.cs b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackModelConfiguration.cs new file mode 100644 index 0000000..ddac54f --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackModelConfiguration.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; + +namespace Socialize.Api.Modules.Feedback.Data; + +public static class FeedbackModelConfiguration +{ + public static ModelBuilder ConfigureFeedbackModule(this ModelBuilder modelBuilder) + { + modelBuilder.Entity(feedback => + { + feedback.ToTable("FeedbackReports"); + feedback.HasKey(x => x.Id); + feedback.Property(x => x.Type).HasConversion().HasMaxLength(32).IsRequired(); + feedback.Property(x => x.Status).HasConversion().HasMaxLength(32).IsRequired(); + feedback.Property(x => x.Description).HasMaxLength(8000).IsRequired(); + feedback.Property(x => x.ReporterDisplayName).HasMaxLength(256).IsRequired(); + feedback.Property(x => x.ReporterEmail).HasMaxLength(256).IsRequired(); + feedback.Property(x => x.SubmittedPath).HasMaxLength(2048).IsRequired(); + feedback.Property(x => x.BrowserUserAgent).HasMaxLength(1024); + feedback.Property(x => x.AppVersion).HasMaxLength(128); + feedback.Property(x => x.WorkspaceName).HasMaxLength(256); + feedback.Property(x => x.ClientName).HasMaxLength(256); + feedback.Property(x => x.ProjectName).HasMaxLength(256); + feedback.Property(x => x.ContentItemTitle).HasMaxLength(256); + feedback.Property(x => x.CancellationReason).HasMaxLength(2000); + feedback.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP"); + feedback.HasIndex(x => x.ReporterUserId); + feedback.HasIndex(x => x.Status); + feedback.HasIndex(x => x.Type); + feedback.HasIndex(x => x.WorkspaceId); + feedback.HasIndex(x => x.LastActivityAt); + }); + + modelBuilder.Entity(tag => + { + tag.ToTable("FeedbackTags"); + tag.HasKey(x => x.Id); + tag.Property(x => x.Name).HasMaxLength(64).IsRequired(); + tag.Property(x => x.NormalizedName).HasMaxLength(64).IsRequired(); + tag.HasIndex(x => x.NormalizedName); + tag.HasIndex(x => new { x.FeedbackReportId, x.NormalizedName }).IsUnique(); + tag.HasOne(x => x.FeedbackReport) + .WithMany(x => x.Tags) + .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 new file mode 100644 index 0000000..5e0355f --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackReport.cs @@ -0,0 +1,31 @@ +namespace Socialize.Api.Modules.Feedback.Data; + +public class FeedbackReport +{ + public Guid Id { get; set; } + public FeedbackType Type { get; set; } + public FeedbackStatus Status { get; set; } + public string Description { get; set; } = string.Empty; + public Guid ReporterUserId { get; set; } + public string ReporterDisplayName { get; set; } = string.Empty; + public string ReporterEmail { get; set; } = string.Empty; + public string SubmittedPath { get; set; } = string.Empty; + public string? BrowserUserAgent { get; set; } + public int? ViewportWidth { get; set; } + public int? ViewportHeight { get; set; } + public string? AppVersion { get; set; } + public Guid? WorkspaceId { get; set; } + public string? WorkspaceName { get; set; } + public Guid? ClientId { get; set; } + public string? ClientName { get; set; } + public Guid? ProjectId { get; set; } + public string? ProjectName { get; set; } + public Guid? ContentItemId { get; set; } + public string? ContentItemTitle { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastActivityAt { get; set; } + public DateTimeOffset? CancelledAt { get; set; } + public Guid? CancelledByUserId { get; set; } + public string? CancellationReason { get; set; } + public ICollection Tags { get; } = new List(); +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackStatus.cs b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackStatus.cs new file mode 100644 index 0000000..12c2df8 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackStatus.cs @@ -0,0 +1,10 @@ +namespace Socialize.Api.Modules.Feedback.Data; + +public enum FeedbackStatus +{ + New, + Planned, + Resolved, + WontDo, + Cancelled, +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackTag.cs b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackTag.cs new file mode 100644 index 0000000..fa865c0 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackTag.cs @@ -0,0 +1,10 @@ +namespace Socialize.Api.Modules.Feedback.Data; + +public class FeedbackTag +{ + public Guid Id { get; set; } + public Guid FeedbackReportId { get; set; } + public string Name { get; set; } = string.Empty; + public string NormalizedName { get; set; } = string.Empty; + public FeedbackReport? FeedbackReport { get; set; } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackType.cs b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackType.cs new file mode 100644 index 0000000..519a7f7 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackType.cs @@ -0,0 +1,8 @@ +namespace Socialize.Api.Modules.Feedback.Data; + +public enum FeedbackType +{ + Bug, + Suggestion, + Request, +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/DependencyInjection.cs b/backend/src/Socialize.Api/Modules/Feedback/DependencyInjection.cs new file mode 100644 index 0000000..17cc920 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/DependencyInjection.cs @@ -0,0 +1,9 @@ +namespace Socialize.Api.Modules.Feedback; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddFeedbackModule(this WebApplicationBuilder builder) + { + return builder; + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/CancelMyFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/CancelMyFeedback.cs new file mode 100644 index 0000000..402f747 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/CancelMyFeedback.cs @@ -0,0 +1,65 @@ +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 CancelMyFeedbackRequest(string? Reason); + +public class CancelMyFeedbackRequestValidator + : Validator +{ + public CancelMyFeedbackRequestValidator() + { + RuleFor(x => x.Reason).MaximumLength(2000); + } +} + +public class CancelMyFeedbackHandler(AppDbContext dbContext) + : Endpoint +{ + public override void Configure() + { + Post("/api/my-feedback/{id}/cancel"); + Options(o => o.WithTags("Feedback")); + } + + public override async Task HandleAsync(CancelMyFeedbackRequest request, CancellationToken ct) + { + Guid id = Route("id"); + Guid reporterUserId = User.GetUserId(); + + FeedbackReport? report = await dbContext.FeedbackReports + .Include(candidate => candidate.Tags) + .SingleOrDefaultAsync( + candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId, + ct); + + if (report is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!FeedbackAccessRules.CanReporterCancel(report, reporterUserId)) + { + AddError("The feedback report cannot be cancelled."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + DateTimeOffset now = DateTimeOffset.UtcNow; + report.Status = FeedbackStatus.Cancelled; + report.CancelledAt = now; + report.CancelledByUserId = reporterUserId; + report.CancellationReason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim(); + report.LastActivityAt = 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 new file mode 100644 index 0000000..4c93b7a --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetDeveloperFeedback.cs @@ -0,0 +1,36 @@ +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 GetDeveloperFeedbackHandler(AppDbContext dbContext) + : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/api/feedback/{id}"); + Roles(KnownRoles.Developer); + Options(o => o.WithTags("Feedback")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid id = Route("id"); + FeedbackReportDto? report = await dbContext.FeedbackReports + .Include(candidate => candidate.Tags) + .Where(candidate => candidate.Id == id) + .Select(candidate => candidate.ToDto()) + .SingleOrDefaultAsync(ct); + + if (report is null) + { + await SendNotFoundAsync(ct); + return; + } + + await SendOkAsync(report, ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetMyFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetMyFeedback.cs new file mode 100644 index 0000000..000996c --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/GetMyFeedback.cs @@ -0,0 +1,37 @@ +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 GetMyFeedbackHandler(AppDbContext dbContext) + : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/api/my-feedback/{id}"); + Options(o => o.WithTags("Feedback")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid id = Route("id"); + Guid reporterUserId = User.GetUserId(); + + FeedbackReportDto? report = await dbContext.FeedbackReports + .Include(candidate => candidate.Tags) + .Where(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId) + .Select(candidate => candidate.ToDto()) + .SingleOrDefaultAsync(ct); + + if (report is null) + { + await SendNotFoundAsync(ct); + return; + } + + await SendOkAsync(report, ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListDeveloperFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListDeveloperFeedback.cs new file mode 100644 index 0000000..a1bbcc3 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListDeveloperFeedback.cs @@ -0,0 +1,29 @@ +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 ListDeveloperFeedbackHandler(AppDbContext dbContext) + : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/api/feedback"); + Roles(KnownRoles.Developer); + Options(o => o.WithTags("Feedback")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + List reports = await dbContext.FeedbackReports + .Include(report => report.Tags) + .OrderByDescending(report => report.LastActivityAt) + .Select(report => report.ToDto()) + .ToListAsync(ct); + + await SendOkAsync(reports, ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListFeedbackTags.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListFeedbackTags.cs new file mode 100644 index 0000000..bcf7004 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListFeedbackTags.cs @@ -0,0 +1,28 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Modules.Identity.Contracts; + +namespace Socialize.Api.Modules.Feedback.Handlers; + +public class ListFeedbackTagsHandler(AppDbContext dbContext) + : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/api/feedback/tags"); + Roles(KnownRoles.Developer); + Options(o => o.WithTags("Feedback")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + List tags = await dbContext.FeedbackTags + .GroupBy(tag => new { tag.NormalizedName, tag.Name }) + .OrderBy(group => group.Key.Name) + .Select(group => group.Key.Name) + .ToListAsync(ct); + + await SendOkAsync(tags, ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListMyFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListMyFeedback.cs new file mode 100644 index 0000000..4a760fb --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/ListMyFeedback.cs @@ -0,0 +1,30 @@ +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 ListMyFeedbackHandler(AppDbContext dbContext) + : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/api/my-feedback"); + Options(o => o.WithTags("Feedback")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid reporterUserId = User.GetUserId(); + List reports = await dbContext.FeedbackReports + .Include(report => report.Tags) + .Where(report => report.ReporterUserId == reporterUserId) + .OrderByDescending(report => report.LastActivityAt) + .Select(report => report.ToDto()) + .ToListAsync(ct); + + await SendOkAsync(reports, ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/SubmitFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/SubmitFeedback.cs new file mode 100644 index 0000000..f7b8b41 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/SubmitFeedback.cs @@ -0,0 +1,102 @@ +using FastEndpoints; +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 SubmitFeedbackRequest( + string Type, + string Description, + string SubmittedPath, + string? BrowserUserAgent, + int? ViewportWidth, + int? ViewportHeight, + string? AppVersion, + Guid? WorkspaceId, + string? WorkspaceName, + Guid? ClientId, + string? ClientName, + Guid? ProjectId, + string? ProjectName, + Guid? ContentItemId, + string? ContentItemTitle); + +public class SubmitFeedbackRequestValidator + : Validator +{ + public SubmitFeedbackRequestValidator() + { + RuleFor(x => x.Type).NotEmpty().MaximumLength(32); + RuleFor(x => x.Description).NotEmpty().MaximumLength(8000); + RuleFor(x => x.SubmittedPath).NotEmpty().MaximumLength(2048); + RuleFor(x => x.BrowserUserAgent).MaximumLength(1024); + RuleFor(x => x.AppVersion).MaximumLength(128); + RuleFor(x => x.WorkspaceName).MaximumLength(256); + RuleFor(x => x.ClientName).MaximumLength(256); + RuleFor(x => x.ProjectName).MaximumLength(256); + RuleFor(x => x.ContentItemTitle).MaximumLength(256); + RuleFor(x => x.ViewportWidth).GreaterThan(0).When(x => x.ViewportWidth.HasValue); + RuleFor(x => x.ViewportHeight).GreaterThan(0).When(x => x.ViewportHeight.HasValue); + } +} + +public class SubmitFeedbackHandler(AppDbContext dbContext) + : Endpoint +{ + public override void Configure() + { + Post("/api/feedback"); + Options(o => o.WithTags("Feedback")); + } + + public override async Task HandleAsync(SubmitFeedbackRequest request, CancellationToken ct) + { + if (!FeedbackRules.TryParseType(request.Type, out FeedbackType type)) + { + AddError(request => request.Type, "The selected feedback type is not valid."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + DateTimeOffset now = DateTimeOffset.UtcNow; + FeedbackReport report = new() + { + Id = Guid.NewGuid(), + Type = type, + Status = FeedbackStatus.New, + Description = request.Description.Trim(), + ReporterUserId = User.GetUserId(), + ReporterDisplayName = User.GetAlias() ?? User.GetName(), + ReporterEmail = User.GetEmail(), + SubmittedPath = request.SubmittedPath.Trim(), + BrowserUserAgent = NormalizeOptional(request.BrowserUserAgent), + ViewportWidth = request.ViewportWidth, + ViewportHeight = request.ViewportHeight, + AppVersion = NormalizeOptional(request.AppVersion), + WorkspaceId = request.WorkspaceId, + WorkspaceName = NormalizeOptional(request.WorkspaceName), + ClientId = request.ClientId, + ClientName = NormalizeOptional(request.ClientName), + ProjectId = request.ProjectId, + ProjectName = NormalizeOptional(request.ProjectName), + ContentItemId = request.ContentItemId, + ContentItemTitle = NormalizeOptional(request.ContentItemTitle), + CreatedAt = now, + LastActivityAt = now, + }; + + dbContext.FeedbackReports.Add(report); + await dbContext.SaveChangesAsync(ct); + + await SendAsync(report.ToDto(), StatusCodes.Status201Created, ct); + } + + private static string? NormalizeOptional(string? value) + { + string? normalized = value?.Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/UpdateDeveloperFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/UpdateDeveloperFeedback.cs new file mode 100644 index 0000000..533bef7 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/UpdateDeveloperFeedback.cs @@ -0,0 +1,141 @@ +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.Feedback.Services; +using Socialize.Api.Modules.Identity.Contracts; + +namespace Socialize.Api.Modules.Feedback.Handlers; + +public record UpdateDeveloperFeedbackRequest( + string? Type, + string? Status, + IReadOnlyCollection? Tags); + +public class UpdateDeveloperFeedbackRequestValidator + : Validator +{ + public UpdateDeveloperFeedbackRequestValidator() + { + RuleFor(x => x.Type).MaximumLength(32); + RuleFor(x => x.Status).MaximumLength(32); + RuleForEach(x => x.Tags).MaximumLength(64); + } +} + +public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext) + : Endpoint +{ + public override void Configure() + { + Patch("/api/feedback/{id}"); + Roles(KnownRoles.Developer); + Options(o => o.WithTags("Feedback")); + } + + public override async Task HandleAsync(UpdateDeveloperFeedbackRequest request, CancellationToken ct) + { + Guid id = Route("id"); + FeedbackReport? report = await dbContext.FeedbackReports + .Include(candidate => candidate.Tags) + .SingleOrDefaultAsync(candidate => candidate.Id == id, ct); + + if (report is null) + { + await SendNotFoundAsync(ct); + return; + } + + bool changed = false; + if (!string.IsNullOrWhiteSpace(request.Type)) + { + if (!FeedbackRules.TryParseType(request.Type, out FeedbackType nextType)) + { + AddError(request => request.Type, "The selected feedback type is not valid."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + if (report.Type != nextType) + { + report.Type = nextType; + changed = true; + } + } + + if (!string.IsNullOrWhiteSpace(request.Status)) + { + if (!FeedbackRules.TryParseStatus(request.Status, out FeedbackStatus nextStatus)) + { + AddError(request => request.Status, "The selected feedback status is not valid."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + if (!FeedbackRules.CanDeveloperSetStatus(report.Status, nextStatus)) + { + AddError(request => request.Status, "The requested status transition is not allowed."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + if (report.Status != nextStatus) + { + report.Status = nextStatus; + changed = true; + } + } + + if (request.Tags is not null) + { + IReadOnlyCollection normalizedTags = FeedbackRules.NormalizeTags(request.Tags); + ApplyTags(report, normalizedTags); + changed = true; + } + + if (changed) + { + report.LastActivityAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(ct); + } + + await SendOkAsync(report.ToDto(), ct); + } + + private static void ApplyTags(FeedbackReport report, IReadOnlyCollection tags) + { + HashSet requestedKeys = tags + .Select(FeedbackRules.NormalizeTagKey) + .ToHashSet(StringComparer.Ordinal); + + foreach (FeedbackTag existingTag in report.Tags.ToArray()) + { + if (!requestedKeys.Contains(existingTag.NormalizedName)) + { + report.Tags.Remove(existingTag); + } + } + + HashSet existingKeys = report.Tags + .Select(tag => tag.NormalizedName) + .ToHashSet(StringComparer.Ordinal); + + foreach (string tag in tags) + { + string key = FeedbackRules.NormalizeTagKey(tag); + if (existingKeys.Contains(key)) + { + continue; + } + + report.Tags.Add(new FeedbackTag + { + Id = Guid.NewGuid(), + FeedbackReportId = report.Id, + Name = tag, + NormalizedName = key, + }); + } + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackAccessRules.cs b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackAccessRules.cs new file mode 100644 index 0000000..8e76ecf --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackAccessRules.cs @@ -0,0 +1,16 @@ +using Socialize.Api.Modules.Feedback.Data; + +namespace Socialize.Api.Modules.Feedback.Services; + +public static class FeedbackAccessRules +{ + public static bool CanReporterAccess(FeedbackReport report, Guid userId) + { + return report.ReporterUserId == userId; + } + + public static bool CanReporterCancel(FeedbackReport report, Guid userId) + { + return CanReporterAccess(report, userId) && FeedbackRules.CanReporterCancel(report.Status); + } +} diff --git a/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackRules.cs b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackRules.cs new file mode 100644 index 0000000..d1a4153 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Feedback/Services/FeedbackRules.cs @@ -0,0 +1,63 @@ +using Socialize.Api.Modules.Feedback.Data; + +namespace Socialize.Api.Modules.Feedback.Services; + +public static class FeedbackRules +{ + public static bool TryParseType(string? value, out FeedbackType type) + { + return Enum.TryParse(value?.Trim(), ignoreCase: true, out type) + && Enum.IsDefined(type); + } + + public static bool TryParseStatus(string? value, out FeedbackStatus status) + { + string? normalized = value?.Trim().Replace("'", string.Empty, StringComparison.Ordinal); + if (string.Equals(normalized, "Wont Do", StringComparison.OrdinalIgnoreCase) || + string.Equals(normalized, "WontDo", StringComparison.OrdinalIgnoreCase)) + { + status = FeedbackStatus.WontDo; + return true; + } + + return Enum.TryParse(normalized, ignoreCase: true, out status) + && Enum.IsDefined(status); + } + + public static bool IsFinal(FeedbackStatus status) + { + return status is FeedbackStatus.Cancelled; + } + + public static bool CanDeveloperSetStatus(FeedbackStatus currentStatus, FeedbackStatus nextStatus) + { + return !IsFinal(currentStatus) && + nextStatus is FeedbackStatus.New or FeedbackStatus.Planned or FeedbackStatus.Resolved or FeedbackStatus.WontDo; + } + + public static bool CanReporterCancel(FeedbackStatus currentStatus) + { + return !IsFinal(currentStatus); + } + + public static IReadOnlyCollection NormalizeTags(IEnumerable? tags) + { + if (tags is null) + { + return Array.Empty(); + } + + return tags + .Select(tag => tag.Trim()) + .Where(tag => !string.IsNullOrWhiteSpace(tag)) + .Select(tag => tag.Length > 64 ? tag[..64] : tag) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public static string NormalizeTagKey(string tag) + { + return tag.Trim().ToUpperInvariant(); + } +} diff --git a/backend/src/Socialize.Api/Modules/Identity/Contracts/KnownRoles.cs b/backend/src/Socialize.Api/Modules/Identity/Contracts/KnownRoles.cs index 460708b..578bbaa 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Contracts/KnownRoles.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Contracts/KnownRoles.cs @@ -7,4 +7,5 @@ public static class KnownRoles public const string Client = nameof(Client); public const string Provider = nameof(Provider); public const string WorkspaceMember = nameof(WorkspaceMember); + public const string Developer = nameof(Developer); } diff --git a/backend/src/Socialize.Api/Modules/Identity/DependencyInjection.cs b/backend/src/Socialize.Api/Modules/Identity/DependencyInjection.cs index cb7dad8..9776e85 100644 --- a/backend/src/Socialize.Api/Modules/Identity/DependencyInjection.cs +++ b/backend/src/Socialize.Api/Modules/Identity/DependencyInjection.cs @@ -97,5 +97,11 @@ public static class DependencyInjection { await roleManager.CreateAsync(workspaceMemberRole); } + + Role developerRole = new(KnownRoles.Developer); + if (roleManager.Roles.All(r => r.Name != developerRole.Name)) + { + await roleManager.CreateAsync(developerRole); + } } } diff --git a/backend/src/Socialize.Api/Program.cs b/backend/src/Socialize.Api/Program.cs index 5d7b2b0..5f9d68c 100644 --- a/backend/src/Socialize.Api/Program.cs +++ b/backend/src/Socialize.Api/Program.cs @@ -13,6 +13,7 @@ using Socialize.Api.Modules.Assets; using Socialize.Api.Modules.Clients; using Socialize.Api.Modules.Comments; using Socialize.Api.Modules.ContentItems; +using Socialize.Api.Modules.Feedback; using Socialize.Api.Modules.Identity; using Socialize.Api.Modules.Notifications; using Socialize.Api.Modules.Projects; @@ -69,6 +70,7 @@ builder.AddAssetsModule(); builder.AddCommentsModule(); builder.AddApprovalsModule(); builder.AddNotificationsModule(); +builder.AddFeedbackModule(); var app = builder.Build(); diff --git a/backend/tests/Socialize.Tests/Feedback/FeedbackRulesTests.cs b/backend/tests/Socialize.Tests/Feedback/FeedbackRulesTests.cs new file mode 100644 index 0000000..4299160 --- /dev/null +++ b/backend/tests/Socialize.Tests/Feedback/FeedbackRulesTests.cs @@ -0,0 +1,115 @@ +using Socialize.Api.Modules.Feedback.Data; +using Socialize.Api.Modules.Feedback.Services; + +namespace Socialize.Tests.Feedback; + +public class FeedbackRulesTests +{ + [Theory] + [InlineData("Bug", FeedbackType.Bug)] + [InlineData("suggestion", FeedbackType.Suggestion)] + [InlineData("Request", FeedbackType.Request)] + public void TryParseType_accepts_supported_types(string value, FeedbackType expected) + { + bool parsed = FeedbackRules.TryParseType(value, out FeedbackType type); + + Assert.True(parsed); + Assert.Equal(expected, type); + } + + [Theory] + [InlineData("")] + [InlineData("Question")] + [InlineData("Incident")] + public void TryParseType_rejects_unsupported_types(string value) + { + bool parsed = FeedbackRules.TryParseType(value, out _); + + Assert.False(parsed); + } + + [Theory] + [InlineData("New", FeedbackStatus.New)] + [InlineData("Planned", FeedbackStatus.Planned)] + [InlineData("Resolved", FeedbackStatus.Resolved)] + [InlineData("Won't Do", FeedbackStatus.WontDo)] + [InlineData("WontDo", FeedbackStatus.WontDo)] + [InlineData("Cancelled", FeedbackStatus.Cancelled)] + public void TryParseStatus_accepts_supported_statuses(string value, FeedbackStatus expected) + { + bool parsed = FeedbackRules.TryParseStatus(value, out FeedbackStatus status); + + Assert.True(parsed); + Assert.Equal(expected, status); + } + + [Fact] + public void CanDeveloperSetStatus_rejects_cancelled_destination() + { + bool allowed = FeedbackRules.CanDeveloperSetStatus(FeedbackStatus.New, FeedbackStatus.Cancelled); + + Assert.False(allowed); + } + + [Fact] + public void CanDeveloperSetStatus_rejects_changes_after_cancelled() + { + bool allowed = FeedbackRules.CanDeveloperSetStatus(FeedbackStatus.Cancelled, FeedbackStatus.Planned); + + Assert.False(allowed); + } + + [Fact] + public void CanReporterCancel_rejects_cancelled_report() + { + bool allowed = FeedbackRules.CanReporterCancel(FeedbackStatus.Cancelled); + + Assert.False(allowed); + } + + [Fact] + public void CanReporterAccess_allows_report_owner() + { + Guid reporterUserId = Guid.NewGuid(); + FeedbackReport report = new() { ReporterUserId = reporterUserId }; + + bool allowed = FeedbackAccessRules.CanReporterAccess(report, reporterUserId); + + Assert.True(allowed); + } + + [Fact] + public void CanReporterAccess_rejects_other_users() + { + FeedbackReport report = new() { ReporterUserId = Guid.NewGuid() }; + + bool allowed = FeedbackAccessRules.CanReporterAccess(report, Guid.NewGuid()); + + Assert.False(allowed); + } + + [Fact] + public void CanReporterCancel_requires_owner_and_non_final_status() + { + Guid reporterUserId = Guid.NewGuid(); + FeedbackReport report = new() + { + ReporterUserId = reporterUserId, + Status = FeedbackStatus.New, + }; + + bool ownerAllowed = FeedbackAccessRules.CanReporterCancel(report, reporterUserId); + bool otherUserAllowed = FeedbackAccessRules.CanReporterCancel(report, Guid.NewGuid()); + + Assert.True(ownerAllowed); + Assert.False(otherUserAllowed); + } + + [Fact] + public void NormalizeTags_trims_deduplicates_and_orders() + { + IReadOnlyCollection tags = FeedbackRules.NormalizeTags([" mobile ", "bug", "Mobile", ""]); + + Assert.Equal(["bug", "mobile"], tags); + } +} diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index b0439a6..944b7fb 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -4,6 +4,22 @@ */ export interface paths { + "/api/storage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["GetApiStorage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/workspaces/{id}/logo": { parameters: { query?: never; @@ -420,6 +436,102 @@ export interface paths { patch?: never; trace?: never; }; + "/api/my-feedback/{id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["SocializeApiModulesFeedbackHandlersCancelMyFeedbackHandler"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/feedback/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SocializeApiModulesFeedbackHandlersGetDeveloperFeedbackHandler"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackHandler"]; + trace?: never; + }; + "/api/my-feedback/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SocializeApiModulesFeedbackHandlersGetMyFeedbackHandler"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/feedback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SocializeApiModulesFeedbackHandlersListDeveloperFeedbackHandler"]; + put?: never; + post: operations["SocializeApiModulesFeedbackHandlersSubmitFeedbackHandler"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/feedback/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SocializeApiModulesFeedbackHandlersListFeedbackTagsHandler"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/my-feedback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SocializeApiModulesFeedbackHandlersListMyFeedbackHandler"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/content-items": { parameters: { query?: never; @@ -874,6 +986,81 @@ export interface components { message?: string; }; SocializeApiModulesIdentityHandlersVerifyEmailRequest: Record; + SocializeApiModulesFeedbackContractsFeedbackReportDto: { + /** Format: guid */ + id?: string; + type?: string; + status?: string; + description?: string; + /** Format: guid */ + reporterUserId?: string; + reporterDisplayName?: string; + reporterEmail?: string; + metadata?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackMetadataDto"]; + context?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackContextDto"]; + tags?: string[]; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + lastActivityAt?: string; + /** Format: date-time */ + cancelledAt?: string | null; + cancellationReason?: string | null; + }; + SocializeApiModulesFeedbackContractsFeedbackMetadataDto: { + submittedPath?: string; + browserUserAgent?: string | null; + /** Format: int32 */ + viewportWidth?: number | null; + /** Format: int32 */ + viewportHeight?: number | null; + appVersion?: string | null; + }; + SocializeApiModulesFeedbackContractsFeedbackContextDto: { + /** Format: guid */ + workspaceId?: string | null; + workspaceName?: string | null; + /** Format: guid */ + clientId?: string | null; + clientName?: string | null; + /** Format: guid */ + projectId?: string | null; + projectName?: string | null; + /** Format: guid */ + contentItemId?: string | null; + contentItemTitle?: string | null; + }; + SocializeApiModulesFeedbackHandlersCancelMyFeedbackRequest: { + reason?: string | null; + }; + SocializeApiModulesFeedbackHandlersSubmitFeedbackRequest: { + type: string; + description: string; + submittedPath: string; + browserUserAgent?: string | null; + /** Format: int32 */ + viewportWidth?: number | null; + /** Format: int32 */ + viewportHeight?: number | null; + appVersion?: string | null; + /** Format: guid */ + workspaceId?: string | null; + workspaceName?: string | null; + /** Format: guid */ + clientId?: string | null; + clientName?: string | null; + /** Format: guid */ + projectId?: string | null; + projectName?: string | null; + /** Format: guid */ + contentItemId?: string | null; + contentItemTitle?: string | null; + }; + SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackRequest: { + type?: string | null; + status?: string | null; + tags?: string[] | null; + }; SocializeApiModulesContentItemsHandlersContentItemDto: { /** Format: guid */ id?: string; @@ -1146,6 +1333,25 @@ export interface components { } export type $defs = Record; export interface operations { + GetApiStorage: { + parameters: { + query?: never; + header?: never; + path: { + blobPath: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SocializeApiModulesWorkspacesHandlersChangeWorkspaceLogoHandler: { parameters: { query?: never; @@ -2032,6 +2238,297 @@ export interface operations { }; }; }; + SocializeApiModulesFeedbackHandlersCancelMyFeedbackHandler: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SocializeApiModulesFeedbackHandlersCancelMyFeedbackRequest"]; + }; + }; + 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; + }; + }; + }; + SocializeApiModulesFeedbackHandlersGetDeveloperFeedbackHandler: { + 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"]["SocializeApiModulesFeedbackContractsFeedbackReportDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackHandler: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackRequest"]; + }; + }; + 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; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SocializeApiModulesFeedbackHandlersGetMyFeedbackHandler: { + 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"]["SocializeApiModulesFeedbackContractsFeedbackReportDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SocializeApiModulesFeedbackHandlersListDeveloperFeedbackHandler: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackReportDto"][]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SocializeApiModulesFeedbackHandlersSubmitFeedbackHandler: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SocializeApiModulesFeedbackHandlersSubmitFeedbackRequest"]; + }; + }; + 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; + }; + }; + }; + SocializeApiModulesFeedbackHandlersListFeedbackTagsHandler: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string[]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SocializeApiModulesFeedbackHandlersListMyFeedbackHandler: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackReportDto"][]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SocializeApiModulesContentItemsHandlersGetContentItemsHandler: { parameters: { query?: { diff --git a/shared/openapi/openapi.json b/shared/openapi/openapi.json index 0af3061..1e5b2b7 100644 --- a/shared/openapi/openapi.json +++ b/shared/openapi/openapi.json @@ -7,10 +7,31 @@ }, "servers": [ { - "url": "http://127.0.0.1:5081" + "url": "http://localhost:5080" } ], "paths": { + "/api/storage": { + "get": { + "operationId": "GetApiStorage", + "parameters": [ + { + "name": "blobPath", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "x-position": 1 + } + ], + "responses": { + "200": { + "description": "" + } + } + } + }, "/api/workspaces/{id}/logo": { "post": { "tags": [ @@ -1200,6 +1221,372 @@ } } }, + "/api/my-feedback/{id}/cancel": { + "post": { + "tags": [ + "Feedback", + "Api" + ], + "operationId": "SocializeApiModulesFeedbackHandlersCancelMyFeedbackHandler", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "x-name": "CancelMyFeedbackRequest", + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesFeedbackHandlersCancelMyFeedbackRequest" + } + } + }, + "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/feedback/{id}": { + "get": { + "tags": [ + "Feedback", + "Api" + ], + "operationId": "SocializeApiModulesFeedbackHandlersGetDeveloperFeedbackHandler", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackReportDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "Developer" + ] + } + ] + }, + "patch": { + "tags": [ + "Feedback", + "Api" + ], + "operationId": "SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackHandler", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "x-name": "UpdateDeveloperFeedbackRequest", + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackRequest" + } + } + }, + "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" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "Developer" + ] + } + ] + } + }, + "/api/my-feedback/{id}": { + "get": { + "tags": [ + "Feedback", + "Api" + ], + "operationId": "SocializeApiModulesFeedbackHandlersGetMyFeedbackHandler", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackReportDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "JWTBearerAuth": [] + } + ] + } + }, + "/api/feedback": { + "get": { + "tags": [ + "Feedback", + "Api" + ], + "operationId": "SocializeApiModulesFeedbackHandlersListDeveloperFeedbackHandler", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackReportDto" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "Developer" + ] + } + ] + }, + "post": { + "tags": [ + "Feedback", + "Api" + ], + "operationId": "SocializeApiModulesFeedbackHandlersSubmitFeedbackHandler", + "requestBody": { + "x-name": "SubmitFeedbackRequest", + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesFeedbackHandlersSubmitFeedbackRequest" + } + } + }, + "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/feedback/tags": { + "get": { + "tags": [ + "Feedback", + "Api" + ], + "operationId": "SocializeApiModulesFeedbackHandlersListFeedbackTagsHandler", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "Developer" + ] + } + ] + } + }, + "/api/my-feedback": { + "get": { + "tags": [ + "Feedback", + "Api" + ], + "operationId": "SocializeApiModulesFeedbackHandlersListMyFeedbackHandler", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackReportDto" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "JWTBearerAuth": [] + } + ] + } + }, "/api/content-items": { "post": { "tags": [ @@ -2900,6 +3287,271 @@ "type": "object", "additionalProperties": false }, + "SocializeApiModulesFeedbackContractsFeedbackReportDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "format": "guid" + }, + "type": { + "type": "string" + }, + "status": { + "type": "string" + }, + "description": { + "type": "string" + }, + "reporterUserId": { + "type": "string", + "format": "guid" + }, + "reporterDisplayName": { + "type": "string" + }, + "reporterEmail": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackMetadataDto" + }, + "context": { + "$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackContextDto" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "lastActivityAt": { + "type": "string", + "format": "date-time" + }, + "cancelledAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "cancellationReason": { + "type": "string", + "nullable": true + } + } + }, + "SocializeApiModulesFeedbackContractsFeedbackMetadataDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "submittedPath": { + "type": "string" + }, + "browserUserAgent": { + "type": "string", + "nullable": true + }, + "viewportWidth": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "viewportHeight": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "appVersion": { + "type": "string", + "nullable": true + } + } + }, + "SocializeApiModulesFeedbackContractsFeedbackContextDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string", + "format": "guid", + "nullable": true + }, + "workspaceName": { + "type": "string", + "nullable": true + }, + "clientId": { + "type": "string", + "format": "guid", + "nullable": true + }, + "clientName": { + "type": "string", + "nullable": true + }, + "projectId": { + "type": "string", + "format": "guid", + "nullable": true + }, + "projectName": { + "type": "string", + "nullable": true + }, + "contentItemId": { + "type": "string", + "format": "guid", + "nullable": true + }, + "contentItemTitle": { + "type": "string", + "nullable": true + } + } + }, + "SocializeApiModulesFeedbackHandlersCancelMyFeedbackRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "maxLength": 2000, + "minLength": 0, + "nullable": true + } + } + }, + "SocializeApiModulesFeedbackHandlersSubmitFeedbackRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "description", + "submittedPath" + ], + "properties": { + "type": { + "type": "string", + "maxLength": 32, + "minLength": 0, + "nullable": false + }, + "description": { + "type": "string", + "maxLength": 8000, + "minLength": 0, + "nullable": false + }, + "submittedPath": { + "type": "string", + "maxLength": 2048, + "minLength": 0, + "nullable": false + }, + "browserUserAgent": { + "type": "string", + "maxLength": 1024, + "minLength": 0, + "nullable": true + }, + "viewportWidth": { + "type": "integer", + "format": "int32", + "minimum": 0.0, + "nullable": true, + "exclusiveMinimum": true + }, + "viewportHeight": { + "type": "integer", + "format": "int32", + "minimum": 0.0, + "nullable": true, + "exclusiveMinimum": true + }, + "appVersion": { + "type": "string", + "maxLength": 128, + "minLength": 0, + "nullable": true + }, + "workspaceId": { + "type": "string", + "format": "guid", + "nullable": true + }, + "workspaceName": { + "type": "string", + "maxLength": 256, + "minLength": 0, + "nullable": true + }, + "clientId": { + "type": "string", + "format": "guid", + "nullable": true + }, + "clientName": { + "type": "string", + "maxLength": 256, + "minLength": 0, + "nullable": true + }, + "projectId": { + "type": "string", + "format": "guid", + "nullable": true + }, + "projectName": { + "type": "string", + "maxLength": 256, + "minLength": 0, + "nullable": true + }, + "contentItemId": { + "type": "string", + "format": "guid", + "nullable": true + }, + "contentItemTitle": { + "type": "string", + "maxLength": 256, + "minLength": 0, + "nullable": true + } + } + }, + "SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "maxLength": 32, + "minLength": 0, + "nullable": true + }, + "status": { + "type": "string", + "maxLength": 32, + "minLength": 0, + "nullable": true + }, + "tags": { + "type": "array", + "maxLength": 64, + "minLength": 0, + "nullable": true, + "items": { + "type": "string" + } + } + } + }, "SocializeApiModulesContentItemsHandlersContentItemDto": { "type": "object", "additionalProperties": false,