feat: add feedback comments activity notifications

This commit is contained in:
2026-04-30 13:24:23 -04:00
parent 4873f39192
commit 1263e28c00
26 changed files with 2255 additions and 18 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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