feat: add feedback comments activity notifications
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user