feat: add feedback backend foundation
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Contracts;
|
||||
|
||||
public record FeedbackContextDto(
|
||||
Guid? WorkspaceId,
|
||||
string? WorkspaceName,
|
||||
Guid? ClientId,
|
||||
string? ClientName,
|
||||
Guid? ProjectId,
|
||||
string? ProjectName,
|
||||
Guid? ContentItemId,
|
||||
string? ContentItemTitle);
|
||||
|
||||
public record FeedbackMetadataDto(
|
||||
string SubmittedPath,
|
||||
string? BrowserUserAgent,
|
||||
int? ViewportWidth,
|
||||
int? ViewportHeight,
|
||||
string? AppVersion);
|
||||
|
||||
public record FeedbackReportDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
string Status,
|
||||
string Description,
|
||||
Guid ReporterUserId,
|
||||
string ReporterDisplayName,
|
||||
string ReporterEmail,
|
||||
FeedbackMetadataDto Metadata,
|
||||
FeedbackContextDto Context,
|
||||
IReadOnlyCollection<string> Tags,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset LastActivityAt,
|
||||
DateTimeOffset? CancelledAt,
|
||||
string? CancellationReason);
|
||||
|
||||
public static class FeedbackDtoMapper
|
||||
{
|
||||
public static FeedbackReportDto ToDto(this FeedbackReport report)
|
||||
{
|
||||
return new FeedbackReportDto(
|
||||
report.Id,
|
||||
ToDisplayString(report.Type),
|
||||
ToDisplayString(report.Status),
|
||||
report.Description,
|
||||
report.ReporterUserId,
|
||||
report.ReporterDisplayName,
|
||||
report.ReporterEmail,
|
||||
new FeedbackMetadataDto(
|
||||
report.SubmittedPath,
|
||||
report.BrowserUserAgent,
|
||||
report.ViewportWidth,
|
||||
report.ViewportHeight,
|
||||
report.AppVersion),
|
||||
new FeedbackContextDto(
|
||||
report.WorkspaceId,
|
||||
report.WorkspaceName,
|
||||
report.ClientId,
|
||||
report.ClientName,
|
||||
report.ProjectId,
|
||||
report.ProjectName,
|
||||
report.ContentItemId,
|
||||
report.ContentItemTitle),
|
||||
report.Tags.OrderBy(tag => tag.Name).Select(tag => tag.Name).ToArray(),
|
||||
report.CreatedAt,
|
||||
report.LastActivityAt,
|
||||
report.CancelledAt,
|
||||
report.CancellationReason);
|
||||
}
|
||||
|
||||
private static string ToDisplayString(FeedbackType type)
|
||||
{
|
||||
return type.ToString();
|
||||
}
|
||||
|
||||
private static string ToDisplayString(FeedbackStatus status)
|
||||
{
|
||||
return status == FeedbackStatus.WontDo ? "Won't Do" : status.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
public static class FeedbackModelConfiguration
|
||||
{
|
||||
public static ModelBuilder ConfigureFeedbackModule(this ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<FeedbackReport>(feedback =>
|
||||
{
|
||||
feedback.ToTable("FeedbackReports");
|
||||
feedback.HasKey(x => x.Id);
|
||||
feedback.Property(x => x.Type).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||
feedback.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||
feedback.Property(x => x.Description).HasMaxLength(8000).IsRequired();
|
||||
feedback.Property(x => x.ReporterDisplayName).HasMaxLength(256).IsRequired();
|
||||
feedback.Property(x => x.ReporterEmail).HasMaxLength(256).IsRequired();
|
||||
feedback.Property(x => x.SubmittedPath).HasMaxLength(2048).IsRequired();
|
||||
feedback.Property(x => x.BrowserUserAgent).HasMaxLength(1024);
|
||||
feedback.Property(x => x.AppVersion).HasMaxLength(128);
|
||||
feedback.Property(x => x.WorkspaceName).HasMaxLength(256);
|
||||
feedback.Property(x => x.ClientName).HasMaxLength(256);
|
||||
feedback.Property(x => x.ProjectName).HasMaxLength(256);
|
||||
feedback.Property(x => x.ContentItemTitle).HasMaxLength(256);
|
||||
feedback.Property(x => x.CancellationReason).HasMaxLength(2000);
|
||||
feedback.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
feedback.HasIndex(x => x.ReporterUserId);
|
||||
feedback.HasIndex(x => x.Status);
|
||||
feedback.HasIndex(x => x.Type);
|
||||
feedback.HasIndex(x => x.WorkspaceId);
|
||||
feedback.HasIndex(x => x.LastActivityAt);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<FeedbackTag>(tag =>
|
||||
{
|
||||
tag.ToTable("FeedbackTags");
|
||||
tag.HasKey(x => x.Id);
|
||||
tag.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
||||
tag.Property(x => x.NormalizedName).HasMaxLength(64).IsRequired();
|
||||
tag.HasIndex(x => x.NormalizedName);
|
||||
tag.HasIndex(x => new { x.FeedbackReportId, x.NormalizedName }).IsUnique();
|
||||
tag.HasOne(x => x.FeedbackReport)
|
||||
.WithMany(x => x.Tags)
|
||||
.HasForeignKey(x => x.FeedbackReportId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
return modelBuilder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
public class FeedbackReport
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public FeedbackType Type { get; set; }
|
||||
public FeedbackStatus Status { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public Guid ReporterUserId { get; set; }
|
||||
public string ReporterDisplayName { get; set; } = string.Empty;
|
||||
public string ReporterEmail { get; set; } = string.Empty;
|
||||
public string SubmittedPath { get; set; } = string.Empty;
|
||||
public string? BrowserUserAgent { get; set; }
|
||||
public int? ViewportWidth { get; set; }
|
||||
public int? ViewportHeight { get; set; }
|
||||
public string? AppVersion { get; set; }
|
||||
public Guid? WorkspaceId { get; set; }
|
||||
public string? WorkspaceName { get; set; }
|
||||
public Guid? ClientId { get; set; }
|
||||
public string? ClientName { get; set; }
|
||||
public Guid? ProjectId { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public Guid? ContentItemId { get; set; }
|
||||
public string? ContentItemTitle { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset LastActivityAt { get; set; }
|
||||
public DateTimeOffset? CancelledAt { get; set; }
|
||||
public Guid? CancelledByUserId { get; set; }
|
||||
public string? CancellationReason { get; set; }
|
||||
public ICollection<FeedbackTag> Tags { get; } = new List<FeedbackTag>();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
public enum FeedbackStatus
|
||||
{
|
||||
New,
|
||||
Planned,
|
||||
Resolved,
|
||||
WontDo,
|
||||
Cancelled,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
public class FeedbackTag
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid FeedbackReportId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string NormalizedName { get; set; } = string.Empty;
|
||||
public FeedbackReport? FeedbackReport { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
public enum FeedbackType
|
||||
{
|
||||
Bug,
|
||||
Suggestion,
|
||||
Request,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Socialize.Api.Modules.Feedback;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddFeedbackModule(this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Feedback.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public record CancelMyFeedbackRequest(string? Reason);
|
||||
|
||||
public class CancelMyFeedbackRequestValidator
|
||||
: Validator<CancelMyFeedbackRequest>
|
||||
{
|
||||
public CancelMyFeedbackRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Reason).MaximumLength(2000);
|
||||
}
|
||||
}
|
||||
|
||||
public class CancelMyFeedbackHandler(AppDbContext dbContext)
|
||||
: Endpoint<CancelMyFeedbackRequest, FeedbackReportDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/my-feedback/{id}/cancel");
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancelMyFeedbackRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
Guid reporterUserId = User.GetUserId();
|
||||
|
||||
FeedbackReport? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Tags)
|
||||
.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId,
|
||||
ct);
|
||||
|
||||
if (report is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!FeedbackAccessRules.CanReporterCancel(report, reporterUserId))
|
||||
{
|
||||
AddError("The feedback report cannot be cancelled.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
report.Status = FeedbackStatus.Cancelled;
|
||||
report.CancelledAt = now;
|
||||
report.CancelledByUserId = reporterUserId;
|
||||
report.CancellationReason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
|
||||
report.LastActivityAt = now;
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
await SendOkAsync(report.ToDto(), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public class GetDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<FeedbackReportDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/feedback/{id}");
|
||||
Roles(KnownRoles.Developer);
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
FeedbackReportDto? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Tags)
|
||||
.Where(candidate => candidate.Id == id)
|
||||
.Select(candidate => candidate.ToDto())
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
if (report is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(report, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public class GetMyFeedbackHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<FeedbackReportDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/my-feedback/{id}");
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
Guid reporterUserId = User.GetUserId();
|
||||
|
||||
FeedbackReportDto? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Tags)
|
||||
.Where(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId)
|
||||
.Select(candidate => candidate.ToDto())
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
if (report is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(report, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public class ListDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<IReadOnlyCollection<FeedbackReportDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/feedback");
|
||||
Roles(KnownRoles.Developer);
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
|
||||
.Include(report => report.Tags)
|
||||
.OrderByDescending(report => report.LastActivityAt)
|
||||
.Select(report => report.ToDto())
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(reports, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public class ListFeedbackTagsHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<IReadOnlyCollection<string>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/feedback/tags");
|
||||
Roles(KnownRoles.Developer);
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
List<string> tags = await dbContext.FeedbackTags
|
||||
.GroupBy(tag => new { tag.NormalizedName, tag.Name })
|
||||
.OrderBy(group => group.Key.Name)
|
||||
.Select(group => group.Key.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(tags, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public class ListMyFeedbackHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<IReadOnlyCollection<FeedbackReportDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/my-feedback");
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid reporterUserId = User.GetUserId();
|
||||
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
|
||||
.Include(report => report.Tags)
|
||||
.Where(report => report.ReporterUserId == reporterUserId)
|
||||
.OrderByDescending(report => report.LastActivityAt)
|
||||
.Select(report => report.ToDto())
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(reports, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using FastEndpoints;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Feedback.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public record SubmitFeedbackRequest(
|
||||
string Type,
|
||||
string Description,
|
||||
string SubmittedPath,
|
||||
string? BrowserUserAgent,
|
||||
int? ViewportWidth,
|
||||
int? ViewportHeight,
|
||||
string? AppVersion,
|
||||
Guid? WorkspaceId,
|
||||
string? WorkspaceName,
|
||||
Guid? ClientId,
|
||||
string? ClientName,
|
||||
Guid? ProjectId,
|
||||
string? ProjectName,
|
||||
Guid? ContentItemId,
|
||||
string? ContentItemTitle);
|
||||
|
||||
public class SubmitFeedbackRequestValidator
|
||||
: Validator<SubmitFeedbackRequest>
|
||||
{
|
||||
public SubmitFeedbackRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Type).NotEmpty().MaximumLength(32);
|
||||
RuleFor(x => x.Description).NotEmpty().MaximumLength(8000);
|
||||
RuleFor(x => x.SubmittedPath).NotEmpty().MaximumLength(2048);
|
||||
RuleFor(x => x.BrowserUserAgent).MaximumLength(1024);
|
||||
RuleFor(x => x.AppVersion).MaximumLength(128);
|
||||
RuleFor(x => x.WorkspaceName).MaximumLength(256);
|
||||
RuleFor(x => x.ClientName).MaximumLength(256);
|
||||
RuleFor(x => x.ProjectName).MaximumLength(256);
|
||||
RuleFor(x => x.ContentItemTitle).MaximumLength(256);
|
||||
RuleFor(x => x.ViewportWidth).GreaterThan(0).When(x => x.ViewportWidth.HasValue);
|
||||
RuleFor(x => x.ViewportHeight).GreaterThan(0).When(x => x.ViewportHeight.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
public class SubmitFeedbackHandler(AppDbContext dbContext)
|
||||
: Endpoint<SubmitFeedbackRequest, FeedbackReportDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/feedback");
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SubmitFeedbackRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!FeedbackRules.TryParseType(request.Type, out FeedbackType type))
|
||||
{
|
||||
AddError(request => request.Type, "The selected feedback type is not valid.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
FeedbackReport report = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = type,
|
||||
Status = FeedbackStatus.New,
|
||||
Description = request.Description.Trim(),
|
||||
ReporterUserId = User.GetUserId(),
|
||||
ReporterDisplayName = User.GetAlias() ?? User.GetName(),
|
||||
ReporterEmail = User.GetEmail(),
|
||||
SubmittedPath = request.SubmittedPath.Trim(),
|
||||
BrowserUserAgent = NormalizeOptional(request.BrowserUserAgent),
|
||||
ViewportWidth = request.ViewportWidth,
|
||||
ViewportHeight = request.ViewportHeight,
|
||||
AppVersion = NormalizeOptional(request.AppVersion),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
WorkspaceName = NormalizeOptional(request.WorkspaceName),
|
||||
ClientId = request.ClientId,
|
||||
ClientName = NormalizeOptional(request.ClientName),
|
||||
ProjectId = request.ProjectId,
|
||||
ProjectName = NormalizeOptional(request.ProjectName),
|
||||
ContentItemId = request.ContentItemId,
|
||||
ContentItemTitle = NormalizeOptional(request.ContentItemTitle),
|
||||
CreatedAt = now,
|
||||
LastActivityAt = now,
|
||||
};
|
||||
|
||||
dbContext.FeedbackReports.Add(report);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendAsync(report.ToDto(), StatusCodes.Status201Created, ct);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
string? normalized = value?.Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Feedback.Services;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public record UpdateDeveloperFeedbackRequest(
|
||||
string? Type,
|
||||
string? Status,
|
||||
IReadOnlyCollection<string>? Tags);
|
||||
|
||||
public class UpdateDeveloperFeedbackRequestValidator
|
||||
: Validator<UpdateDeveloperFeedbackRequest>
|
||||
{
|
||||
public UpdateDeveloperFeedbackRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Type).MaximumLength(32);
|
||||
RuleFor(x => x.Status).MaximumLength(32);
|
||||
RuleForEach(x => x.Tags).MaximumLength(64);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
: Endpoint<UpdateDeveloperFeedbackRequest, FeedbackReportDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Patch("/api/feedback/{id}");
|
||||
Roles(KnownRoles.Developer);
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateDeveloperFeedbackRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
FeedbackReport? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Tags)
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
|
||||
if (report is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
if (!string.IsNullOrWhiteSpace(request.Type))
|
||||
{
|
||||
if (!FeedbackRules.TryParseType(request.Type, out FeedbackType nextType))
|
||||
{
|
||||
AddError(request => request.Type, "The selected feedback type is not valid.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (report.Type != nextType)
|
||||
{
|
||||
report.Type = nextType;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||
{
|
||||
if (!FeedbackRules.TryParseStatus(request.Status, out FeedbackStatus nextStatus))
|
||||
{
|
||||
AddError(request => request.Status, "The selected feedback status is not valid.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!FeedbackRules.CanDeveloperSetStatus(report.Status, nextStatus))
|
||||
{
|
||||
AddError(request => request.Status, "The requested status transition is not allowed.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (report.Status != nextStatus)
|
||||
{
|
||||
report.Status = nextStatus;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Tags is not null)
|
||||
{
|
||||
IReadOnlyCollection<string> normalizedTags = FeedbackRules.NormalizeTags(request.Tags);
|
||||
ApplyTags(report, normalizedTags);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
report.LastActivityAt = DateTimeOffset.UtcNow;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
await SendOkAsync(report.ToDto(), ct);
|
||||
}
|
||||
|
||||
private static void ApplyTags(FeedbackReport report, IReadOnlyCollection<string> tags)
|
||||
{
|
||||
HashSet<string> requestedKeys = tags
|
||||
.Select(FeedbackRules.NormalizeTagKey)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (FeedbackTag existingTag in report.Tags.ToArray())
|
||||
{
|
||||
if (!requestedKeys.Contains(existingTag.NormalizedName))
|
||||
{
|
||||
report.Tags.Remove(existingTag);
|
||||
}
|
||||
}
|
||||
|
||||
HashSet<string> existingKeys = report.Tags
|
||||
.Select(tag => tag.NormalizedName)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (string tag in tags)
|
||||
{
|
||||
string key = FeedbackRules.NormalizeTagKey(tag);
|
||||
if (existingKeys.Contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
report.Tags.Add(new FeedbackTag
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FeedbackReportId = report.Id,
|
||||
Name = tag,
|
||||
NormalizedName = key,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Services;
|
||||
|
||||
public static class FeedbackAccessRules
|
||||
{
|
||||
public static bool CanReporterAccess(FeedbackReport report, Guid userId)
|
||||
{
|
||||
return report.ReporterUserId == userId;
|
||||
}
|
||||
|
||||
public static bool CanReporterCancel(FeedbackReport report, Guid userId)
|
||||
{
|
||||
return CanReporterAccess(report, userId) && FeedbackRules.CanReporterCancel(report.Status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Services;
|
||||
|
||||
public static class FeedbackRules
|
||||
{
|
||||
public static bool TryParseType(string? value, out FeedbackType type)
|
||||
{
|
||||
return Enum.TryParse(value?.Trim(), ignoreCase: true, out type)
|
||||
&& Enum.IsDefined(type);
|
||||
}
|
||||
|
||||
public static bool TryParseStatus(string? value, out FeedbackStatus status)
|
||||
{
|
||||
string? normalized = value?.Trim().Replace("'", string.Empty, StringComparison.Ordinal);
|
||||
if (string.Equals(normalized, "Wont Do", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(normalized, "WontDo", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
status = FeedbackStatus.WontDo;
|
||||
return true;
|
||||
}
|
||||
|
||||
return Enum.TryParse(normalized, ignoreCase: true, out status)
|
||||
&& Enum.IsDefined(status);
|
||||
}
|
||||
|
||||
public static bool IsFinal(FeedbackStatus status)
|
||||
{
|
||||
return status is FeedbackStatus.Cancelled;
|
||||
}
|
||||
|
||||
public static bool CanDeveloperSetStatus(FeedbackStatus currentStatus, FeedbackStatus nextStatus)
|
||||
{
|
||||
return !IsFinal(currentStatus) &&
|
||||
nextStatus is FeedbackStatus.New or FeedbackStatus.Planned or FeedbackStatus.Resolved or FeedbackStatus.WontDo;
|
||||
}
|
||||
|
||||
public static bool CanReporterCancel(FeedbackStatus currentStatus)
|
||||
{
|
||||
return !IsFinal(currentStatus);
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<string> NormalizeTags(IEnumerable<string>? tags)
|
||||
{
|
||||
if (tags is null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return tags
|
||||
.Select(tag => tag.Trim())
|
||||
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(tag => tag.Length > 64 ? tag[..64] : tag)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static string NormalizeTagKey(string tag)
|
||||
{
|
||||
return tag.Trim().ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,5 @@ public static class KnownRoles
|
||||
public const string Client = nameof(Client);
|
||||
public const string Provider = nameof(Provider);
|
||||
public const string WorkspaceMember = nameof(WorkspaceMember);
|
||||
public const string Developer = nameof(Developer);
|
||||
}
|
||||
|
||||
@@ -97,5 +97,11 @@ public static class DependencyInjection
|
||||
{
|
||||
await roleManager.CreateAsync(workspaceMemberRole);
|
||||
}
|
||||
|
||||
Role developerRole = new(KnownRoles.Developer);
|
||||
if (roleManager.Roles.All(r => r.Name != developerRole.Name))
|
||||
{
|
||||
await roleManager.CreateAsync(developerRole);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user