7 Commits

Author SHA1 Message Date
07458c1541 chore: remove unused bootstop-vdp-agentic.sh script
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-04-30 14:25:17 -04:00
a9bfdc460d chore: update docs to include feedback module 2026-04-30 14:22:09 -04:00
258554f9d4 chore: add a dev user 2026-04-30 14:09:52 -04:00
6731fb5d3a feat: add feedback review notification UI 2026-04-30 13:53:00 -04:00
5aaddbca40 feat: add feedback submission flow 2026-04-30 13:33:10 -04:00
1263e28c00 feat: add feedback comments activity notifications 2026-04-30 13:24:23 -04:00
4873f39192 feat: protect feedback screenshots 2026-04-30 13:15:19 -04:00
62 changed files with 7694 additions and 1461 deletions

View File

@@ -78,6 +78,7 @@ Update OpenAPI:
- `Comments`: discussion threads on reviewable work.
- `Approvals`: review decisions and workflow state transitions.
- `Notifications`: activity feed and unread workflow notifications.
- `Feedback`: product feedback reports, screenshots, comments, activity, and developer review workflows.
## Task Discipline

View File

@@ -31,6 +31,9 @@ public class AppDbContext(
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
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)
{

View File

@@ -6,4 +6,5 @@ internal static class ContainerNames
public const string Clients = "clients";
public const string Workspaces = "workspaces";
public const string Creators = "creators";
public const string Feedback = "feedback";
}

View File

@@ -5,4 +5,5 @@ public static class SubDirectoryNames
public const string Profile = "profile";
public const string Contents = "contents";
public const string Albums = "albums";
public const string FeedbackScreenshots = "screenshots";
}

View File

@@ -51,8 +51,6 @@ public static class DevelopmentSeedExtensions
UserManager userManager = scope.ServiceProvider.GetRequiredService<UserManager>();
AppDbContext dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await RemoveLegacyDevUserAsync(userManager);
User manager = await EnsureUserAsync(
userManager,
id: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
@@ -104,6 +102,21 @@ public static class DevelopmentSeedExtensions
new Claim(KnownClaims.ProjectScope, ScopedProjectId.ToString()),
]);
User dev = await EnsureUserAsync(
userManager,
id: Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"),
username: "dev",
email: "dev@socialize.local",
password: "dev",
alias: "Socialize Dev",
firstname: "Jo",
lastname: "Bumble",
portraitUrl: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80",
roles: [KnownRoles.Developer, KnownRoles.Administrator, KnownRoles.Manager, KnownRoles.WorkspaceMember],
claims:
[
]);
await EnsureWorkspaceDataAsync(
manager.Id,
clientUser.Id,
@@ -114,19 +127,6 @@ public static class DevelopmentSeedExtensions
return app;
}
private static async Task RemoveLegacyDevUserAsync(UserManager userManager)
{
User? legacyUser = await userManager.FindByNameAsync("dev")
?? await userManager.FindByEmailAsync("dev@socialize.local");
if (legacyUser is null)
{
return;
}
await userManager.DeleteAsync(legacyUser);
}
private static async Task<User> EnsureUserAsync(
UserManager userManager,
Guid id,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddFeedbackScreenshots : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FeedbackScreenshots",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
FileName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ContentType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
BlobContainerName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
BlobName = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackScreenshots", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackScreenshots_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_FeedbackScreenshots_FeedbackReportId",
table: "FeedbackScreenshots",
column: "FeedbackReportId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FeedbackScreenshots");
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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")
@@ -672,6 +775,51 @@ namespace Socialize.Api.Migrations
b.ToTable("FeedbackReports", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BlobContainerName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("FeedbackReportId")
.HasColumnType("uuid");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("FeedbackReportId")
.IsUnique();
b.ToTable("FeedbackScreenshots", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b =>
{
b.Property<Guid>("Id")
@@ -1081,6 +1229,39 @@ 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")
.WithOne("Screenshot")
.HasForeignKey("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", "FeedbackReportId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("FeedbackReport");
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b =>
{
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
@@ -1094,6 +1275,12 @@ 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");
});
#pragma warning restore 612, 618

View File

@@ -19,6 +19,14 @@ public record FeedbackMetadataDto(
int? ViewportHeight,
string? AppVersion);
public record FeedbackScreenshotDto(
Guid Id,
string FileName,
string ContentType,
long SizeBytes,
string DownloadPath,
DateTimeOffset CreatedAt);
public record FeedbackReportDto(
Guid Id,
string Type,
@@ -29,12 +37,28 @@ public record FeedbackReportDto(
string ReporterEmail,
FeedbackMetadataDto Metadata,
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)
@@ -62,7 +86,22 @@ public static class FeedbackDtoMapper
report.ProjectName,
report.ContentItemId,
report.ContentItemTitle),
report.Screenshot is null
? null
: new FeedbackScreenshotDto(
report.Screenshot.Id,
report.Screenshot.FileName,
report.Screenshot.ContentType,
report.Screenshot.SizeBytes,
$"/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,
@@ -78,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);
}
}

View File

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

View File

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

View File

@@ -45,6 +45,60 @@ public static class FeedbackModelConfiguration
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<FeedbackScreenshot>(screenshot =>
{
screenshot.ToTable("FeedbackScreenshots");
screenshot.HasKey(x => x.Id);
screenshot.Property(x => x.FileName).HasMaxLength(256).IsRequired();
screenshot.Property(x => x.ContentType).HasMaxLength(128).IsRequired();
screenshot.Property(x => x.BlobContainerName).HasMaxLength(128).IsRequired();
screenshot.Property(x => x.BlobName).HasMaxLength(512).IsRequired();
screenshot.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
screenshot.HasIndex(x => x.FeedbackReportId).IsUnique();
screenshot.HasOne(x => x.FeedbackReport)
.WithOne(x => x.Screenshot)
.HasForeignKey<FeedbackScreenshot>(x => x.FeedbackReportId)
.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;
}
}

View File

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

View File

@@ -0,0 +1,14 @@
namespace Socialize.Api.Modules.Feedback.Data;
public class FeedbackScreenshot
{
public Guid Id { get; set; }
public Guid FeedbackReportId { get; set; }
public string FileName { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public string BlobContainerName { get; set; } = string.Empty;
public string BlobName { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public FeedbackReport? FeedbackReport { get; set; }
}

View File

@@ -4,6 +4,8 @@ public static class DependencyInjection
{
public static WebApplicationBuilder AddFeedbackModule(this WebApplicationBuilder builder)
{
builder.Services.AddScoped<Services.FeedbackNotificationService>();
return builder;
}
}

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

@@ -0,0 +1,124 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
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 AttachMyFeedbackScreenshotRequest(IFormFile File);
public class AttachMyFeedbackScreenshotRequestValidator
: Validator<AttachMyFeedbackScreenshotRequest>
{
public AttachMyFeedbackScreenshotRequestValidator()
{
RuleFor(x => x.File).NotNull().NotEmpty();
}
}
public class AttachMyFeedbackScreenshotHandler(
AppDbContext dbContext,
IBlobStorage blobStorage)
: Endpoint<AttachMyFeedbackScreenshotRequest, FeedbackReportDto>
{
public override void Configure()
{
Post("/api/my-feedback/{id}/screenshot");
Options(o => o.WithTags("Feedback"));
AllowFileUploads();
}
public override async Task HandleAsync(AttachMyFeedbackScreenshotRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
Guid reporterUserId = User.GetUserId();
FeedbackReport? report = await dbContext.FeedbackReports
.Include(candidate => candidate.Tags)
.Include(candidate => candidate.Screenshot)
.SingleOrDefaultAsync(
candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId,
ct);
if (report is null)
{
await SendNotFoundAsync(ct);
return;
}
if (report.Screenshot is not null)
{
AddError("A screenshot is already attached to this feedback report.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
if (!FeedbackScreenshotRules.IsAllowedSize(request.File.Length))
{
AddError(
request => request.File,
$"The screenshot must be greater than 0 bytes and no larger than {FeedbackScreenshotRules.MaxScreenshotBytes} bytes.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!FeedbackScreenshotRules.IsAllowedContentType(request.File.ContentType))
{
AddError(request => request.File, "The screenshot must be a PNG or JPEG image.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
Guid screenshotId = Guid.NewGuid();
string extension = FeedbackScreenshotRules.GetFileExtension(request.File.ContentType);
string blobName = $"{SubDirectoryNames.FeedbackScreenshots}/{report.Id}/{screenshotId}{extension}";
try
{
await blobStorage.UploadFileAsync(
ContainerNames.Feedback,
blobName,
request.File.OpenReadStream(),
request.File.ContentType,
ct);
}
catch (InvalidOperationException)
{
AddError(request => request.File, "The screenshot file is invalid or unsupported.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
DateTimeOffset now = DateTimeOffset.UtcNow;
report.Screenshot = new FeedbackScreenshot
{
Id = screenshotId,
FeedbackReportId = report.Id,
FileName = NormalizeFileName(request.File.FileName, extension),
ContentType = request.File.ContentType,
SizeBytes = request.File.Length,
BlobContainerName = ContainerNames.Feedback,
BlobName = blobName,
CreatedAt = now,
};
report.LastActivityAt = now;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(report.ToDto(), ct);
}
private static string NormalizeFileName(string? fileName, string extension)
{
string normalized = Path.GetFileName(fileName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return $"feedback-screenshot{extension}";
}
return normalized.Length > 256 ? normalized[..256] : normalized;
}
}

View File

@@ -35,6 +35,7 @@ public class CancelMyFeedbackHandler(AppDbContext dbContext)
FeedbackReport? report = await dbContext.FeedbackReports
.Include(candidate => candidate.Tags)
.Include(candidate => candidate.Screenshot)
.SingleOrDefaultAsync(
candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId,
ct);
@@ -53,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,11 +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)
.Where(candidate => candidate.Id == id)
.Select(candidate => candidate.ToDto())
.SingleOrDefaultAsync(ct);
.Include(candidate => candidate.Screenshot)
.Include(candidate => candidate.Comments)
.Include(candidate => candidate.ActivityEntries)
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (report is null)
{
@@ -31,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

@@ -0,0 +1,65 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
using Socialize.Api.Infrastructure.Security;
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 GetFeedbackScreenshotHandler(
AppDbContext dbContext,
IBlobStorage blobStorage)
: EndpointWithoutRequest<Stream>
{
public override void Configure()
{
Get("/api/feedback/{id}/screenshot");
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
Guid userId = User.GetUserId();
FeedbackReport? report = await dbContext.FeedbackReports
.Include(candidate => candidate.Screenshot)
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (report is null || report.Screenshot is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!FeedbackAccessRules.CanAccessScreenshot(report, userId, User.IsInRole(KnownRoles.Developer)))
{
await SendForbiddenAsync(ct);
return;
}
MemoryStream stream;
try
{
stream = await blobStorage.DownloadFileAsync(
report.Screenshot.BlobContainerName,
report.Screenshot.BlobName,
ct);
}
catch (FileNotFoundException)
{
await SendNotFoundAsync(ct);
return;
}
await SendStreamAsync(
stream,
fileName: report.Screenshot.FileName,
fileLengthBytes: report.Screenshot.SizeBytes,
contentType: report.Screenshot.ContentType,
cancellation: 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,11 +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)
.Where(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId)
.Select(candidate => candidate.ToDto())
.SingleOrDefaultAsync(ct);
.Include(candidate => candidate.Screenshot)
.Include(candidate => candidate.Comments)
.Include(candidate => candidate.ActivityEntries)
.SingleOrDefaultAsync(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId, ct);
if (report is null)
{
@@ -32,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

@@ -20,6 +20,7 @@ public class ListDeveloperFeedbackHandler(AppDbContext dbContext)
{
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
.Include(report => report.Tags)
.Include(report => report.Screenshot)
.OrderByDescending(report => report.LastActivityAt)
.Select(report => report.ToDto())
.ToListAsync(ct);

View File

@@ -20,6 +20,7 @@ public class ListMyFeedbackHandler(AppDbContext dbContext)
Guid reporterUserId = User.GetUserId();
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
.Include(report => report.Tags)
.Include(report => report.Screenshot)
.Where(report => report.ReporterUserId == reporterUserId)
.OrderByDescending(report => report.LastActivityAt)
.Select(report => report.ToDto())

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()
@@ -39,6 +42,7 @@ public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext)
Guid id = Route<Guid>("id");
FeedbackReport? report = await dbContext.FeedbackReports
.Include(candidate => candidate.Tags)
.Include(candidate => candidate.Screenshot)
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (report is null)
@@ -48,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))
@@ -59,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;
}
@@ -82,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;
}
}
@@ -90,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;
}
}
@@ -136,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));
}
}

View File

@@ -13,4 +13,19 @@ 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);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
namespace Socialize.Api.Modules.Feedback.Services;
public static class FeedbackScreenshotRules
{
public const long MaxScreenshotBytes = 5 * 1024 * 1024;
private static readonly HashSet<string> AllowedContentTypes =
[
"image/png",
"image/jpeg",
"image/jpg",
];
public static bool IsAllowedContentType(string? contentType)
{
return !string.IsNullOrWhiteSpace(contentType) &&
AllowedContentTypes.Contains(contentType.Trim(), StringComparer.OrdinalIgnoreCase);
}
public static bool IsAllowedSize(long sizeBytes)
{
return sizeBytes is > 0 and <= MaxScreenshotBytes;
}
public static string GetFileExtension(string contentType)
{
return contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase)
? ".png"
: ".jpg";
}
}

View File

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

View File

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

View File

@@ -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,99 @@ 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()
{
Guid reporterUserId = Guid.NewGuid();
FeedbackReport report = new() { ReporterUserId = reporterUserId };
bool allowed = FeedbackAccessRules.CanAccessScreenshot(report, reporterUserId, isDeveloper: false);
Assert.True(allowed);
}
[Fact]
public void CanAccessScreenshot_allows_developer()
{
FeedbackReport report = new() { ReporterUserId = Guid.NewGuid() };
bool allowed = FeedbackAccessRules.CanAccessScreenshot(report, Guid.NewGuid(), isDeveloper: true);
Assert.True(allowed);
}
[Fact]
public void CanAccessScreenshot_rejects_unrelated_non_developer()
{
FeedbackReport report = new() { ReporterUserId = Guid.NewGuid() };
bool allowed = FeedbackAccessRules.CanAccessScreenshot(report, Guid.NewGuid(), isDeveloper: false);
Assert.False(allowed);
}
[Theory]
[InlineData("image/png")]
[InlineData("image/jpeg")]
[InlineData("image/jpg")]
public void Screenshot_content_type_allows_png_and_jpeg(string contentType)
{
bool allowed = FeedbackScreenshotRules.IsAllowedContentType(contentType);
Assert.True(allowed);
}
[Theory]
[InlineData("text/html")]
[InlineData("application/pdf")]
[InlineData("")]
public void Screenshot_content_type_rejects_non_images(string contentType)
{
bool allowed = FeedbackScreenshotRules.IsAllowedContentType(contentType);
Assert.False(allowed);
}
[Theory]
[InlineData(1)]
[InlineData(FeedbackScreenshotRules.MaxScreenshotBytes)]
public void Screenshot_size_allows_non_empty_files_up_to_limit(long sizeBytes)
{
bool allowed = FeedbackScreenshotRules.IsAllowedSize(sizeBytes);
Assert.True(allowed);
}
[Theory]
[InlineData(0)]
[InlineData(FeedbackScreenshotRules.MaxScreenshotBytes + 1)]
public void Screenshot_size_rejects_empty_and_oversized_files(long sizeBytes)
{
bool allowed = FeedbackScreenshotRules.IsAllowedSize(sizeBytes);
Assert.False(allowed);
}
[Fact]
public void NormalizeTags_trims_deduplicates_and_orders()
{
@@ -112,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());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,7 @@ Composition registers:
- web services and auth in `DependencyInjection.cs`
- infrastructure in `Infrastructure/DependencyInjection.cs`
- domain modules for Identity, Workspaces, Clients, Projects, ContentItems, Assets, Comments, Approvals, and Notifications
- domain modules for Identity, Workspaces, Clients, Projects, ContentItems, Assets, Comments, Approvals, Notifications, and Feedback
## Data Ownership

View File

@@ -72,6 +72,7 @@ This is product-level support data for the SaaS operator. It may capture workspa
- Screenshots are uploaded through the blob storage abstraction, not embedded in feedback database rows.
- Feedback screenshots should use a dedicated storage area or prefix.
- Feedback screenshot records store blob container/path metadata and expose a protected API download path, not a public blob URL.
- Annotated captures are exported as compressed image files.
- Backend upload size and content type validation must be enforced.
- The UI must show a friendly error when an image is too large or invalid.

View File

@@ -15,6 +15,7 @@
"@vueuse/head": "^2.0.0",
"@xtiannyeto/vue-auth-social": "^0.1.9",
"axios": "^1.6.7",
"html2canvas": "^1.4.1",
"i18n": "^0.15.1",
"jwt-decode": "^4.0.0",
"pinia": "^2.1.7",
@@ -2161,6 +2162,15 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -2497,6 +2507,15 @@
"node": ">= 8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -3565,6 +3584,19 @@
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -5341,6 +5373,15 @@
"node": ">=14.0.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -5539,6 +5580,15 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",

View File

@@ -19,6 +19,7 @@
"@vueuse/head": "^2.0.0",
"@xtiannyeto/vue-auth-social": "^0.1.9",
"axios": "^1.6.7",
"html2canvas": "^1.4.1",
"i18n": "^0.15.1",
"jwt-decode": "^4.0.0",
"pinia": "^2.1.7",

View File

@@ -28,6 +28,8 @@
<router-view></router-view>
</div>
</div>
<FeedbackFloatingButton v-if="showsAppSidebar" />
</div>
</v-app>
</template>
@@ -39,6 +41,7 @@
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import AppBar from '@/layouts/main/AppBar.vue';
import AppSidebar from '@/layouts/main/AppSidebar.vue';
import FeedbackFloatingButton from '@/features/feedback/components/FeedbackFloatingButton.vue';
const route = useRoute();
const authStore = useAuthStore();

View File

@@ -436,6 +436,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/my-feedback/{id}/screenshot": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/my-feedback/{id}/cancel": {
parameters: {
query?: never;
@@ -468,6 +484,22 @@ export interface paths {
patch: operations["SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackHandler"];
trace?: never;
};
"/api/feedback/{id}/screenshot": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesFeedbackHandlersGetFeedbackScreenshotHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/my-feedback/{id}": {
parameters: {
query?: never;
@@ -998,6 +1030,7 @@ export interface components {
reporterEmail?: string;
metadata?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackMetadataDto"];
context?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackContextDto"];
screenshot?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackScreenshotDto"] | null;
tags?: string[];
/** Format: date-time */
createdAt?: string;
@@ -1030,6 +1063,21 @@ export interface components {
contentItemId?: string | null;
contentItemTitle?: string | null;
};
SocializeApiModulesFeedbackContractsFeedbackScreenshotDto: {
/** Format: guid */
id?: string;
fileName?: string;
contentType?: string;
/** Format: int64 */
sizeBytes?: number;
downloadPath?: string;
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotRequest: {
/** Format: binary */
file: string;
};
SocializeApiModulesFeedbackHandlersCancelMyFeedbackRequest: {
reason?: string | null;
};
@@ -2238,6 +2286,48 @@ export interface operations {
};
};
};
SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackReportDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesFeedbackHandlersCancelMyFeedbackHandler: {
parameters: {
query?: never;
@@ -2365,6 +2455,35 @@ export interface operations {
};
};
};
SocializeApiModulesFeedbackHandlersGetFeedbackScreenshotHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SystemIOStream"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesFeedbackHandlersGetMyFeedbackHandler: {
parameters: {
query?: never;

View File

@@ -0,0 +1,50 @@
<script setup>
import { defineAsyncComponent, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { mdiMessageAlertOutline } from '@mdi/js';
const FeedbackSubmissionDialog = defineAsyncComponent(() => import('./FeedbackSubmissionDialog.vue'));
const { t } = useI18n();
const isDialogOpen = ref(false);
</script>
<template>
<div
class="feedback-entry"
data-feedback-ui="true"
>
<button
class="feedback-entry-button"
type="button"
:title="t('feedback.open')"
@click="isDialogOpen = true"
>
<v-icon :icon="mdiMessageAlertOutline" />
<span>{{ t('feedback.button') }}</span>
</button>
<FeedbackSubmissionDialog v-model="isDialogOpen" />
</div>
</template>
<style scoped>
.feedback-entry {
@apply fixed bottom-5 right-5 z-50;
}
.feedback-entry-button {
@apply flex h-12 items-center gap-2 rounded-full border px-4 text-sm font-bold shadow-lg transition-colors;
background: #172033;
border-color: rgba(255, 255, 255, 0.4);
color: #fffaf2;
}
.feedback-entry-button:hover {
background: #0f766e;
}
.feedback-entry-button span {
@apply hidden sm:inline;
}
</style>

View File

@@ -0,0 +1,711 @@
<script setup>
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import html2canvas from 'html2canvas';
import { useToast } from 'vue-toastification';
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
import { useContentItemDetailStore } from '@/features/content/stores/contentItemDetailStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
import { useFeedbackSubmissionStore } from '@/features/feedback/stores/feedbackSubmissionStore.js';
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import {
mdiArrowTopRight,
mdiCameraOutline,
mdiClose,
mdiContentSaveOutline,
mdiCrop,
mdiEraser,
mdiFormatText,
mdiGesture,
mdiMinus,
mdiRedoVariant,
mdiShapeOvalPlus,
mdiUndoVariant,
} from '@mdi/js';
const model = defineModel({ type: Boolean, default: false });
const { t } = useI18n();
const route = useRoute();
const toast = useToast();
const clientsStore = useClientsStore();
const contentItemsStore = useContentItemsStore();
const contentItemDetailStore = useContentItemDetailStore();
const feedbackStore = useFeedbackSubmissionStore();
const projectsStore = useProjectsStore();
const workspaceStore = useWorkspaceStore();
const form = reactive({
type: null,
description: '',
});
const editorCanvas = ref(null);
const sourceImage = ref(null);
const imageElement = ref(null);
const screenshotBlob = ref(null);
const selectedTool = ref('freehand');
const captureError = ref(null);
const isCapturing = ref(false);
const isDrawing = ref(false);
const startPoint = ref(null);
const draftPoint = ref(null);
const freehandPoints = ref([]);
const annotations = ref([]);
const imageHistory = ref([]);
const feedbackTypes = computed(() => [
{ title: t('feedback.types.bug'), value: 'Bug' },
{ title: t('feedback.types.suggestion'), value: 'Suggestion' },
{ title: t('feedback.types.request'), value: 'Request' },
]);
const annotationTools = computed(() => [
{ value: 'crop', label: t('feedback.tools.crop'), icon: mdiCrop },
{ value: 'arrow', label: t('feedback.tools.arrow'), icon: mdiArrowTopRight },
{ value: 'ellipse', label: t('feedback.tools.ellipse'), icon: mdiShapeOvalPlus },
{ value: 'line', label: t('feedback.tools.line'), icon: mdiMinus },
{ value: 'freehand', label: t('feedback.tools.freehand'), icon: mdiGesture },
{ value: 'text', label: t('feedback.tools.text'), icon: mdiFormatText },
]);
const isDirty = computed(() =>
Boolean(form.type || form.description.trim() || sourceImage.value || screenshotBlob.value)
);
const canSubmit = computed(() =>
Boolean(form.type && form.description.trim()) && !feedbackStore.isSubmitting
);
const currentContentItem = computed(() => {
const routeId = route.params.id;
if (!routeId) {
return null;
}
return contentItemDetailStore.item?.id === routeId
? contentItemDetailStore.item
: contentItemsStore.items.find(item => item.id === routeId) ?? null;
});
const currentProject = computed(() => {
const projectId = route.params.projectId ?? currentContentItem.value?.projectId;
if (!projectId) {
return null;
}
return projectsStore.projects.find(project => project.id === projectId) ?? null;
});
const currentClient = computed(() => {
const clientId = route.query.clientId ?? currentProject.value?.clientId ?? currentContentItem.value?.clientId;
if (!clientId) {
return clientsStore.operationalClient ?? null;
}
return clientsStore.clients.find(client => client.id === clientId) ?? null;
});
watch(model, value => {
if (value) {
resetForm();
}
});
function resetForm() {
form.type = null;
form.description = '';
sourceImage.value = null;
imageElement.value = null;
screenshotBlob.value = null;
captureError.value = null;
selectedTool.value = 'freehand';
annotations.value = [];
imageHistory.value = [];
clearPointerState();
}
async function requestClose() {
if (isDirty.value && !window.confirm(t('feedback.discardConfirm'))) {
return;
}
model.value = false;
resetForm();
}
async function captureViewport() {
isCapturing.value = true;
captureError.value = null;
try {
await nextTick();
const target = document.querySelector('.shell-container') ?? document.body;
const canvas = await html2canvas(target, {
backgroundColor: '#fffaf2',
height: window.innerHeight,
ignoreElements: element => element.dataset?.feedbackUi === 'true',
scale: Math.min(window.devicePixelRatio || 1, 2),
scrollX: -window.scrollX,
scrollY: -window.scrollY,
useCORS: true,
width: window.innerWidth,
windowHeight: window.innerHeight,
windowWidth: window.innerWidth,
});
sourceImage.value = canvas.toDataURL('image/jpeg', 0.88);
imageHistory.value = [sourceImage.value];
annotations.value = [];
await loadImage();
await exportScreenshot();
} catch (error) {
console.error('Failed to capture feedback screenshot:', error);
captureError.value = t('feedback.captureFailed');
} finally {
isCapturing.value = false;
}
}
function loadImage() {
return new Promise(resolve => {
if (!sourceImage.value) {
resolve();
return;
}
const image = new Image();
image.onload = () => {
imageElement.value = image;
nextTick(() => {
redrawCanvas();
resolve();
});
};
image.src = sourceImage.value;
});
}
function getCanvasPoint(event) {
const canvas = editorCanvas.value;
const rect = canvas.getBoundingClientRect();
const clientX = event.touches?.[0]?.clientX ?? event.clientX;
const clientY = event.touches?.[0]?.clientY ?? event.clientY;
return {
x: ((clientX - rect.left) / rect.width) * canvas.width,
y: ((clientY - rect.top) / rect.height) * canvas.height,
};
}
function beginAnnotation(event) {
if (!imageElement.value) {
return;
}
const point = getCanvasPoint(event);
if (selectedTool.value === 'text') {
const text = window.prompt(t('feedback.textPrompt'));
if (text?.trim()) {
annotations.value.push({ tool: 'text', x: point.x, y: point.y, text: text.trim() });
redrawCanvas();
exportScreenshot();
}
return;
}
isDrawing.value = true;
startPoint.value = point;
draftPoint.value = point;
freehandPoints.value = [point];
}
function moveAnnotation(event) {
if (!isDrawing.value) {
return;
}
const point = getCanvasPoint(event);
draftPoint.value = point;
if (selectedTool.value === 'freehand') {
freehandPoints.value.push(point);
}
redrawCanvas(true);
}
async function endAnnotation() {
if (!isDrawing.value || !startPoint.value || !draftPoint.value) {
clearPointerState();
return;
}
const annotation = selectedTool.value === 'freehand'
? { tool: 'freehand', points: [...freehandPoints.value] }
: {
tool: selectedTool.value,
x1: startPoint.value.x,
y1: startPoint.value.y,
x2: draftPoint.value.x,
y2: draftPoint.value.y,
};
if (selectedTool.value === 'crop') {
await applyCrop(annotation);
} else if (hasMeaningfulSize(annotation)) {
annotations.value.push(annotation);
}
clearPointerState();
redrawCanvas();
await exportScreenshot();
}
function hasMeaningfulSize(annotation) {
if (annotation.tool === 'freehand') {
return annotation.points.length > 2;
}
return Math.abs(annotation.x2 - annotation.x1) > 6 || Math.abs(annotation.y2 - annotation.y1) > 6;
}
async function applyCrop(crop) {
const canvas = editorCanvas.value;
const x = Math.max(0, Math.min(crop.x1, crop.x2));
const y = Math.max(0, Math.min(crop.y1, crop.y2));
const width = Math.min(canvas.width - x, Math.abs(crop.x2 - crop.x1));
const height = Math.min(canvas.height - y, Math.abs(crop.y2 - crop.y1));
if (width < 20 || height < 20) {
return;
}
const cropCanvas = document.createElement('canvas');
cropCanvas.width = width;
cropCanvas.height = height;
cropCanvas.getContext('2d').drawImage(canvas, x, y, width, height, 0, 0, width, height);
sourceImage.value = cropCanvas.toDataURL('image/jpeg', 0.9);
imageHistory.value.push(sourceImage.value);
annotations.value = [];
await loadImage();
}
function clearPointerState() {
isDrawing.value = false;
startPoint.value = null;
draftPoint.value = null;
freehandPoints.value = [];
}
function redrawCanvas(includeDraft = false) {
const canvas = editorCanvas.value;
const image = imageElement.value;
if (!canvas || !image) {
return;
}
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext('2d');
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0);
annotations.value.forEach(annotation => drawAnnotation(context, annotation));
if (includeDraft && startPoint.value && draftPoint.value) {
const draft = selectedTool.value === 'freehand'
? { tool: 'freehand', points: freehandPoints.value }
: { tool: selectedTool.value, x1: startPoint.value.x, y1: startPoint.value.y, x2: draftPoint.value.x, y2: draftPoint.value.y };
drawAnnotation(context, draft, true);
}
}
function drawAnnotation(context, annotation, isDraft = false) {
context.save();
context.strokeStyle = annotation.tool === 'crop' ? '#0f766e' : '#ef4444';
context.fillStyle = '#ef4444';
context.lineWidth = Math.max(4, editorCanvas.value.width / 320);
context.lineCap = 'round';
context.lineJoin = 'round';
context.globalAlpha = isDraft ? 0.78 : 1;
if (annotation.tool === 'line' || annotation.tool === 'arrow') {
drawLine(context, annotation.x1, annotation.y1, annotation.x2, annotation.y2, annotation.tool === 'arrow');
} else if (annotation.tool === 'ellipse') {
context.beginPath();
context.ellipse(
(annotation.x1 + annotation.x2) / 2,
(annotation.y1 + annotation.y2) / 2,
Math.abs(annotation.x2 - annotation.x1) / 2,
Math.abs(annotation.y2 - annotation.y1) / 2,
0,
0,
Math.PI * 2
);
context.stroke();
} else if (annotation.tool === 'crop') {
context.setLineDash([12, 8]);
context.strokeRect(annotation.x1, annotation.y1, annotation.x2 - annotation.x1, annotation.y2 - annotation.y1);
} else if (annotation.tool === 'freehand') {
context.beginPath();
annotation.points.forEach((point, index) => {
if (index === 0) {
context.moveTo(point.x, point.y);
} else {
context.lineTo(point.x, point.y);
}
});
context.stroke();
} else if (annotation.tool === 'text') {
context.font = `${Math.max(24, editorCanvas.value.width / 32)}px sans-serif`;
context.lineWidth = 6;
context.strokeStyle = '#fffaf2';
context.strokeText(annotation.text, annotation.x, annotation.y);
context.fillText(annotation.text, annotation.x, annotation.y);
}
context.restore();
}
function drawLine(context, x1, y1, x2, y2, withArrow) {
context.beginPath();
context.moveTo(x1, y1);
context.lineTo(x2, y2);
context.stroke();
if (!withArrow) {
return;
}
const angle = Math.atan2(y2 - y1, x2 - x1);
const size = Math.max(18, editorCanvas.value.width / 48);
context.beginPath();
context.moveTo(x2, y2);
context.lineTo(x2 - size * Math.cos(angle - Math.PI / 6), y2 - size * Math.sin(angle - Math.PI / 6));
context.lineTo(x2 - size * Math.cos(angle + Math.PI / 6), y2 - size * Math.sin(angle + Math.PI / 6));
context.closePath();
context.fill();
}
async function undoAnnotation() {
if (annotations.value.length) {
annotations.value.pop();
} else if (imageHistory.value.length > 1) {
imageHistory.value.pop();
sourceImage.value = imageHistory.value[imageHistory.value.length - 1];
await loadImage();
}
redrawCanvas();
await exportScreenshot();
}
async function clearAnnotations() {
if (!sourceImage.value) {
return;
}
annotations.value = [];
if (imageHistory.value.length > 1) {
sourceImage.value = imageHistory.value[0];
imageHistory.value = [sourceImage.value];
await loadImage();
}
redrawCanvas();
await exportScreenshot();
}
function removeScreenshot() {
sourceImage.value = null;
imageElement.value = null;
screenshotBlob.value = null;
annotations.value = [];
imageHistory.value = [];
}
function exportScreenshot() {
return new Promise(resolve => {
const canvas = editorCanvas.value;
if (!canvas) {
resolve();
return;
}
canvas.toBlob(blob => {
screenshotBlob.value = blob;
resolve();
}, 'image/jpeg', 0.86);
});
}
function buildMetadata() {
return {
type: form.type,
description: form.description.trim(),
submittedPath: route.fullPath,
browserUserAgent: navigator.userAgent,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
appVersion: import.meta.env.VITE_APP_VERSION ?? null,
workspaceId: workspaceStore.activeWorkspace?.id ?? null,
workspaceName: workspaceStore.activeWorkspace?.name ?? null,
clientId: currentClient.value?.id ?? null,
clientName: currentClient.value?.name ?? null,
projectId: currentProject.value?.id ?? null,
projectName: currentProject.value?.name ?? null,
contentItemId: currentContentItem.value?.id ?? null,
contentItemTitle: currentContentItem.value?.title ?? null,
};
}
async function submit() {
if (!canSubmit.value) {
return;
}
try {
await exportScreenshot();
await feedbackStore.submitFeedback(buildMetadata(), screenshotBlob.value);
toast.success(t('feedback.submitted'));
model.value = false;
resetForm();
} catch (error) {
toast.error(t('feedback.submitFailed'));
}
}
</script>
<template>
<v-dialog
:model-value="model"
max-width="980"
persistent
data-feedback-ui="true"
>
<section class="feedback-dialog">
<header class="feedback-dialog-header">
<div>
<p>{{ t('feedback.eyebrow') }}</p>
<h2>{{ t('feedback.title') }}</h2>
</div>
<button
class="feedback-icon-button"
type="button"
:title="t('close')"
@click="requestClose"
>
<v-icon :icon="mdiClose" />
</button>
</header>
<div class="feedback-dialog-body">
<div class="feedback-form">
<v-select
v-model="form.type"
:items="feedbackTypes"
:label="t('feedback.fields.type')"
variant="outlined"
density="comfortable"
/>
<v-textarea
v-model="form.description"
:label="t('feedback.fields.description')"
:placeholder="t('feedback.fields.descriptionPlaceholder')"
variant="outlined"
rows="7"
auto-grow
counter="8000"
/>
<v-alert
v-if="captureError"
type="warning"
variant="tonal"
density="compact"
>
{{ captureError }}
</v-alert>
<div class="feedback-actions">
<v-btn
variant="tonal"
color="primary"
:loading="isCapturing"
:prepend-icon="mdiCameraOutline"
@click="captureViewport"
>
{{ t('feedback.capture') }}
</v-btn>
<v-btn
v-if="sourceImage"
variant="text"
color="error"
:prepend-icon="mdiEraser"
@click="removeScreenshot"
>
{{ t('feedback.removeCapture') }}
</v-btn>
</div>
</div>
<div
v-if="sourceImage"
class="feedback-editor"
>
<div class="feedback-toolstrip">
<button
v-for="tool in annotationTools"
:key="tool.value"
class="feedback-tool-button"
:class="{ 'feedback-tool-button-active': selectedTool === tool.value }"
type="button"
:title="tool.label"
@click="selectedTool = tool.value"
>
<v-icon :icon="tool.icon" />
</button>
<span class="feedback-tool-divider"></span>
<button
class="feedback-tool-button"
type="button"
:title="t('feedback.tools.undo')"
@click="undoAnnotation"
>
<v-icon :icon="mdiUndoVariant" />
</button>
<button
class="feedback-tool-button"
type="button"
:title="t('feedback.tools.clear')"
@click="clearAnnotations"
>
<v-icon :icon="mdiRedoVariant" />
</button>
</div>
<canvas
ref="editorCanvas"
class="feedback-canvas"
@pointerdown="beginAnnotation"
@pointermove="moveAnnotation"
@pointerup="endAnnotation"
@pointerleave="endAnnotation"
></canvas>
</div>
<div
v-else
class="feedback-empty-preview"
>
<v-icon :icon="mdiCameraOutline" />
<span>{{ t('feedback.noCapture') }}</span>
</div>
</div>
<footer class="feedback-dialog-footer">
<v-btn
variant="text"
@click="requestClose"
>
{{ t('cancel') }}
</v-btn>
<v-btn
color="primary"
:disabled="!canSubmit"
:loading="feedbackStore.isSubmitting"
:prepend-icon="mdiContentSaveOutline"
@click="submit"
>
{{ t('feedback.submit') }}
</v-btn>
</footer>
</section>
</v-dialog>
</template>
<style scoped>
.feedback-dialog {
@apply overflow-hidden rounded-lg border;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.12);
color: #172033;
}
.feedback-dialog-header,
.feedback-dialog-footer {
@apply flex items-center justify-between gap-4 px-5 py-4;
border-bottom: 1px solid rgba(23, 32, 51, 0.08);
}
.feedback-dialog-footer {
@apply justify-end;
border-bottom: 0;
border-top: 1px solid rgba(23, 32, 51, 0.08);
}
.feedback-dialog-header p {
@apply text-xs font-black uppercase;
color: #0f766e;
}
.feedback-dialog-header h2 {
@apply text-xl font-black;
}
.feedback-icon-button,
.feedback-tool-button {
@apply flex h-10 w-10 items-center justify-center rounded-full transition-colors;
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
.feedback-icon-button:hover,
.feedback-tool-button:hover,
.feedback-tool-button-active {
background: #172033;
color: #fffaf2;
}
.feedback-dialog-body {
@apply grid gap-5 p-5 lg:grid-cols-[minmax(18rem,22rem)_1fr];
}
.feedback-form,
.feedback-editor,
.feedback-empty-preview {
@apply min-w-0;
}
.feedback-actions {
@apply flex flex-wrap items-center gap-2;
}
.feedback-editor {
@apply flex flex-col gap-3;
}
.feedback-toolstrip {
@apply flex flex-wrap items-center gap-2;
}
.feedback-tool-divider {
@apply h-7 w-px;
background: rgba(23, 32, 51, 0.16);
}
.feedback-canvas {
@apply block w-full rounded-md border;
max-height: 58vh;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.12);
cursor: crosshair;
object-fit: contain;
}
.feedback-empty-preview {
@apply flex min-h-[18rem] flex-col items-center justify-center gap-3 rounded-md border border-dashed text-sm;
background: rgba(23, 32, 51, 0.03);
border-color: rgba(23, 32, 51, 0.16);
color: #526178;
}
.feedback-empty-preview i {
@apply text-4xl;
}
</style>

View File

@@ -0,0 +1,290 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { useClient } from '@/plugins/api.js';
const DEFAULT_FILTERS = Object.freeze({
type: '',
status: '',
tag: '',
reporter: '',
workspace: '',
fromDate: '',
toDate: '',
search: '',
sort: 'lastActivity',
});
export const FEEDBACK_TYPES = ['Bug', 'Suggestion', 'Request'];
export const FEEDBACK_STATUSES = ['New', 'Planned', 'Resolved', "Won't Do", 'Cancelled'];
export const FEEDBACK_DEVELOPER_STATUSES = ['New', 'Planned', 'Resolved', "Won't Do"];
export const useDeveloperFeedbackStore = defineStore('developer-feedback', () => {
const client = useClient();
const reports = ref([]);
const selectedReport = ref(null);
const screenshotPreviewUrl = ref('');
const tags = ref([]);
const filters = ref({ ...DEFAULT_FILTERS });
const isLoading = ref(false);
const isDetailLoading = ref(false);
const isSaving = ref(false);
const isCommenting = ref(false);
const error = ref(null);
const filteredReports = computed(() => {
const query = filters.value.search.trim().toLowerCase();
const reporter = filters.value.reporter.trim().toLowerCase();
const workspace = filters.value.workspace.trim().toLowerCase();
const fromDate = filters.value.fromDate ? new Date(`${filters.value.fromDate}T00:00:00`) : null;
const toDate = filters.value.toDate ? new Date(`${filters.value.toDate}T23:59:59`) : null;
const rows = reports.value.filter(report => {
if (filters.value.type && report.type !== filters.value.type) {
return false;
}
if (filters.value.status && report.status !== filters.value.status) {
return false;
}
if (filters.value.tag && !(report.tags ?? []).includes(filters.value.tag)) {
return false;
}
if (reporter) {
const reporterText = `${report.reporterDisplayName ?? ''} ${report.reporterEmail ?? ''}`.toLowerCase();
if (!reporterText.includes(reporter)) {
return false;
}
}
if (workspace) {
const workspaceText = `${report.context?.workspaceName ?? ''} ${report.context?.workspaceId ?? ''}`.toLowerCase();
if (!workspaceText.includes(workspace)) {
return false;
}
}
if (fromDate || toDate) {
const createdAt = report.createdAt ? new Date(report.createdAt) : null;
if (!createdAt || (fromDate && createdAt < fromDate) || (toDate && createdAt > toDate)) {
return false;
}
}
if (query) {
const haystack = [
report.description,
report.type,
report.status,
report.reporterDisplayName,
report.reporterEmail,
report.metadata?.submittedPath,
report.context?.workspaceName,
report.context?.clientName,
report.context?.projectName,
report.context?.contentItemTitle,
...(report.tags ?? []),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
if (!haystack.includes(query)) {
return false;
}
}
return true;
});
return rows.sort((a, b) => {
if (filters.value.sort === 'oldest') {
return compareDates(a.createdAt, b.createdAt);
}
if (filters.value.sort === 'newest') {
return compareDates(b.createdAt, a.createdAt);
}
return compareDates(b.lastActivityAt, a.lastActivityAt);
});
});
const tagOptions = computed(() => {
const fromReports = reports.value.flatMap(report => report.tags ?? []);
return [...new Set([...tags.value, ...fromReports])]
.filter(Boolean)
.sort((a, b) => a.localeCompare(b));
});
async function loadReports() {
isLoading.value = true;
error.value = null;
try {
const [reportsResponse, tagsResponse] = await Promise.all([
client.get('/api/feedback'),
client.get('/api/feedback/tags'),
]);
reports.value = reportsResponse.data ?? [];
tags.value = tagsResponse.data ?? [];
} catch (loadError) {
console.error('Failed to load developer feedback:', loadError);
error.value = 'feedback.review.errors.loadFailed';
throw loadError;
} finally {
isLoading.value = false;
}
}
async function loadReport(id) {
isDetailLoading.value = true;
error.value = null;
clearScreenshotPreview();
try {
const response = await client.get(`/api/feedback/${id}`);
selectedReport.value = response.data;
await loadTimeline(id);
await loadScreenshotPreview();
return selectedReport.value;
} catch (loadError) {
console.error('Failed to load feedback report:', loadError);
error.value = 'feedback.review.errors.detailFailed';
throw loadError;
} finally {
isDetailLoading.value = false;
}
}
async function loadTimeline(id) {
const response = await client.get(`/api/feedback/${id}/timeline`);
if (selectedReport.value?.id === id) {
selectedReport.value = {
...selectedReport.value,
timeline: response.data ?? [],
};
}
}
async function updateReport(id, payload) {
isSaving.value = true;
try {
const response = await client.patch(`/api/feedback/${id}`, payload);
selectedReport.value = {
...response.data,
timeline: selectedReport.value?.timeline ?? response.data?.timeline ?? [],
};
reports.value = reports.value.map(report =>
report.id === id ? { ...report, ...response.data } : report
);
await loadTimeline(id);
return selectedReport.value;
} finally {
isSaving.value = false;
}
}
async function addComment(id, body) {
isCommenting.value = true;
try {
await client.post(`/api/feedback/${id}/comments`, { body });
await loadReport(id);
} finally {
isCommenting.value = false;
}
}
async function loadScreenshotPreview() {
if (!selectedReport.value?.screenshot?.downloadPath) {
return;
}
const response = await client.get(selectedReport.value.screenshot.downloadPath, {
responseType: 'blob',
});
screenshotPreviewUrl.value = URL.createObjectURL(response.data);
}
async function downloadScreenshot() {
if (!selectedReport.value?.screenshot?.downloadPath) {
return;
}
const response = await client.get(selectedReport.value.screenshot.downloadPath, {
responseType: 'blob',
});
const url = URL.createObjectURL(response.data);
const link = document.createElement('a');
link.href = url;
link.download = selectedReport.value.screenshot.fileName || 'feedback-screenshot';
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
async function openScreenshot() {
if (!selectedReport.value?.screenshot?.downloadPath) {
return;
}
const response = await client.get(selectedReport.value.screenshot.downloadPath, {
responseType: 'blob',
});
const url = URL.createObjectURL(response.data);
window.open(url, '_blank', 'noopener');
window.setTimeout(() => URL.revokeObjectURL(url), 60000);
}
function resetFilters() {
filters.value = { ...DEFAULT_FILTERS };
}
function clearSelectedReport() {
selectedReport.value = null;
clearScreenshotPreview();
}
function clearScreenshotPreview() {
if (screenshotPreviewUrl.value) {
URL.revokeObjectURL(screenshotPreviewUrl.value);
screenshotPreviewUrl.value = '';
}
}
return {
reports,
selectedReport,
screenshotPreviewUrl,
tags,
filters,
filteredReports,
tagOptions,
isLoading,
isDetailLoading,
isSaving,
isCommenting,
error,
loadReports,
loadReport,
updateReport,
addComment,
downloadScreenshot,
openScreenshot,
resetFilters,
clearSelectedReport,
clearScreenshotPreview,
};
});
function compareDates(left, right) {
return new Date(left ?? 0).getTime() - new Date(right ?? 0).getTime();
}

View File

@@ -0,0 +1,45 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { useClient } from '@/plugins/api.js';
export const useFeedbackSubmissionStore = defineStore('feedback-submission', () => {
const client = useClient();
const isSubmitting = ref(false);
const error = ref(null);
async function submitFeedback(payload, screenshotBlob) {
if (isSubmitting.value) {
throw new Error('A feedback submission is already in progress.');
}
isSubmitting.value = true;
error.value = null;
try {
const response = await client.post('/api/feedback', payload);
let report = response.data;
if (screenshotBlob && report?.id) {
const formData = new FormData();
formData.append('file', screenshotBlob, 'feedback-screenshot.jpg');
const screenshotResponse = await client.post(`/api/my-feedback/${report.id}/screenshot`, formData);
report = screenshotResponse.data ?? report;
}
return report;
} catch (submitError) {
console.error('Failed to submit feedback:', submitError);
error.value = 'Failed to submit feedback.';
throw submitError;
} finally {
isSubmitting.value = false;
}
}
return {
isSubmitting,
error,
submitFeedback,
};
});

View File

@@ -0,0 +1,179 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { useClient } from '@/plugins/api.js';
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
const DEFAULT_FILTERS = Object.freeze({
type: '',
status: '',
sort: 'lastActivity',
});
export const MY_FEEDBACK_DEFAULT_STATUSES = ['New', 'Planned'];
export const useMyFeedbackStore = defineStore('my-feedback', () => {
const client = useClient();
const notificationsStore = useNotificationsStore();
const reports = ref([]);
const selectedReport = ref(null);
const screenshotPreviewUrl = ref('');
const filters = ref({ ...DEFAULT_FILTERS });
const isLoading = ref(false);
const isDetailLoading = ref(false);
const isCommenting = ref(false);
const isCancelling = ref(false);
const error = ref(null);
const unreadReportIds = computed(() => notificationsStore.unreadFeedbackReportIds);
const filteredReports = computed(() => {
const rows = reports.value.filter(report => {
if (filters.value.type && report.type !== filters.value.type) {
return false;
}
if (filters.value.status) {
return report.status === filters.value.status;
}
return MY_FEEDBACK_DEFAULT_STATUSES.includes(report.status);
});
return rows.sort((a, b) => {
if (filters.value.sort === 'newest') {
return compareDates(b.createdAt, a.createdAt);
}
return compareDates(b.lastActivityAt, a.lastActivityAt);
});
});
async function loadReports() {
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/my-feedback');
reports.value = response.data ?? [];
} catch (loadError) {
console.error('Failed to load my feedback:', loadError);
error.value = 'feedback.mine.errors.loadFailed';
throw loadError;
} finally {
isLoading.value = false;
}
}
async function loadReport(id) {
isDetailLoading.value = true;
error.value = null;
clearScreenshotPreview();
try {
const response = await client.get(`/api/my-feedback/${id}`);
selectedReport.value = response.data;
await loadTimeline(id);
await loadScreenshotPreview();
await notificationsStore.markFeedbackReportAsRead(id);
return selectedReport.value;
} catch (loadError) {
console.error('Failed to load my feedback report:', loadError);
error.value = 'feedback.mine.errors.detailFailed';
throw loadError;
} finally {
isDetailLoading.value = false;
}
}
async function loadTimeline(id) {
const response = await client.get(`/api/my-feedback/${id}/timeline`);
if (selectedReport.value?.id === id) {
selectedReport.value = {
...selectedReport.value,
timeline: response.data ?? [],
};
}
}
async function addComment(id, body) {
isCommenting.value = true;
try {
await client.post(`/api/my-feedback/${id}/comments`, { body });
await loadReport(id);
await loadReports();
} finally {
isCommenting.value = false;
}
}
async function cancelReport(id, reason) {
isCancelling.value = true;
try {
const response = await client.post(`/api/my-feedback/${id}/cancel`, { reason });
selectedReport.value = {
...response.data,
timeline: selectedReport.value?.timeline ?? response.data?.timeline ?? [],
};
reports.value = reports.value.map(report => report.id === id ? { ...report, ...response.data } : report);
await loadTimeline(id);
return selectedReport.value;
} finally {
isCancelling.value = false;
}
}
async function loadScreenshotPreview() {
if (!selectedReport.value?.screenshot?.downloadPath) {
return;
}
const response = await client.get(selectedReport.value.screenshot.downloadPath, {
responseType: 'blob',
});
screenshotPreviewUrl.value = URL.createObjectURL(response.data);
}
function resetFilters() {
filters.value = { ...DEFAULT_FILTERS };
}
function clearSelectedReport() {
selectedReport.value = null;
clearScreenshotPreview();
}
function clearScreenshotPreview() {
if (screenshotPreviewUrl.value) {
URL.revokeObjectURL(screenshotPreviewUrl.value);
screenshotPreviewUrl.value = '';
}
}
return {
reports,
selectedReport,
screenshotPreviewUrl,
filters,
unreadReportIds,
filteredReports,
isLoading,
isDetailLoading,
isCommenting,
isCancelling,
error,
loadReports,
loadReport,
addComment,
cancelReport,
resetFilters,
clearSelectedReport,
clearScreenshotPreview,
};
});
function compareDates(left, right) {
return new Date(left ?? 0).getTime() - new Date(right ?? 0).getTime();
}

View File

@@ -0,0 +1,623 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'vue-toastification';
import { FEEDBACK_DEVELOPER_STATUSES, FEEDBACK_TYPES, useDeveloperFeedbackStore } from '@/features/feedback/stores/developerFeedbackStore.js';
import {
mdiArrowLeft,
mdiDownloadOutline,
mdiOpenInNew,
mdiTagOutline,
} from '@mdi/js';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const toast = useToast();
const feedbackStore = useDeveloperFeedbackStore();
const commentBody = ref('');
const form = ref({
type: '',
status: '',
tags: [],
});
const report = computed(() => feedbackStore.selectedReport);
const canSubmitComment = computed(() =>
commentBody.value.trim().length > 0 && !feedbackStore.isCommenting
);
const statusOptions = computed(() => {
if (report.value?.status && !FEEDBACK_DEVELOPER_STATUSES.includes(report.value.status)) {
return [report.value.status, ...FEEDBACK_DEVELOPER_STATUSES];
}
return FEEDBACK_DEVELOPER_STATUSES;
});
const metadataRows = computed(() => {
const current = report.value;
if (!current) {
return [];
}
return [
[t('feedback.review.detail.metadata.path'), current.metadata?.submittedPath],
[t('feedback.review.detail.metadata.userAgent'), current.metadata?.browserUserAgent],
[t('feedback.review.detail.metadata.viewport'), formatViewport(current.metadata)],
[t('feedback.review.detail.metadata.appVersion'), current.metadata?.appVersion],
[t('feedback.review.detail.metadata.created'), formatDate(current.createdAt)],
[t('feedback.review.detail.metadata.lastActivity'), formatDate(current.lastActivityAt)],
];
});
const contextRows = computed(() => {
const context = report.value?.context;
return [
[t('feedback.review.detail.context.workspace'), context?.workspaceName ?? context?.workspaceId],
[t('feedback.review.detail.context.client'), context?.clientName ?? context?.clientId],
[t('feedback.review.detail.context.project'), context?.projectName ?? context?.projectId],
[t('feedback.review.detail.context.contentItem'), context?.contentItemTitle ?? context?.contentItemId],
];
});
const timeline = computed(() =>
[...(report.value?.timeline ?? [])].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
);
onMounted(() => {
feedbackStore.loadReport(route.params.id);
});
onBeforeUnmount(() => {
feedbackStore.clearSelectedReport();
});
watch(report, nextReport => {
if (!nextReport) {
return;
}
form.value = {
type: nextReport.type ?? '',
status: nextReport.status ?? '',
tags: [...(nextReport.tags ?? [])],
};
}, { immediate: true });
async function saveReviewChanges() {
try {
const payload = {};
if (form.value.type !== report.value.type) {
payload.type = form.value.type;
}
if (form.value.status !== report.value.status && FEEDBACK_DEVELOPER_STATUSES.includes(form.value.status)) {
payload.status = form.value.status;
}
if (JSON.stringify(form.value.tags) !== JSON.stringify(report.value.tags ?? [])) {
payload.tags = form.value.tags;
}
if (Object.keys(payload).length > 0) {
await feedbackStore.updateReport(report.value.id, payload);
}
toast.success(t('feedback.review.detail.saved'));
} catch (error) {
console.error('Failed to save feedback review changes:', error);
toast.error(t('feedback.review.detail.saveFailed'));
}
}
async function submitComment() {
if (!canSubmitComment.value) {
return;
}
try {
await feedbackStore.addComment(report.value.id, commentBody.value.trim());
commentBody.value = '';
toast.success(t('feedback.review.detail.commentAdded'));
} catch (error) {
console.error('Failed to add developer feedback comment:', error);
toast.error(t('feedback.review.detail.commentFailed'));
}
}
function formatDate(value) {
if (!value) {
return t('feedback.review.emptyValue');
}
return new Date(value).toLocaleString();
}
function formatViewport(metadata) {
if (!metadata?.viewportWidth || !metadata?.viewportHeight) {
return null;
}
return `${metadata.viewportWidth} x ${metadata.viewportHeight}`;
}
function formatFileSize(bytes) {
if (!bytes) {
return t('feedback.review.emptyValue');
}
if (bytes < 1024 * 1024) {
return `${Math.round(bytes / 1024)} KB`;
}
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function activityText(item) {
if (item.kind === 'Comment') {
return item.body;
}
if (item.activityType === 'StatusChanged') {
return t('feedback.review.detail.activity.statusChanged', {
from: item.fromValue,
to: item.toValue,
});
}
if (item.activityType === 'TypeChanged') {
return t('feedback.review.detail.activity.typeChanged', {
from: item.fromValue,
to: item.toValue,
});
}
if (item.activityType === 'TagsChanged') {
return t('feedback.review.detail.activity.tagsChanged', {
from: item.fromValue || t('feedback.review.emptyValue'),
to: item.toValue || t('feedback.review.emptyValue'),
});
}
return item.note || item.activityType || t('feedback.review.detail.activity.updated');
}
</script>
<template>
<section class="feedback-detail-page">
<button
class="back-button"
type="button"
@click="router.push({ name: 'developer-feedback' })"
>
<v-icon :icon="mdiArrowLeft" />
{{ t('feedback.review.detail.back') }}
</button>
<div
v-if="feedbackStore.isDetailLoading"
class="page-message"
>
{{ t('feedback.review.loading') }}
</div>
<div
v-else-if="feedbackStore.error"
class="page-message page-message-error"
>
{{ t(feedbackStore.error) }}
</div>
<template v-else-if="report">
<header class="detail-header">
<div>
<div class="eyebrow">{{ t('feedback.review.detail.eyebrow') }}</div>
<h1>{{ report.type }}: {{ report.description }}</h1>
<div class="header-meta">
<span>{{ report.status }}</span>
<span>{{ report.reporterDisplayName }}</span>
<span>{{ formatDate(report.createdAt) }}</span>
</div>
</div>
</header>
<div class="detail-grid">
<main class="detail-main">
<section class="panel">
<div class="panel-header">
<strong>{{ t('feedback.review.detail.report') }}</strong>
</div>
<p class="description">{{ report.description }}</p>
<a
v-if="report.metadata?.submittedPath"
class="path-link"
:href="report.metadata.submittedPath"
target="_blank"
rel="noopener"
>
<v-icon :icon="mdiOpenInNew" />
{{ report.metadata.submittedPath }}
</a>
</section>
<section class="panel">
<div class="panel-header">
<strong>{{ t('feedback.review.detail.screenshot') }}</strong>
<button
v-if="report.screenshot"
class="small-button"
type="button"
@click="feedbackStore.downloadScreenshot"
>
<v-icon :icon="mdiDownloadOutline" />
{{ t('feedback.review.detail.download') }}
</button>
<button
v-if="report.screenshot"
class="small-button"
type="button"
@click="feedbackStore.openScreenshot"
>
<v-icon :icon="mdiOpenInNew" />
{{ t('feedback.review.detail.openOriginal') }}
</button>
</div>
<div
v-if="feedbackStore.screenshotPreviewUrl"
class="screenshot-frame"
>
<img
:src="feedbackStore.screenshotPreviewUrl"
:alt="t('feedback.review.detail.screenshotAlt')"
/>
</div>
<div
v-else
class="empty-block"
>
{{ t('feedback.review.detail.noScreenshot') }}
</div>
<div
v-if="report.screenshot"
class="file-meta"
>
<span>{{ report.screenshot.fileName }}</span>
<span>{{ report.screenshot.contentType }}</span>
<span>{{ formatFileSize(report.screenshot.sizeBytes) }}</span>
</div>
</section>
<section class="panel">
<div class="panel-header">
<strong>{{ t('feedback.review.detail.timeline') }}</strong>
</div>
<div class="timeline">
<article
v-for="item in timeline"
:key="item.id"
class="timeline-item"
:class="{ 'timeline-comment': item.kind === 'Comment' }"
>
<div>
<strong>{{ item.actorDisplayName }}</strong>
<span>{{ item.actorRole || t('feedback.review.detail.activityLabel') }}</span>
</div>
<p>{{ activityText(item) }}</p>
<small>{{ formatDate(item.createdAt) }}</small>
</article>
<div
v-if="!timeline.length"
class="empty-block"
>
{{ t('feedback.review.detail.noTimeline') }}
</div>
</div>
<form
class="comment-form"
@submit.prevent="submitComment"
>
<v-textarea
v-model="commentBody"
:label="t('feedback.review.detail.commentLabel')"
rows="3"
auto-grow
variant="outlined"
hide-details
/>
<button
class="primary-button"
type="submit"
:disabled="!canSubmitComment"
>
{{ feedbackStore.isCommenting ? t('feedback.review.detail.commenting') : t('feedback.review.detail.addComment') }}
</button>
</form>
</section>
</main>
<aside class="detail-side">
<section class="panel">
<div class="panel-header">
<strong>{{ t('feedback.review.detail.reviewControls') }}</strong>
</div>
<v-select
v-model="form.type"
:items="FEEDBACK_TYPES"
:label="t('feedback.review.filters.type')"
variant="outlined"
density="compact"
/>
<v-select
v-model="form.status"
:items="statusOptions"
:label="t('feedback.review.filters.status')"
variant="outlined"
density="compact"
/>
<v-combobox
v-model="form.tags"
:items="feedbackStore.tagOptions"
:label="t('feedback.review.filters.tag')"
multiple
chips
closable-chips
variant="outlined"
density="compact"
>
<template #chip="{ props, item }">
<v-chip
v-bind="props"
size="small"
>
<v-icon
start
:icon="mdiTagOutline"
/>
{{ item.title }}
</v-chip>
</template>
</v-combobox>
<button
class="primary-button"
type="button"
:disabled="feedbackStore.isSaving"
@click="saveReviewChanges"
>
{{ feedbackStore.isSaving ? t('common.saving') : t('save') }}
</button>
</section>
<section class="panel">
<div class="panel-header">
<strong>{{ t('feedback.review.detail.reporter') }}</strong>
</div>
<dl class="info-list">
<div>
<dt>{{ t('name') }}</dt>
<dd>{{ report.reporterDisplayName }}</dd>
</div>
<div>
<dt>{{ t('email') }}</dt>
<dd>{{ report.reporterEmail }}</dd>
</div>
</dl>
</section>
<section class="panel">
<div class="panel-header">
<strong>{{ t('feedback.review.detail.metadata.title') }}</strong>
</div>
<dl class="info-list">
<div
v-for="[label, value] in metadataRows"
:key="label"
>
<dt>{{ label }}</dt>
<dd>{{ value || t('feedback.review.emptyValue') }}</dd>
</div>
</dl>
</section>
<section class="panel">
<div class="panel-header">
<strong>{{ t('feedback.review.detail.context.title') }}</strong>
</div>
<dl class="info-list">
<div
v-for="[label, value] in contextRows"
:key="label"
>
<dt>{{ label }}</dt>
<dd>{{ value || t('feedback.review.emptyValue') }}</dd>
</div>
</dl>
</section>
</aside>
</div>
</template>
</section>
</template>
<style scoped>
.feedback-detail-page {
@apply mx-auto flex w-full max-w-7xl flex-col gap-5 px-5 py-8 md:px-8;
}
.back-button,
.small-button,
.primary-button {
@apply inline-flex w-fit items-center justify-center gap-2 rounded-lg border px-4 py-2 text-sm font-bold transition-colors;
}
.back-button,
.small-button {
border-color: rgba(23, 32, 51, 0.12);
background: rgba(255, 255, 255, 0.88);
color: #172033;
}
.back-button:hover,
.small-button:hover {
background: #172033;
color: white;
}
.primary-button {
border-color: #0f766e;
background: #0f766e;
color: white;
}
.primary-button:disabled {
@apply cursor-not-allowed opacity-50;
}
.detail-header {
@apply rounded-lg border p-5;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.88);
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.22em];
color: #0f766e;
}
.detail-header h1 {
@apply mt-2 line-clamp-3 text-2xl font-black md:text-3xl;
color: #172033;
}
.header-meta,
.file-meta {
@apply mt-3 flex flex-wrap gap-2;
}
.header-meta span,
.file-meta span {
@apply rounded-md px-2.5 py-1 text-xs font-bold;
background: rgba(15, 118, 110, 0.08);
color: #0f766e;
}
.detail-grid {
@apply grid gap-5 xl:grid-cols-[minmax(0,1fr)_22rem];
}
.detail-main,
.detail-side {
@apply flex min-w-0 flex-col gap-5;
}
.panel {
@apply rounded-lg border p-5;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.9);
}
.panel-header {
@apply mb-4 flex flex-wrap items-center justify-between gap-3;
}
.panel-header strong {
@apply text-base font-black;
color: #172033;
}
.description {
@apply whitespace-pre-wrap text-sm leading-7;
color: #334155;
}
.path-link {
@apply mt-4 inline-flex max-w-full items-center gap-2 break-all rounded-lg px-3 py-2 text-sm font-semibold;
background: rgba(37, 99, 235, 0.08);
color: #1d4ed8;
}
.screenshot-frame {
@apply overflow-hidden rounded-lg border;
border-color: rgba(23, 32, 51, 0.1);
background: #0f172a;
}
.screenshot-frame img {
@apply block max-h-[38rem] w-full object-contain;
}
.empty-block,
.page-message {
@apply rounded-lg border p-4 text-sm font-semibold;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(248, 250, 252, 0.9);
color: #526178;
}
.page-message-error {
border-color: rgba(220, 38, 38, 0.24);
color: #b91c1c;
}
.timeline {
@apply flex flex-col gap-3;
}
.timeline-item {
@apply rounded-lg border p-4;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(248, 250, 252, 0.78);
}
.timeline-comment {
background: rgba(15, 118, 110, 0.06);
}
.timeline-item div {
@apply flex flex-wrap items-center gap-2;
}
.timeline-item strong {
@apply text-sm font-black;
color: #172033;
}
.timeline-item span,
.timeline-item small {
@apply text-xs font-semibold;
color: #64748b;
}
.timeline-item p {
@apply mt-2 whitespace-pre-wrap text-sm leading-6;
color: #334155;
}
.comment-form {
@apply mt-5 flex flex-col gap-3;
}
.info-list {
@apply flex flex-col gap-3;
}
.info-list div {
@apply min-w-0;
}
.info-list dt {
@apply text-xs font-bold uppercase tracking-[0.14em];
color: #64748b;
}
.info-list dd {
@apply mt-1 break-words text-sm font-semibold;
color: #172033;
}
</style>

View File

@@ -0,0 +1,450 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { FEEDBACK_STATUSES, FEEDBACK_TYPES, useDeveloperFeedbackStore } from '@/features/feedback/stores/developerFeedbackStore.js';
import {
mdiFilterOffOutline,
mdiImageOutline,
mdiMagnify,
mdiMessageTextOutline,
mdiRefresh,
mdiTagOutline,
} from '@mdi/js';
const { t } = useI18n();
const router = useRouter();
const feedbackStore = useDeveloperFeedbackStore();
const sortOptions = computed(() => [
{ title: t('feedback.review.sort.lastActivity'), value: 'lastActivity' },
{ title: t('feedback.review.sort.newest'), value: 'newest' },
{ title: t('feedback.review.sort.oldest'), value: 'oldest' },
]);
const summary = computed(() => ({
total: feedbackStore.reports.length,
visible: feedbackStore.filteredReports.length,
newCount: feedbackStore.reports.filter(report => report.status === 'New').length,
plannedCount: feedbackStore.reports.filter(report => report.status === 'Planned').length,
}));
onMounted(() => {
feedbackStore.loadReports();
});
function openReport(report) {
router.push({ name: 'developer-feedback-detail', params: { id: report.id } });
}
function formatDate(value) {
if (!value) {
return t('feedback.review.emptyValue');
}
return new Date(value).toLocaleString();
}
function reportContext(report) {
return [
report.context?.workspaceName,
report.context?.clientName,
report.context?.projectName,
report.context?.contentItemTitle,
]
.filter(Boolean)
.join(' / ') || t('feedback.review.noContext');
}
function statusClass(status) {
return `status-${String(status ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
}
</script>
<template>
<section class="feedback-review-page">
<header class="review-header">
<div>
<div class="eyebrow">{{ t('feedback.review.eyebrow') }}</div>
<h1>{{ t('feedback.review.title') }}</h1>
<p>{{ t('feedback.review.description') }}</p>
</div>
<button
class="icon-button"
type="button"
:title="t('feedback.review.refresh')"
@click="feedbackStore.loadReports"
>
<v-icon :icon="mdiRefresh" />
</button>
</header>
<section class="metric-grid">
<article class="metric">
<span>{{ t('feedback.review.metrics.total') }}</span>
<strong>{{ summary.total }}</strong>
</article>
<article class="metric">
<span>{{ t('feedback.review.metrics.visible') }}</span>
<strong>{{ summary.visible }}</strong>
</article>
<article class="metric">
<span>{{ t('feedback.review.metrics.new') }}</span>
<strong>{{ summary.newCount }}</strong>
</article>
<article class="metric">
<span>{{ t('feedback.review.metrics.planned') }}</span>
<strong>{{ summary.plannedCount }}</strong>
</article>
</section>
<section class="filter-panel">
<label class="filter-search">
<v-icon :icon="mdiMagnify" />
<input
v-model="feedbackStore.filters.search"
type="search"
:placeholder="t('feedback.review.filters.search')"
/>
</label>
<v-select
v-model="feedbackStore.filters.type"
:items="FEEDBACK_TYPES"
:label="t('feedback.review.filters.type')"
density="compact"
variant="outlined"
hide-details
clearable
/>
<v-select
v-model="feedbackStore.filters.status"
:items="FEEDBACK_STATUSES"
:label="t('feedback.review.filters.status')"
density="compact"
variant="outlined"
hide-details
clearable
/>
<v-select
v-model="feedbackStore.filters.tag"
:items="feedbackStore.tagOptions"
:label="t('feedback.review.filters.tag')"
density="compact"
variant="outlined"
hide-details
clearable
/>
<input
v-model="feedbackStore.filters.reporter"
class="field"
type="text"
:placeholder="t('feedback.review.filters.reporter')"
/>
<input
v-model="feedbackStore.filters.workspace"
class="field"
type="text"
:placeholder="t('feedback.review.filters.workspace')"
/>
<input
v-model="feedbackStore.filters.fromDate"
class="field"
type="date"
:aria-label="t('feedback.review.filters.fromDate')"
/>
<input
v-model="feedbackStore.filters.toDate"
class="field"
type="date"
:aria-label="t('feedback.review.filters.toDate')"
/>
<v-select
v-model="feedbackStore.filters.sort"
:items="sortOptions"
:label="t('feedback.review.filters.sort')"
density="compact"
variant="outlined"
hide-details
/>
<button
class="filter-reset"
type="button"
:title="t('feedback.review.filters.clear')"
@click="feedbackStore.resetFilters"
>
<v-icon :icon="mdiFilterOffOutline" />
</button>
</section>
<div
v-if="feedbackStore.isLoading"
class="page-message"
>
{{ t('feedback.review.loading') }}
</div>
<div
v-else-if="feedbackStore.error"
class="page-message page-message-error"
>
{{ t(feedbackStore.error) }}
</div>
<section
v-else
class="report-table"
>
<button
v-for="report in feedbackStore.filteredReports"
:key="report.id"
class="report-row"
type="button"
@click="openReport(report)"
>
<span class="report-main">
<span class="report-title">
<span
class="status-dot"
:class="statusClass(report.status)"
></span>
<strong>{{ report.type }}</strong>
<em>{{ report.status }}</em>
</span>
<span class="report-description">{{ report.description }}</span>
<span class="report-tags">
<span
v-for="tag in report.tags"
:key="tag"
>
<v-icon :icon="mdiTagOutline" />
{{ tag }}
</span>
</span>
</span>
<span class="report-secondary">
<span>{{ report.reporterDisplayName }}</span>
<small>{{ report.reporterEmail }}</small>
</span>
<span class="report-context">{{ reportContext(report) }}</span>
<span class="report-activity">
<span>{{ t('feedback.review.lastActivity') }}</span>
<strong>{{ formatDate(report.lastActivityAt) }}</strong>
<small>
<v-icon
v-if="report.screenshot"
:icon="mdiImageOutline"
/>
<v-icon
v-if="report.timeline?.length"
:icon="mdiMessageTextOutline"
/>
</small>
</span>
</button>
<div
v-if="!feedbackStore.filteredReports.length"
class="page-message"
>
{{ t('feedback.review.empty') }}
</div>
</section>
</section>
</template>
<style scoped>
.feedback-review-page {
@apply mx-auto flex w-full max-w-7xl flex-col gap-5 px-5 py-8 md:px-8;
}
.review-header {
@apply flex flex-col justify-between gap-4 md:flex-row md:items-start;
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.22em];
color: #0f766e;
}
.review-header h1 {
@apply mt-2 text-3xl font-black md:text-4xl;
color: #172033;
}
.review-header p {
@apply mt-2 max-w-3xl text-sm leading-6;
color: #526178;
}
.icon-button,
.filter-reset {
@apply inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-lg border transition-colors;
border-color: rgba(23, 32, 51, 0.12);
background: rgba(255, 255, 255, 0.92);
color: #172033;
}
.icon-button:hover,
.filter-reset:hover {
background: #172033;
color: white;
}
.metric-grid {
@apply grid gap-3 sm:grid-cols-2 xl:grid-cols-4;
}
.metric {
@apply rounded-lg border p-4;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.86);
}
.metric span {
@apply text-xs font-bold uppercase tracking-[0.16em];
color: #64748b;
}
.metric strong {
@apply mt-2 block text-3xl font-black;
color: #172033;
}
.filter-panel {
@apply grid gap-3 rounded-lg border p-4 lg:grid-cols-[minmax(15rem,1.5fr)_repeat(4,minmax(9rem,1fr))_repeat(2,minmax(8rem,0.8fr))_minmax(10rem,1fr)_auto];
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.9);
}
.filter-search,
.field {
@apply flex h-10 items-center gap-2 rounded-lg border px-3 text-sm;
border-color: rgba(23, 32, 51, 0.16);
background: white;
color: #172033;
}
.filter-search input {
@apply min-w-0 flex-1 bg-transparent outline-none;
}
.field {
@apply w-full outline-none;
}
.report-table {
@apply flex flex-col gap-2;
}
.report-row {
@apply grid gap-4 rounded-lg border p-4 text-left transition-colors lg:grid-cols-[minmax(0,1.55fr)_minmax(12rem,0.8fr)_minmax(12rem,0.8fr)_minmax(12rem,0.7fr)] lg:items-center;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.86);
}
.report-row:hover {
border-color: rgba(15, 118, 110, 0.36);
background: white;
}
.report-main,
.report-secondary,
.report-activity {
@apply flex min-w-0 flex-col gap-1;
}
.report-title {
@apply flex flex-wrap items-center gap-2;
}
.report-title strong {
@apply text-sm font-black;
color: #172033;
}
.report-title em {
@apply rounded-md px-2 py-1 text-xs font-bold not-italic;
background: rgba(15, 118, 110, 0.08);
color: #0f766e;
}
.status-dot {
@apply h-2.5 w-2.5 rounded-full;
background: #64748b;
}
.status-new {
background: #2563eb;
}
.status-planned {
background: #0f766e;
}
.status-resolved {
background: #16a34a;
}
.status-won-t-do,
.status-cancelled {
background: #64748b;
}
.report-description {
@apply line-clamp-2 text-sm leading-6;
color: #526178;
}
.report-tags {
@apply mt-1 flex flex-wrap gap-1.5;
}
.report-tags span {
@apply inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold;
background: rgba(23, 32, 51, 0.06);
color: #44516a;
}
.report-secondary span,
.report-context,
.report-activity strong {
@apply text-sm font-semibold;
color: #172033;
}
.report-secondary small,
.report-activity span,
.report-activity small {
@apply text-xs;
color: #64748b;
}
.report-activity small {
@apply flex gap-1;
}
.page-message {
@apply rounded-lg border p-4 text-sm font-semibold;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.86);
color: #526178;
}
.page-message-error {
border-color: rgba(220, 38, 38, 0.24);
color: #b91c1c;
}
</style>

View File

@@ -0,0 +1,394 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'vue-toastification';
import { useMyFeedbackStore } from '@/features/feedback/stores/myFeedbackStore.js';
import { mdiArrowLeft, mdiCancel, mdiOpenInNew, mdiTagOutline } from '@mdi/js';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const toast = useToast();
const feedbackStore = useMyFeedbackStore();
const commentBody = ref('');
const report = computed(() => feedbackStore.selectedReport);
const timeline = computed(() =>
[...(report.value?.timeline ?? [])].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
);
const canSubmitComment = computed(() =>
commentBody.value.trim().length > 0 && !feedbackStore.isCommenting
);
const canCancel = computed(() => report.value && !['Resolved', "Won't Do", 'Cancelled'].includes(report.value.status));
onMounted(() => {
feedbackStore.loadReport(route.params.id);
});
onBeforeUnmount(() => {
feedbackStore.clearSelectedReport();
});
async function submitComment() {
if (!canSubmitComment.value) {
return;
}
try {
await feedbackStore.addComment(report.value.id, commentBody.value.trim());
commentBody.value = '';
toast.success(t('feedback.mine.detail.commentAdded'));
} catch (error) {
console.error('Failed to add feedback comment:', error);
toast.error(t('feedback.mine.detail.commentFailed'));
}
}
async function cancelReport() {
if (!canCancel.value) {
return;
}
const reason = window.prompt(t('feedback.mine.detail.cancelPrompt'));
if (reason === null) {
return;
}
try {
await feedbackStore.cancelReport(report.value.id, reason.trim());
toast.success(t('feedback.mine.detail.cancelled'));
} catch (error) {
console.error('Failed to cancel feedback:', error);
toast.error(t('feedback.mine.detail.cancelFailed'));
}
}
function formatDate(value) {
return value ? new Date(value).toLocaleString() : t('feedback.review.emptyValue');
}
function activityText(item) {
if (item.kind === 'Comment') {
return item.body;
}
if (item.activityType === 'StatusChanged') {
return t('feedback.review.detail.activity.statusChanged', {
from: item.fromValue,
to: item.toValue,
});
}
if (item.activityType === 'TypeChanged') {
return t('feedback.review.detail.activity.typeChanged', {
from: item.fromValue,
to: item.toValue,
});
}
if (item.activityType === 'TagsChanged') {
return t('feedback.review.detail.activity.tagsChanged', {
from: item.fromValue || t('feedback.review.emptyValue'),
to: item.toValue || t('feedback.review.emptyValue'),
});
}
return item.note || item.activityType || t('feedback.review.detail.activity.updated');
}
</script>
<template>
<section class="feedback-detail-page">
<button
class="back-button"
type="button"
@click="router.push({ name: 'my-feedback' })"
>
<v-icon :icon="mdiArrowLeft" />
{{ t('feedback.mine.detail.back') }}
</button>
<div
v-if="feedbackStore.isDetailLoading"
class="page-message"
>
{{ t('feedback.review.loading') }}
</div>
<div
v-else-if="feedbackStore.error"
class="page-message page-message-error"
>
{{ t(feedbackStore.error) }}
</div>
<template v-else-if="report">
<header class="detail-header">
<div>
<div class="eyebrow">{{ t('feedback.mine.detail.eyebrow') }}</div>
<h1>{{ report.type }}: {{ report.description }}</h1>
<div class="header-meta">
<span>{{ report.status }}</span>
<span>{{ formatDate(report.createdAt) }}</span>
</div>
</div>
<button
v-if="canCancel"
class="cancel-button"
type="button"
:disabled="feedbackStore.isCancelling"
@click="cancelReport"
>
<v-icon :icon="mdiCancel" />
{{ feedbackStore.isCancelling ? t('common.saving') : t('feedback.mine.detail.cancel') }}
</button>
</header>
<div class="detail-grid">
<main class="detail-main">
<section class="panel">
<div class="panel-header">
<strong>{{ t('feedback.review.detail.report') }}</strong>
</div>
<p class="description">{{ report.description }}</p>
<a
v-if="report.metadata?.submittedPath"
class="path-link"
:href="report.metadata.submittedPath"
target="_blank"
rel="noopener"
>
<v-icon :icon="mdiOpenInNew" />
{{ report.metadata.submittedPath }}
</a>
<div class="tag-row">
<span
v-for="tag in report.tags"
:key="tag"
>
<v-icon :icon="mdiTagOutline" />
{{ tag }}
</span>
</div>
</section>
<section class="panel">
<div class="panel-header">
<strong>{{ t('feedback.review.detail.screenshot') }}</strong>
</div>
<img
v-if="feedbackStore.screenshotPreviewUrl"
class="screenshot-preview"
:src="feedbackStore.screenshotPreviewUrl"
:alt="t('feedback.review.detail.screenshotAlt')"
/>
<p
v-else
class="muted"
>
{{ t('feedback.review.detail.noScreenshot') }}
</p>
</section>
</main>
<aside class="detail-side">
<section class="panel">
<div class="panel-header">
<strong>{{ t('feedback.review.detail.timeline') }}</strong>
</div>
<ol
v-if="timeline.length"
class="timeline"
>
<li
v-for="item in timeline"
:key="item.id"
>
<strong>{{ item.actorDisplayName }}</strong>
<span>{{ item.actorRole || t('feedback.review.detail.activityLabel') }}</span>
<p>{{ activityText(item) }}</p>
<small>{{ formatDate(item.createdAt) }}</small>
</li>
</ol>
<p
v-else
class="muted"
>
{{ t('feedback.review.detail.noTimeline') }}
</p>
<v-textarea
v-model="commentBody"
class="mt-4"
:label="t('feedback.mine.detail.commentLabel')"
rows="3"
variant="outlined"
hide-details
/>
<button
class="primary-button"
type="button"
:disabled="!canSubmitComment"
@click="submitComment"
>
{{ feedbackStore.isCommenting ? t('feedback.review.detail.commenting') : t('feedback.review.detail.addComment') }}
</button>
</section>
</aside>
</div>
</template>
</section>
</template>
<style scoped>
.feedback-detail-page {
@apply mx-auto flex w-full max-w-7xl flex-col gap-5 px-5 py-8 md:px-8;
}
.back-button,
.cancel-button,
.primary-button {
@apply inline-flex w-fit items-center gap-2 rounded-lg px-4 py-2 text-sm font-bold transition-colors;
}
.back-button {
color: #0f766e;
background: rgba(15, 118, 110, 0.08);
}
.cancel-button {
border: 1px solid rgba(220, 38, 38, 0.24);
color: #b91c1c;
background: white;
}
.primary-button {
@apply mt-3 justify-center;
background: #172033;
color: white;
}
.primary-button:disabled,
.cancel-button:disabled {
@apply cursor-not-allowed opacity-60;
}
.detail-header {
@apply flex flex-col justify-between gap-4 md:flex-row md:items-start;
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.22em];
color: #0f766e;
}
.detail-header h1 {
@apply mt-2 max-w-4xl text-2xl font-black leading-tight md:text-4xl;
color: #172033;
}
.header-meta {
@apply mt-3 flex flex-wrap gap-2 text-xs font-bold;
color: #64748b;
}
.header-meta span {
@apply rounded-md px-2 py-1;
background: rgba(23, 32, 51, 0.06);
}
.detail-grid {
@apply grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,0.42fr)];
}
.detail-main,
.detail-side {
@apply flex flex-col gap-5;
}
.panel,
.page-message {
@apply rounded-lg border p-4;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.9);
}
.panel-header {
@apply mb-3 flex items-center justify-between gap-3;
}
.panel-header strong {
@apply text-sm font-black;
color: #172033;
}
.description {
@apply whitespace-pre-wrap text-sm leading-6;
color: #526178;
}
.path-link {
@apply mt-3 inline-flex max-w-full items-center gap-2 truncate rounded-md px-2 py-1 text-sm font-semibold;
background: rgba(15, 118, 110, 0.08);
color: #0f766e;
}
.tag-row {
@apply mt-4 flex flex-wrap gap-1.5;
}
.tag-row span {
@apply inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold;
background: rgba(23, 32, 51, 0.06);
color: #44516a;
}
.screenshot-preview {
@apply max-h-[34rem] w-full rounded-lg object-contain;
background: #f8fafc;
}
.timeline {
@apply flex list-none flex-col gap-3 p-0;
}
.timeline li {
@apply rounded-lg border p-3;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(248, 250, 252, 0.75);
}
.timeline strong,
.timeline span,
.timeline small {
@apply block;
}
.timeline strong {
@apply text-sm font-black;
color: #172033;
}
.timeline span,
.timeline small,
.muted,
.page-message {
@apply text-sm;
color: #64748b;
}
.timeline p {
@apply my-2 whitespace-pre-wrap text-sm leading-6;
color: #526178;
}
.page-message-error {
border-color: rgba(220, 38, 38, 0.24);
color: #b91c1c;
}
</style>

View File

@@ -0,0 +1,311 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { FEEDBACK_STATUSES, FEEDBACK_TYPES } from '@/features/feedback/stores/developerFeedbackStore.js';
import { useMyFeedbackStore } from '@/features/feedback/stores/myFeedbackStore.js';
import { mdiFilterOffOutline, mdiRefresh, mdiTagOutline } from '@mdi/js';
const { t } = useI18n();
const router = useRouter();
const feedbackStore = useMyFeedbackStore();
const sortOptions = computed(() => [
{ title: t('feedback.review.sort.lastActivity'), value: 'lastActivity' },
{ title: t('feedback.review.sort.newest'), value: 'newest' },
]);
const summary = computed(() => ({
active: feedbackStore.reports.filter(report => ['New', 'Planned'].includes(report.status)).length,
unread: feedbackStore.reports.filter(report => feedbackStore.unreadReportIds.has(report.id)).length,
visible: feedbackStore.filteredReports.length,
}));
onMounted(() => {
feedbackStore.loadReports();
});
function openReport(report) {
router.push({ name: 'my-feedback-detail', params: { id: report.id } });
}
function formatDate(value) {
return value ? new Date(value).toLocaleString() : t('feedback.review.emptyValue');
}
</script>
<template>
<section class="my-feedback-page">
<header class="page-header">
<div>
<div class="eyebrow">{{ t('feedback.mine.eyebrow') }}</div>
<h1>{{ t('feedback.mine.title') }}</h1>
<p>{{ t('feedback.mine.description') }}</p>
</div>
<button
class="icon-button"
type="button"
:title="t('feedback.mine.refresh')"
@click="feedbackStore.loadReports"
>
<v-icon :icon="mdiRefresh" />
</button>
</header>
<section class="metric-grid">
<article class="metric">
<span>{{ t('feedback.mine.metrics.active') }}</span>
<strong>{{ summary.active }}</strong>
</article>
<article class="metric">
<span>{{ t('feedback.mine.metrics.unread') }}</span>
<strong>{{ summary.unread }}</strong>
</article>
<article class="metric">
<span>{{ t('feedback.mine.metrics.visible') }}</span>
<strong>{{ summary.visible }}</strong>
</article>
</section>
<section class="filter-panel">
<v-select
v-model="feedbackStore.filters.type"
:items="FEEDBACK_TYPES"
:label="t('feedback.review.filters.type')"
density="compact"
variant="outlined"
hide-details
clearable
/>
<v-select
v-model="feedbackStore.filters.status"
:items="FEEDBACK_STATUSES"
:label="t('feedback.review.filters.status')"
density="compact"
variant="outlined"
hide-details
clearable
/>
<v-select
v-model="feedbackStore.filters.sort"
:items="sortOptions"
:label="t('feedback.review.filters.sort')"
density="compact"
variant="outlined"
hide-details
/>
<button
class="icon-button"
type="button"
:title="t('feedback.review.filters.clear')"
@click="feedbackStore.resetFilters"
>
<v-icon :icon="mdiFilterOffOutline" />
</button>
</section>
<div
v-if="feedbackStore.isLoading"
class="page-message"
>
{{ t('feedback.review.loading') }}
</div>
<div
v-else-if="feedbackStore.error"
class="page-message page-message-error"
>
{{ t(feedbackStore.error) }}
</div>
<section
v-else
class="report-list"
>
<button
v-for="report in feedbackStore.filteredReports"
:key="report.id"
class="report-row"
:class="{ 'report-row-unread': feedbackStore.unreadReportIds.has(report.id) }"
type="button"
@click="openReport(report)"
>
<span
v-if="feedbackStore.unreadReportIds.has(report.id)"
class="unread-dot"
:title="t('feedback.mine.unread')"
></span>
<span class="report-main">
<span class="report-title">
<strong>{{ report.type }}</strong>
<em>{{ report.status }}</em>
</span>
<span class="report-description">{{ report.description }}</span>
<span class="report-tags">
<span
v-for="tag in report.tags"
:key="tag"
>
<v-icon :icon="mdiTagOutline" />
{{ tag }}
</span>
</span>
</span>
<span class="report-activity">
<span>{{ t('feedback.review.lastActivity') }}</span>
<strong>{{ formatDate(report.lastActivityAt) }}</strong>
</span>
</button>
<div
v-if="!feedbackStore.filteredReports.length"
class="page-message"
>
{{ t('feedback.mine.empty') }}
</div>
</section>
</section>
</template>
<style scoped>
.my-feedback-page {
@apply mx-auto flex w-full max-w-6xl flex-col gap-5 px-5 py-8 md:px-8;
}
.page-header {
@apply flex flex-col justify-between gap-4 md:flex-row md:items-start;
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.22em];
color: #0f766e;
}
.page-header h1 {
@apply mt-2 text-3xl font-black md:text-4xl;
color: #172033;
}
.page-header p {
@apply mt-2 max-w-3xl text-sm leading-6;
color: #526178;
}
.metric-grid {
@apply grid gap-3 md:grid-cols-3;
}
.metric,
.filter-panel,
.report-row,
.page-message {
@apply rounded-lg border;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.9);
}
.metric {
@apply p-4;
}
.metric span {
@apply text-xs font-bold uppercase tracking-[0.16em];
color: #64748b;
}
.metric strong {
@apply mt-2 block text-3xl font-black;
color: #172033;
}
.filter-panel {
@apply grid gap-3 p-4 md:grid-cols-[repeat(3,minmax(10rem,1fr))_auto];
}
.icon-button {
@apply inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-lg border transition-colors;
border-color: rgba(23, 32, 51, 0.12);
background: rgba(255, 255, 255, 0.92);
color: #172033;
}
.icon-button:hover {
background: #172033;
color: white;
}
.report-list {
@apply flex flex-col gap-2;
}
.report-row {
@apply grid gap-4 p-4 text-left transition-colors md:grid-cols-[auto_minmax(0,1fr)_minmax(12rem,0.35fr)] md:items-center;
}
.report-row:hover,
.report-row-unread {
border-color: rgba(15, 118, 110, 0.36);
background: white;
}
.unread-dot {
@apply h-2.5 w-2.5 rounded-full;
background: #0f766e;
}
.report-main,
.report-activity {
@apply flex min-w-0 flex-col gap-1;
}
.report-title {
@apply flex flex-wrap items-center gap-2;
}
.report-title strong,
.report-activity strong {
@apply text-sm font-black;
color: #172033;
}
.report-title em {
@apply rounded-md px-2 py-1 text-xs font-bold not-italic;
background: rgba(15, 118, 110, 0.08);
color: #0f766e;
}
.report-description {
@apply line-clamp-2 text-sm leading-6;
color: #526178;
}
.report-tags {
@apply mt-1 flex flex-wrap gap-1.5;
}
.report-tags span {
@apply inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold;
background: rgba(23, 32, 51, 0.06);
color: #44516a;
}
.report-activity span {
@apply text-xs;
color: #64748b;
}
.page-message {
@apply p-4 text-sm font-semibold;
color: #526178;
}
.page-message-error {
border-color: rgba(220, 38, 38, 0.24);
color: #b91c1c;
}
</style>

View File

@@ -0,0 +1,45 @@
export function getNotificationRoute(notification, authStore) {
const metadataRoute = getMetadataRoute(notification);
if (metadataRoute) {
return metadataRoute;
}
if (isFeedbackNotification(notification)) {
return {
name: authStore.hasAnyRole(['Developer']) ? 'developer-feedback-detail' : 'my-feedback-detail',
params: { id: notification.entityId },
};
}
if (notification.contentItemId) {
return {
name: 'content-item-detail',
params: { id: notification.contentItemId },
};
}
return null;
}
export function isFeedbackNotification(notification) {
return notification.entityType === 'FeedbackReport' ||
notification.eventType?.startsWith('Feedback.') ||
getMetadata(notification)?.isFeedbackNotification === true;
}
function getMetadataRoute(notification) {
const route = getMetadata(notification)?.route;
return typeof route === 'string' && route.startsWith('/app/') ? route : null;
}
function getMetadata(notification) {
if (!notification.metadataJson) {
return null;
}
try {
return JSON.parse(notification.metadataJson);
} catch {
return null;
}
}

View File

@@ -3,6 +3,7 @@ import { defineStore } from 'pinia';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useClient } from '@/plugins/api.js';
import { isFeedbackNotification } from '@/features/notifications/notificationRoutes.js';
export const useNotificationsStore = defineStore('notifications', () => {
const authStore = useAuthStore();
@@ -18,6 +19,13 @@ export const useNotificationsStore = defineStore('notifications', () => {
);
const recentItems = computed(() => items.value.slice(0, 6));
const unreadFeedbackReportIds = computed(() =>
new Set(
items.value
.filter(item => !item.readAt && isFeedbackNotification(item) && item.entityId)
.map(item => item.entityId)
)
);
function reset() {
items.value = [];
@@ -25,7 +33,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
}
async function fetchNotifications() {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
if (!authStore.isAuthenticated) {
reset();
return;
}
@@ -34,11 +42,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
error.value = null;
try {
const response = await client.get('/api/notifications', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
},
});
const response = await client.get('/api/notifications');
items.value = response.data ?? [];
} catch (fetchError) {
@@ -63,10 +67,18 @@ export const useNotificationsStore = defineStore('notifications', () => {
}
}
async function markFeedbackReportAsRead(reportId) {
const unreadNotifications = items.value.filter(item =>
!item.readAt && isFeedbackNotification(item) && item.entityId === reportId
);
await Promise.all(unreadNotifications.map(item => markAsRead(item.id)));
}
watch(
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
async ([isAuthenticated, workspaceId]) => {
if (!isAuthenticated || !workspaceId) {
if (!isAuthenticated) {
reset();
return;
}
@@ -79,11 +91,13 @@ export const useNotificationsStore = defineStore('notifications', () => {
return {
items,
recentItems,
unreadFeedbackReportIds,
unreadCount,
isLoading,
error,
reset,
fetchNotifications,
markAsRead,
markFeedbackReportAsRead,
};
});

View File

@@ -7,6 +7,7 @@
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
import { useLanguageStore } from '@/stores/languageStore.js';
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
import { getNotificationRoute } from '@/features/notifications/notificationRoutes.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
@@ -21,6 +22,7 @@
mdiLan,
mdiMagnify,
mdiPlus,
mdiBugOutline,
} from '@mdi/js';
const props = defineProps({
@@ -53,8 +55,13 @@
{ to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline },
{ to: '/app/workspace', labelKey: 'nav.workspacePlan', icon: mdiCalendarMonthOutline },
{ to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline },
{ to: '/app/my-feedback', labelKey: 'nav.myFeedback', icon: mdiBugOutline },
{ to: '/app/feedback', labelKey: 'nav.feedbackReview', icon: mdiBugOutline, roles: ['Developer'] },
{ to: '/app/workspace-settings', labelKey: 'nav.settings', icon: mdiCogOutline },
];
const visiblePrimaryLinks = computed(() =>
primaryLinks.filter(link => !link.roles || authStore.hasAnyRole(link.roles))
);
const openSections = ref({
channels: false,
@@ -111,6 +118,10 @@
'content-item.status.updated': t('notifications.events.statusUpdated'),
'asset.google-drive-linked': t('notifications.events.assetLinked'),
'asset.revision.created': t('notifications.events.assetRevisionCreated'),
'Feedback.ReportCreated': t('notifications.events.feedbackReportCreated'),
'Feedback.DeveloperCommented': t('notifications.events.feedbackDeveloperCommented'),
'Feedback.StatusChanged': t('notifications.events.feedbackStatusChanged'),
'Feedback.ReporterCommented': t('notifications.events.feedbackReporterCommented'),
}));
function toggleSection(sectionName) {
@@ -159,8 +170,9 @@
isNotificationsOpen.value = false;
if (notification.contentItemId) {
await router.push({ name: 'content-item-detail', params: { id: notification.contentItemId } });
const notificationRoute = getNotificationRoute(notification, authStore);
if (notificationRoute) {
await router.push(notificationRoute);
}
}
@@ -372,7 +384,7 @@
<div class="sidebar-section">
<router-link
v-for="link in primaryLinks"
v-for="link in visiblePrimaryLinks"
:key="link.to"
:to="link.to"
class="sidebar-link"

View File

@@ -71,6 +71,8 @@
"overview": "Overview",
"workspacePlan": "Content",
"mediaLibrary": "Media Library",
"myFeedback": "My Feedback",
"feedbackReview": "Feedback Review",
"channels": "Channels",
"projects": "Campaigns",
"reviewQueue": "Review Queue",
@@ -96,7 +98,156 @@
"revisionCreated": "Revision created",
"statusUpdated": "Status updated",
"assetLinked": "Asset linked",
"assetRevisionCreated": "Asset revision created"
"assetRevisionCreated": "Asset revision created",
"feedbackReportCreated": "New feedback report",
"feedbackDeveloperCommented": "Developer commented",
"feedbackStatusChanged": "Feedback status changed",
"feedbackReporterCommented": "Reporter replied"
}
},
"feedback": {
"button": "Feedback",
"open": "Send product feedback",
"eyebrow": "Product feedback",
"title": "Send feedback",
"capture": "Capture screen",
"removeCapture": "Remove capture",
"noCapture": "Capture the current app viewport if a screenshot would help.",
"captureFailed": "The screenshot could not be captured. You can still submit feedback without it.",
"submit": "Submit feedback",
"submitted": "Feedback submitted.",
"submitFailed": "Feedback could not be submitted.",
"discardConfirm": "Discard this unsent feedback?",
"textPrompt": "Text label",
"types": {
"bug": "Bug",
"suggestion": "Suggestion",
"request": "Request"
},
"fields": {
"type": "Type",
"description": "Description",
"descriptionPlaceholder": "Describe what happened, what you expected, or what would improve the workflow."
},
"tools": {
"crop": "Crop",
"arrow": "Arrow",
"ellipse": "Circle",
"line": "Line",
"freehand": "Freehand",
"text": "Text label",
"undo": "Undo",
"clear": "Clear and reset"
},
"review": {
"eyebrow": "Developer review",
"title": "Product feedback",
"description": "Review submitted bugs, suggestions, and requests across all workspaces.",
"refresh": "Refresh feedback",
"loading": "Loading feedback...",
"empty": "No feedback matches the current filters.",
"emptyValue": "Not captured",
"noContext": "No workspace context",
"lastActivity": "Last activity",
"metrics": {
"total": "Total reports",
"visible": "Visible",
"new": "New",
"planned": "Planned"
},
"filters": {
"search": "Search feedback",
"type": "Type",
"status": "Status",
"tag": "Tag",
"reporter": "Reporter",
"workspace": "Workspace",
"fromDate": "From date",
"toDate": "To date",
"sort": "Sort",
"clear": "Clear filters"
},
"sort": {
"lastActivity": "Last activity",
"newest": "Newest",
"oldest": "Oldest"
},
"errors": {
"loadFailed": "Feedback could not be loaded.",
"detailFailed": "The feedback report could not be loaded."
},
"detail": {
"back": "Back to feedback",
"eyebrow": "Feedback detail",
"report": "Report",
"screenshot": "Screenshot",
"download": "Download original",
"openOriginal": "Open original",
"screenshotAlt": "Feedback screenshot",
"noScreenshot": "No screenshot was attached.",
"timeline": "Comments and activity",
"noTimeline": "No comments or activity yet.",
"commentLabel": "Developer comment",
"addComment": "Add comment",
"commenting": "Adding comment...",
"commentAdded": "Comment added.",
"commentFailed": "Comment could not be added.",
"reviewControls": "Review controls",
"saved": "Feedback updated.",
"saveFailed": "Feedback could not be updated.",
"reporter": "Reporter",
"activityLabel": "Activity",
"metadata": {
"title": "Captured metadata",
"path": "Submitted path",
"userAgent": "Browser",
"viewport": "Viewport",
"appVersion": "App version",
"created": "Created",
"lastActivity": "Last activity"
},
"context": {
"title": "Context",
"workspace": "Workspace",
"client": "Client",
"project": "Campaign",
"contentItem": "Content item"
},
"activity": {
"updated": "Updated feedback.",
"statusChanged": "Changed status from {from} to {to}.",
"typeChanged": "Changed type from {from} to {to}.",
"tagsChanged": "Changed tags from {from} to {to}."
}
}
},
"mine": {
"eyebrow": "My feedback",
"title": "My Feedback",
"description": "Track the product feedback you have submitted across workspaces.",
"refresh": "Refresh my feedback",
"empty": "No feedback matches the current filters.",
"unread": "Unread feedback activity",
"metrics": {
"active": "Active reports",
"unread": "Unread",
"visible": "Visible"
},
"errors": {
"loadFailed": "Your feedback could not be loaded.",
"detailFailed": "This feedback report could not be loaded."
},
"detail": {
"back": "Back to my feedback",
"eyebrow": "My feedback detail",
"commentLabel": "Follow-up comment",
"commentAdded": "Comment added.",
"commentFailed": "Comment could not be added.",
"cancel": "Cancel report",
"cancelPrompt": "Optional cancellation reason",
"cancelled": "Feedback cancelled.",
"cancelFailed": "Feedback could not be cancelled."
}
}
},
"sidebar": {

View File

@@ -71,6 +71,8 @@
"overview": "Vue globale",
"workspacePlan": "Contenu",
"mediaLibrary": "Bibliotheque media",
"myFeedback": "Mon feedback",
"feedbackReview": "Revue feedback",
"channels": "Canaux",
"projects": "Campagnes",
"reviewQueue": "File de révision",
@@ -96,7 +98,156 @@
"revisionCreated": "Révision créée",
"statusUpdated": "Statut mis à jour",
"assetLinked": "Ressource liée",
"assetRevisionCreated": "Révision de ressource créée"
"assetRevisionCreated": "Révision de ressource créée",
"feedbackReportCreated": "Nouveau rapport de feedback",
"feedbackDeveloperCommented": "Commentaire développeur",
"feedbackStatusChanged": "Statut du feedback modifié",
"feedbackReporterCommented": "Réponse du rapporteur"
}
},
"feedback": {
"button": "Feedback",
"open": "Envoyer un feedback produit",
"eyebrow": "Feedback produit",
"title": "Envoyer un feedback",
"capture": "Capturer l'écran",
"removeCapture": "Retirer la capture",
"noCapture": "Capturez la vue actuelle de l'application si une image peut aider.",
"captureFailed": "La capture d'écran n'a pas pu être faite. Vous pouvez quand même envoyer un feedback sans image.",
"submit": "Envoyer le feedback",
"submitted": "Feedback envoyé.",
"submitFailed": "Le feedback n'a pas pu être envoyé.",
"discardConfirm": "Supprimer ce feedback non envoyé ?",
"textPrompt": "Libellé texte",
"types": {
"bug": "Bug",
"suggestion": "Suggestion",
"request": "Demande"
},
"fields": {
"type": "Type",
"description": "Description",
"descriptionPlaceholder": "Décrivez ce qui s'est produit, ce que vous attendiez ou ce qui améliorerait le workflow."
},
"tools": {
"crop": "Recadrer",
"arrow": "Flèche",
"ellipse": "Cercle",
"line": "Ligne",
"freehand": "Main levée",
"text": "Texte",
"undo": "Annuler",
"clear": "Effacer et réinitialiser"
},
"review": {
"eyebrow": "Revue développeur",
"title": "Feedback produit",
"description": "Passez en revue les bugs, suggestions et demandes soumis dans tous les espaces.",
"refresh": "Actualiser le feedback",
"loading": "Chargement du feedback...",
"empty": "Aucun feedback ne correspond aux filtres actuels.",
"emptyValue": "Non capturé",
"noContext": "Aucun contexte d'espace",
"lastActivity": "Dernière activité",
"metrics": {
"total": "Rapports totaux",
"visible": "Visibles",
"new": "Nouveaux",
"planned": "Planifiés"
},
"filters": {
"search": "Rechercher du feedback",
"type": "Type",
"status": "Statut",
"tag": "Tag",
"reporter": "Rapporteur",
"workspace": "Espace",
"fromDate": "Date de début",
"toDate": "Date de fin",
"sort": "Tri",
"clear": "Effacer les filtres"
},
"sort": {
"lastActivity": "Dernière activité",
"newest": "Plus récent",
"oldest": "Plus ancien"
},
"errors": {
"loadFailed": "Le feedback n'a pas pu être chargé.",
"detailFailed": "Le rapport de feedback n'a pas pu être chargé."
},
"detail": {
"back": "Retour au feedback",
"eyebrow": "Détail du feedback",
"report": "Rapport",
"screenshot": "Capture d'écran",
"download": "Télécharger l'original",
"openOriginal": "Ouvrir l'original",
"screenshotAlt": "Capture d'écran du feedback",
"noScreenshot": "Aucune capture n'a été jointe.",
"timeline": "Commentaires et activité",
"noTimeline": "Aucun commentaire ou activité pour le moment.",
"commentLabel": "Commentaire développeur",
"addComment": "Ajouter un commentaire",
"commenting": "Ajout du commentaire...",
"commentAdded": "Commentaire ajouté.",
"commentFailed": "Le commentaire n'a pas pu être ajouté.",
"reviewControls": "Contrôles de revue",
"saved": "Feedback mis à jour.",
"saveFailed": "Le feedback n'a pas pu être mis à jour.",
"reporter": "Rapporteur",
"activityLabel": "Activité",
"metadata": {
"title": "Métadonnées capturées",
"path": "Chemin soumis",
"userAgent": "Navigateur",
"viewport": "Fenêtre",
"appVersion": "Version de l'app",
"created": "Créé",
"lastActivity": "Dernière activité"
},
"context": {
"title": "Contexte",
"workspace": "Espace",
"client": "Client",
"project": "Campagne",
"contentItem": "Élément de contenu"
},
"activity": {
"updated": "Feedback mis à jour.",
"statusChanged": "Statut modifié de {from} à {to}.",
"typeChanged": "Type modifié de {from} à {to}.",
"tagsChanged": "Tags modifiés de {from} à {to}."
}
}
},
"mine": {
"eyebrow": "Mon feedback",
"title": "Mon feedback",
"description": "Suivez le feedback produit que vous avez soumis dans tous les espaces.",
"refresh": "Actualiser mon feedback",
"empty": "Aucun feedback ne correspond aux filtres actuels.",
"unread": "Activité de feedback non lue",
"metrics": {
"active": "Rapports actifs",
"unread": "Non lus",
"visible": "Visibles"
},
"errors": {
"loadFailed": "Votre feedback n'a pas pu être chargé.",
"detailFailed": "Ce rapport de feedback n'a pas pu être chargé."
},
"detail": {
"back": "Retour à mon feedback",
"eyebrow": "Détail de mon feedback",
"commentLabel": "Commentaire de suivi",
"commentAdded": "Commentaire ajouté.",
"commentFailed": "Le commentaire n'a pas pu être ajouté.",
"cancel": "Annuler le rapport",
"cancelPrompt": "Raison d'annulation optionnelle",
"cancelled": "Feedback annulé.",
"cancelFailed": "Le feedback n'a pas pu être annulé."
}
}
},
"sidebar": {

View File

@@ -14,6 +14,7 @@ import {
VIcon,
VProgressCircular,
VProgressLinear,
VSelect,
VSnackbar,
VTextarea,
VTextField,
@@ -42,6 +43,7 @@ const vuetify = createVuetify({
VProgressLinear,
VProgressCircular,
VIcon,
VSelect,
VTextField,
VSnackbar,
VForm,

View File

@@ -21,6 +21,10 @@ const WorkspaceSettingsView = () => import('@/features/workspaces/views/Workspac
const ReviewQueueView = () => import('@/features/reviews/views/ReviewQueueView.vue');
const ContentItemsView = () => import('@/features/content/views/ContentItemsView.vue');
const ContentItemDetailView = () => import('@/features/content/views/ContentItemDetailView.vue');
const MyFeedbackListView = () => import('@/features/feedback/views/MyFeedbackListView.vue');
const MyFeedbackDetailView = () => import('@/features/feedback/views/MyFeedbackDetailView.vue');
const DeveloperFeedbackListView = () => import('@/features/feedback/views/DeveloperFeedbackListView.vue');
const DeveloperFeedbackDetailView = () => import('@/features/feedback/views/DeveloperFeedbackDetailView.vue');
const routes = [
{
@@ -74,6 +78,30 @@ const routes = [
component: ReviewQueueView,
meta: { requiresAuth: true },
},
{
path: '/app/my-feedback',
name: 'my-feedback',
component: MyFeedbackListView,
meta: { requiresAuth: true },
},
{
path: '/app/my-feedback/:id',
name: 'my-feedback-detail',
component: MyFeedbackDetailView,
meta: { requiresAuth: true },
},
{
path: '/app/feedback',
name: 'developer-feedback',
component: DeveloperFeedbackListView,
meta: { requiresAuth: true, roles: ['Developer'] },
},
{
path: '/app/feedback/:id',
name: 'developer-feedback-detail',
component: DeveloperFeedbackDetailView,
meta: { requiresAuth: true, roles: ['Developer'] },
},
{
path: '/app/workspace-settings',
name: 'workspace-settings',

View File

@@ -1221,6 +1221,68 @@
}
}
},
"/api/my-feedback/{id}/screenshot": {
"post": {
"tags": [
"Feedback",
"Api"
],
"operationId": "SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotHandler",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"x-name": "AttachMyFeedbackScreenshotRequest",
"description": "",
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotRequest"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackReportDto"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
}
},
"security": [
{
"JWTBearerAuth": []
}
]
}
},
"/api/my-feedback/{id}/cancel": {
"post": {
"tags": [
@@ -1392,6 +1454,45 @@
]
}
},
"/api/feedback/{id}/screenshot": {
"get": {
"tags": [
"Feedback",
"Api"
],
"operationId": "SocializeApiModulesFeedbackHandlersGetFeedbackScreenshotHandler",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemIOStream"
}
}
}
},
"401": {
"description": "Unauthorized"
}
},
"security": [
{
"JWTBearerAuth": []
}
]
}
},
"/api/my-feedback/{id}": {
"get": {
"tags": [
@@ -3320,6 +3421,14 @@
"context": {
"$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackContextDto"
},
"screenshot": {
"nullable": true,
"oneOf": [
{
"$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackScreenshotDto"
}
]
},
"tags": {
"type": "array",
"items": {
@@ -3414,6 +3523,48 @@
}
}
},
"SocializeApiModulesFeedbackContractsFeedbackScreenshotDto": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"format": "guid"
},
"fileName": {
"type": "string"
},
"contentType": {
"type": "string"
},
"sizeBytes": {
"type": "integer",
"format": "int64"
},
"downloadPath": {
"type": "string"
},
"createdAt": {
"type": "string",
"format": "date-time"
}
}
},
"SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary",
"minLength": 1,
"nullable": false
}
}
},
"SocializeApiModulesFeedbackHandlersCancelMyFeedbackRequest": {
"type": "object",
"additionalProperties": false,