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