feat: add feedback backend foundation

This commit is contained in:
2026-04-30 03:31:42 -04:00
parent f9960b4fc9
commit cb6948aa14
27 changed files with 3428 additions and 25 deletions

View File

@@ -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<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>();
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
public DbSet<FeedbackReport> FeedbackReports => Set<FeedbackReport>();
public DbSet<FeedbackTag> FeedbackTags => Set<FeedbackTag>();
protected override void OnModelCreating(ModelBuilder builder)
{
@@ -41,5 +44,6 @@ public class AppDbContext(
builder.ConfigureCommentsModule();
builder.ConfigureApprovalsModule();
builder.ConfigureNotificationsModule();
builder.ConfigureFeedbackModule();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddFeedbackFoundation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FeedbackReports",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Description = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: false),
ReporterUserId = table.Column<Guid>(type: "uuid", nullable: false),
ReporterDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ReporterEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
SubmittedPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
BrowserUserAgent = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
ViewportWidth = table.Column<int>(type: "integer", nullable: true),
ViewportHeight = table.Column<int>(type: "integer", nullable: true),
AppVersion = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: true),
WorkspaceName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ClientId = table.Column<Guid>(type: "uuid", nullable: true),
ClientName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ProjectId = table.Column<Guid>(type: "uuid", nullable: true),
ProjectName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: true),
ContentItemTitle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
LastActivityAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CancelledAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CancelledByUserId = table.Column<Guid>(type: "uuid", nullable: true),
CancellationReason = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
NormalizedName = table.Column<string>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FeedbackTags");
migrationBuilder.DropTable(
name: "FeedbackReports");
}
}
}

View File

@@ -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<Guid>("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<Guid>("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<Guid>("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<Guid>("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<Guid>("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<Guid>("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<Guid>("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<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AppVersion")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("BrowserUserAgent")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("CancellationReason")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("CancelledAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("CancelledByUserId")
.HasColumnType("uuid");
b.Property<Guid?>("ClientId")
.HasColumnType("uuid");
b.Property<string>("ClientName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("ContentItemId")
.HasColumnType("uuid");
b.Property<string>("ContentItemTitle")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(8000)
.HasColumnType("character varying(8000)");
b.Property<DateTimeOffset>("LastActivityAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("ProjectName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ReporterDisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ReporterEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("ReporterUserId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("SubmittedPath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int?>("ViewportHeight")
.HasColumnType("integer");
b.Property<int?>("ViewportWidth")
.HasColumnType("integer");
b.Property<Guid?>("WorkspaceId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("FeedbackReportId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("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<Guid>("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<Guid>("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<Guid>("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<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -814,15 +957,15 @@ namespace Socialize.Api.Migrations
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("LogoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LogoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -889,7 +1032,7 @@ namespace Socialize.Api.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", 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<System.Guid>", 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<System.Guid>", 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<System.Guid>", 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<System.Guid>", 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
}
}

View File

@@ -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<string> 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();
}
}

View File

@@ -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<FeedbackReport>(feedback =>
{
feedback.ToTable("FeedbackReports");
feedback.HasKey(x => x.Id);
feedback.Property(x => x.Type).HasConversion<string>().HasMaxLength(32).IsRequired();
feedback.Property(x => x.Status).HasConversion<string>().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<FeedbackTag>(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;
}
}

View File

@@ -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<FeedbackTag> Tags { get; } = new List<FeedbackTag>();
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.Feedback.Data;
public enum FeedbackStatus
{
New,
Planned,
Resolved,
WontDo,
Cancelled,
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,8 @@
namespace Socialize.Api.Modules.Feedback.Data;
public enum FeedbackType
{
Bug,
Suggestion,
Request,
}

View File

@@ -0,0 +1,9 @@
namespace Socialize.Api.Modules.Feedback;
public static class DependencyInjection
{
public static WebApplicationBuilder AddFeedbackModule(this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -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<CancelMyFeedbackRequest>
{
public CancelMyFeedbackRequestValidator()
{
RuleFor(x => x.Reason).MaximumLength(2000);
}
}
public class CancelMyFeedbackHandler(AppDbContext dbContext)
: Endpoint<CancelMyFeedbackRequest, FeedbackReportDto>
{
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<Guid>("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);
}
}

View File

@@ -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<FeedbackReportDto>
{
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<Guid>("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);
}
}

View File

@@ -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<FeedbackReportDto>
{
public override void Configure()
{
Get("/api/my-feedback/{id}");
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("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);
}
}

View File

@@ -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<IReadOnlyCollection<FeedbackReportDto>>
{
public override void Configure()
{
Get("/api/feedback");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(CancellationToken ct)
{
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
.Include(report => report.Tags)
.OrderByDescending(report => report.LastActivityAt)
.Select(report => report.ToDto())
.ToListAsync(ct);
await SendOkAsync(reports, ct);
}
}

View File

@@ -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<IReadOnlyCollection<string>>
{
public override void Configure()
{
Get("/api/feedback/tags");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(CancellationToken ct)
{
List<string> 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);
}
}

View File

@@ -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<IReadOnlyCollection<FeedbackReportDto>>
{
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<FeedbackReportDto> 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);
}
}

View File

@@ -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<SubmitFeedbackRequest>
{
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<SubmitFeedbackRequest, FeedbackReportDto>
{
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;
}
}

View File

@@ -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<string>? Tags);
public class UpdateDeveloperFeedbackRequestValidator
: Validator<UpdateDeveloperFeedbackRequest>
{
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<UpdateDeveloperFeedbackRequest, FeedbackReportDto>
{
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<Guid>("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<string> 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<string> tags)
{
HashSet<string> 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<string> 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,
});
}
}
}

View File

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

View File

@@ -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<string> NormalizeTags(IEnumerable<string>? tags)
{
if (tags is null)
{
return Array.Empty<string>();
}
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();
}
}

View File

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

View File

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

View File

@@ -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();

View File

@@ -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<string> tags = FeedbackRules.NormalizeTags([" mobile ", "bug", "Mobile", ""]);
Assert.Equal(["bug", "mobile"], tags);
}
}

View File

@@ -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<string, never>;
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<string, never>;
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?: {

View File

@@ -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,