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<FeedbackTag> FeedbackTags => Set<FeedbackTag>();
|
||||
public DbSet<FeedbackScreenshot> FeedbackScreenshots => Set<FeedbackScreenshot>();
|
||||
public DbSet<FeedbackComment> FeedbackComments => Set<FeedbackComment>();
|
||||
public DbSet<FeedbackActivityEntry> FeedbackActivityEntries => Set<FeedbackActivityEntry>();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1126,6 +1229,28 @@ namespace Socialize.Api.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
|
||||
.WithMany("ActivityEntries")
|
||||
.HasForeignKey("FeedbackReportId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("FeedbackReport");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackComment", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
|
||||
.WithMany("Comments")
|
||||
.HasForeignKey("FeedbackReportId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("FeedbackReport");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
|
||||
@@ -1150,6 +1275,10 @@ namespace Socialize.Api.Migrations
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
|
||||
{
|
||||
b.Navigation("ActivityEntries");
|
||||
|
||||
b.Navigation("Comments");
|
||||
|
||||
b.Navigation("Screenshot");
|
||||
|
||||
b.Navigation("Tags");
|
||||
|
||||
@@ -39,11 +39,26 @@ public record FeedbackReportDto(
|
||||
FeedbackContextDto Context,
|
||||
FeedbackScreenshotDto? Screenshot,
|
||||
IReadOnlyCollection<string> Tags,
|
||||
IReadOnlyCollection<FeedbackTimelineItemDto> Timeline,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset LastActivityAt,
|
||||
DateTimeOffset? CancelledAt,
|
||||
string? CancellationReason);
|
||||
|
||||
public record FeedbackTimelineItemDto(
|
||||
Guid Id,
|
||||
string Kind,
|
||||
Guid ActorUserId,
|
||||
string ActorDisplayName,
|
||||
string ActorEmail,
|
||||
string? ActorRole,
|
||||
string? Body,
|
||||
string? ActivityType,
|
||||
string? FromValue,
|
||||
string? ToValue,
|
||||
string? Note,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public static class FeedbackDtoMapper
|
||||
{
|
||||
public static FeedbackReportDto ToDto(this FeedbackReport report)
|
||||
@@ -81,6 +96,12 @@ public static class FeedbackDtoMapper
|
||||
$"/api/feedback/{report.Id}/screenshot",
|
||||
report.Screenshot.CreatedAt),
|
||||
report.Tags.OrderBy(tag => tag.Name).Select(tag => tag.Name).ToArray(),
|
||||
report.Comments
|
||||
.Select(comment => comment.ToTimelineDto())
|
||||
.Concat(report.ActivityEntries.Select(activity => activity.ToTimelineDto()))
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ThenBy(item => item.Kind)
|
||||
.ToArray(),
|
||||
report.CreatedAt,
|
||||
report.LastActivityAt,
|
||||
report.CancelledAt,
|
||||
@@ -96,4 +117,48 @@ public static class FeedbackDtoMapper
|
||||
{
|
||||
return status == FeedbackStatus.WontDo ? "Won't Do" : status.ToString();
|
||||
}
|
||||
|
||||
public static FeedbackTimelineItemDto ToTimelineDto(this FeedbackComment comment)
|
||||
{
|
||||
return new FeedbackTimelineItemDto(
|
||||
comment.Id,
|
||||
"Comment",
|
||||
comment.AuthorUserId,
|
||||
comment.AuthorDisplayName,
|
||||
comment.AuthorEmail,
|
||||
comment.AuthorRole,
|
||||
comment.Body,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
comment.CreatedAt);
|
||||
}
|
||||
|
||||
public static FeedbackTimelineItemDto ToTimelineDto(this FeedbackActivityEntry activity)
|
||||
{
|
||||
return new FeedbackTimelineItemDto(
|
||||
activity.Id,
|
||||
"Activity",
|
||||
activity.ActorUserId,
|
||||
activity.ActorDisplayName,
|
||||
activity.ActorEmail,
|
||||
null,
|
||||
null,
|
||||
activity.ActivityType,
|
||||
activity.FromValue,
|
||||
activity.ToValue,
|
||||
activity.Note,
|
||||
activity.CreatedAt);
|
||||
}
|
||||
|
||||
public static string ToFeedbackDisplayString(this FeedbackType type)
|
||||
{
|
||||
return ToDisplayString(type);
|
||||
}
|
||||
|
||||
public static string ToFeedbackDisplayString(this FeedbackStatus status)
|
||||
{
|
||||
return ToDisplayString(status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,5 +28,7 @@ public class FeedbackReport
|
||||
public Guid? CancelledByUserId { get; set; }
|
||||
public string? CancellationReason { get; set; }
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddFeedbackModule(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddScoped<Services.FeedbackNotificationService>();
|
||||
|
||||
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;
|
||||
FeedbackStatus previousStatus = report.Status;
|
||||
report.Status = FeedbackStatus.Cancelled;
|
||||
report.CancelledAt = now;
|
||||
report.CancelledByUserId = reporterUserId;
|
||||
report.CancellationReason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
|
||||
report.LastActivityAt = now;
|
||||
report.ActivityEntries.Add(new FeedbackActivityEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FeedbackReportId = report.Id,
|
||||
ActorUserId = reporterUserId,
|
||||
ActorDisplayName = User.GetAlias() ?? User.GetName(),
|
||||
ActorEmail = User.GetEmail(),
|
||||
ActivityType = FeedbackActivityTypes.Cancelled,
|
||||
FromValue = previousStatus.ToFeedbackDisplayString(),
|
||||
ToValue = FeedbackStatus.Cancelled.ToFeedbackDisplayString(),
|
||||
Note = report.CancellationReason,
|
||||
CreatedAt = now,
|
||||
});
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
await SendOkAsync(report.ToDto(), ct);
|
||||
|
||||
@@ -2,6 +2,7 @@ using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
@@ -19,12 +20,12 @@ public class GetDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
FeedbackReportDto? report = await dbContext.FeedbackReports
|
||||
FeedbackReport? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Tags)
|
||||
.Include(candidate => candidate.Screenshot)
|
||||
.Where(candidate => candidate.Id == id)
|
||||
.Select(candidate => candidate.ToDto())
|
||||
.SingleOrDefaultAsync(ct);
|
||||
.Include(candidate => candidate.Comments)
|
||||
.Include(candidate => candidate.ActivityEntries)
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
|
||||
if (report is null)
|
||||
{
|
||||
@@ -32,6 +33,6 @@ public class GetDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(report, ct);
|
||||
await SendOkAsync(report.ToDto(), ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
@@ -20,12 +21,12 @@ public class GetMyFeedbackHandler(AppDbContext dbContext)
|
||||
Guid id = Route<Guid>("id");
|
||||
Guid reporterUserId = User.GetUserId();
|
||||
|
||||
FeedbackReportDto? report = await dbContext.FeedbackReports
|
||||
FeedbackReport? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Tags)
|
||||
.Include(candidate => candidate.Screenshot)
|
||||
.Where(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId)
|
||||
.Select(candidate => candidate.ToDto())
|
||||
.SingleOrDefaultAsync(ct);
|
||||
.Include(candidate => candidate.Comments)
|
||||
.Include(candidate => candidate.ActivityEntries)
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId, ct);
|
||||
|
||||
if (report is null)
|
||||
{
|
||||
@@ -33,6 +34,6 @@ public class GetMyFeedbackHandler(AppDbContext dbContext)
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(report, ct);
|
||||
await SendOkAsync(report.ToDto(), ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -89,6 +91,7 @@ public class SubmitFeedbackHandler(AppDbContext dbContext)
|
||||
};
|
||||
|
||||
dbContext.FeedbackReports.Add(report);
|
||||
await notificationService.AddNewReportNotificationsAsync(report, ct);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendAsync(report.ToDto(), StatusCodes.Status201Created, ct);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Feedback.Services;
|
||||
@@ -24,7 +25,9 @@ public class UpdateDeveloperFeedbackRequestValidator
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
public class UpdateDeveloperFeedbackHandler(
|
||||
AppDbContext dbContext,
|
||||
FeedbackNotificationService notificationService)
|
||||
: Endpoint<UpdateDeveloperFeedbackRequest, FeedbackReportDto>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -49,6 +52,8 @@ public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
Guid developerUserId = User.GetUserId();
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
if (!string.IsNullOrWhiteSpace(request.Type))
|
||||
{
|
||||
if (!FeedbackRules.TryParseType(request.Type, out FeedbackType nextType))
|
||||
@@ -60,6 +65,14 @@ public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
|
||||
if (report.Type != nextType)
|
||||
{
|
||||
AddActivity(
|
||||
report,
|
||||
developerUserId,
|
||||
FeedbackActivityTypes.TypeChanged,
|
||||
report.Type.ToFeedbackDisplayString(),
|
||||
nextType.ToFeedbackDisplayString(),
|
||||
null,
|
||||
now);
|
||||
report.Type = nextType;
|
||||
changed = true;
|
||||
}
|
||||
@@ -83,7 +96,16 @@ public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
|
||||
if (report.Status != nextStatus)
|
||||
{
|
||||
AddActivity(
|
||||
report,
|
||||
developerUserId,
|
||||
FeedbackActivityTypes.StatusChanged,
|
||||
report.Status.ToFeedbackDisplayString(),
|
||||
nextStatus.ToFeedbackDisplayString(),
|
||||
null,
|
||||
now);
|
||||
report.Status = nextStatus;
|
||||
notificationService.AddDeveloperStatusNotification(report, developerUserId, nextStatus);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -91,30 +113,68 @@ public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
if (request.Tags is not null)
|
||||
{
|
||||
IReadOnlyCollection<string> normalizedTags = FeedbackRules.NormalizeTags(request.Tags);
|
||||
ApplyTags(report, normalizedTags);
|
||||
changed = true;
|
||||
string beforeTags = FormatTags(report.Tags.Select(tag => tag.Name));
|
||||
bool tagsChanged = ApplyTags(report, normalizedTags);
|
||||
if (tagsChanged)
|
||||
{
|
||||
AddActivity(
|
||||
report,
|
||||
developerUserId,
|
||||
FeedbackActivityTypes.TagsChanged,
|
||||
beforeTags,
|
||||
FormatTags(normalizedTags),
|
||||
null,
|
||||
now);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
report.LastActivityAt = DateTimeOffset.UtcNow;
|
||||
report.LastActivityAt = now;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
await SendOkAsync(report.ToDto(), ct);
|
||||
}
|
||||
|
||||
private static void ApplyTags(FeedbackReport report, IReadOnlyCollection<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
|
||||
.Select(FeedbackRules.NormalizeTagKey)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
bool changed = false;
|
||||
foreach (FeedbackTag existingTag in report.Tags.ToArray())
|
||||
{
|
||||
if (!requestedKeys.Contains(existingTag.NormalizedName))
|
||||
{
|
||||
report.Tags.Remove(existingTag);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +197,14 @@ public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
Name = tag,
|
||||
NormalizedName = key,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static string FormatTags(IEnumerable<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);
|
||||
}
|
||||
|
||||
public static bool CanReporterComment(FeedbackReport report, Guid userId)
|
||||
{
|
||||
return CanReporterAccess(report, userId);
|
||||
}
|
||||
|
||||
public static bool CanDeveloperComment(bool isDeveloper)
|
||||
{
|
||||
return isDeveloper;
|
||||
}
|
||||
|
||||
public static bool CanAccessScreenshot(FeedbackReport report, Guid userId, bool isDeveloper)
|
||||
{
|
||||
return isDeveloper || CanReporterAccess(report, userId);
|
||||
|
||||
@@ -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();
|
||||
Guid currentUserId = User.GetUserId();
|
||||
|
||||
if (!accessScopeService.IsManager(User))
|
||||
{
|
||||
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)
|
||||
{
|
||||
query = query.Where(notificationEvent => notificationEvent.WorkspaceId == request.WorkspaceId.Value);
|
||||
|
||||
@@ -28,7 +28,9 @@ public class MarkNotificationAsReadHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanAccessWorkspace(User, notificationEvent.WorkspaceId))
|
||||
Guid currentUserId = User.GetUserId();
|
||||
bool canReadRecipientNotification = notificationEvent.RecipientUserId == currentUserId;
|
||||
if (!canReadRecipientNotification && !accessScopeService.CanAccessWorkspace(User, notificationEvent.WorkspaceId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Feedback.Services;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Socialize.Tests.Feedback;
|
||||
|
||||
@@ -105,6 +107,26 @@ public class FeedbackRulesTests
|
||||
Assert.False(otherUserAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanReporterComment_requires_report_owner()
|
||||
{
|
||||
Guid reporterUserId = Guid.NewGuid();
|
||||
FeedbackReport report = new() { ReporterUserId = reporterUserId };
|
||||
|
||||
bool ownerAllowed = FeedbackAccessRules.CanReporterComment(report, reporterUserId);
|
||||
bool otherUserAllowed = FeedbackAccessRules.CanReporterComment(report, Guid.NewGuid());
|
||||
|
||||
Assert.True(ownerAllowed);
|
||||
Assert.False(otherUserAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanDeveloperComment_requires_developer_role()
|
||||
{
|
||||
Assert.True(FeedbackAccessRules.CanDeveloperComment(isDeveloper: true));
|
||||
Assert.False(FeedbackAccessRules.CanDeveloperComment(isDeveloper: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanAccessScreenshot_allows_report_owner()
|
||||
{
|
||||
@@ -185,4 +207,68 @@ public class FeedbackRulesTests
|
||||
|
||||
Assert.Equal(["bug", "mobile"], tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Feedback_report_dto_returns_mixed_timeline_in_created_order()
|
||||
{
|
||||
Guid reportId = Guid.NewGuid();
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
FeedbackReport report = new()
|
||||
{
|
||||
Id = reportId,
|
||||
Type = FeedbackType.Bug,
|
||||
Status = FeedbackStatus.New,
|
||||
Description = "Broken layout",
|
||||
ReporterUserId = Guid.NewGuid(),
|
||||
ReporterDisplayName = "Reporter",
|
||||
ReporterEmail = "reporter@example.com",
|
||||
SubmittedPath = "/app/example",
|
||||
CreatedAt = now,
|
||||
LastActivityAt = now.AddMinutes(2),
|
||||
};
|
||||
report.ActivityEntries.Add(new FeedbackActivityEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FeedbackReportId = reportId,
|
||||
ActorUserId = Guid.NewGuid(),
|
||||
ActorDisplayName = "Developer",
|
||||
ActorEmail = "dev@example.com",
|
||||
ActivityType = FeedbackActivityTypes.StatusChanged,
|
||||
FromValue = "New",
|
||||
ToValue = "Planned",
|
||||
CreatedAt = now.AddMinutes(2),
|
||||
});
|
||||
report.Comments.Add(new FeedbackComment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FeedbackReportId = reportId,
|
||||
AuthorUserId = report.ReporterUserId,
|
||||
AuthorDisplayName = "Reporter",
|
||||
AuthorEmail = "reporter@example.com",
|
||||
AuthorRole = "Reporter",
|
||||
Body = "More context",
|
||||
CreatedAt = now.AddMinutes(1),
|
||||
});
|
||||
|
||||
FeedbackReportDto dto = report.ToDto();
|
||||
|
||||
Assert.Equal(["Comment", "Activity"], dto.Timeline.Select(item => item.Kind).ToArray());
|
||||
Assert.Equal("More context", dto.Timeline.First().Body);
|
||||
Assert.Equal(FeedbackActivityTypes.StatusChanged, dto.Timeline.Last().ActivityType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Feedback_notification_metadata_includes_target_route()
|
||||
{
|
||||
Guid reportId = Guid.NewGuid();
|
||||
|
||||
string developerMetadata = FeedbackNotificationRoutes.BuildMetadataJson(reportId, developerRoute: true);
|
||||
string reporterMetadata = FeedbackNotificationRoutes.BuildMetadataJson(reportId, developerRoute: false);
|
||||
|
||||
using JsonDocument developerDocument = JsonDocument.Parse(developerMetadata);
|
||||
using JsonDocument reporterDocument = JsonDocument.Parse(reporterMetadata);
|
||||
Assert.Equal($"/app/feedback/{reportId}", developerDocument.RootElement.GetProperty("route").GetString());
|
||||
Assert.Equal($"/app/my-feedback/{reportId}", reporterDocument.RootElement.GetProperty("route").GetString());
|
||||
Assert.True(developerDocument.RootElement.GetProperty("isFeedbackNotification").GetBoolean());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user