Compare commits
17 Commits
43bcf449fd
...
work-in-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 07458c1541 | |||
| a9bfdc460d | |||
| 258554f9d4 | |||
| 6731fb5d3a | |||
| 5aaddbca40 | |||
| 1263e28c00 | |||
| 4873f39192 | |||
| cb6948aa14 | |||
| f9960b4fc9 | |||
| 2e4c16621d | |||
| 60ce08ee86 | |||
| 0f3652c1a1 | |||
| 63738ad027 | |||
| 6177eec2bf | |||
| b51b8b4185 | |||
| d222e33667 | |||
| fcd80cd30f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ dist/
|
||||
*.local
|
||||
.env.local
|
||||
.env.*.local
|
||||
App_Data/
|
||||
|
||||
# Local SSL certificates
|
||||
*.pem
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# PROMPT TEMPLATES
|
||||
I need you to help me write a feature. First, we need to define it, so you will ask me questions one-by-one to make sure we have a shared understanding of the scope
|
||||
and expectating. - The feature we want is a way for your clients to report bugs/suggestions/requests from within our app. It should not be intrusive. It should allow
|
||||
them to take a screen capture, put annotation, describe their request and/or issue. Then, as a dev, i will want to collect and review them.
|
||||
|
||||
## Purpose
|
||||
This document standardizes how we interact with AI coding agents (Codex, Claude, etc).
|
||||
|
||||
@@ -5,6 +5,7 @@ using Socialize.Api.Modules.Assets.Data;
|
||||
using Socialize.Api.Modules.Clients.Data;
|
||||
using Socialize.Api.Modules.Comments.Data;
|
||||
using Socialize.Api.Modules.ContentItems.Data;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Identity.Data;
|
||||
using Socialize.Api.Modules.Notifications.Data;
|
||||
using Socialize.Api.Modules.Projects.Data;
|
||||
@@ -28,18 +29,24 @@ public class AppDbContext(
|
||||
public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
|
||||
public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>();
|
||||
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 modelBuilder)
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
modelBuilder.ConfigureWorkspacesModule();
|
||||
modelBuilder.ConfigureClientsModule();
|
||||
modelBuilder.ConfigureProjectsModule();
|
||||
modelBuilder.ConfigureContentItemsModule();
|
||||
modelBuilder.ConfigureAssetsModule();
|
||||
modelBuilder.ConfigureCommentsModule();
|
||||
modelBuilder.ConfigureApprovalsModule();
|
||||
modelBuilder.ConfigureNotificationsModule();
|
||||
builder.ConfigureWorkspacesModule();
|
||||
builder.ConfigureClientsModule();
|
||||
builder.ConfigureProjectsModule();
|
||||
builder.ConfigureContentItemsModule();
|
||||
builder.ConfigureAssetsModule();
|
||||
builder.ConfigureCommentsModule();
|
||||
builder.ConfigureApprovalsModule();
|
||||
builder.ConfigureNotificationsModule();
|
||||
builder.ConfigureFeedbackModule();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ public static class DependencyInjection
|
||||
{
|
||||
using IServiceScope scope = app.ApplicationServices.CreateScope();
|
||||
await using AppDbContext context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await context.Database.EnsureCreatedAsync(cancellationToken);
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||
|
||||
public sealed class LocalBlobStorageOptions
|
||||
{
|
||||
public const string SectionName = "LocalBlobStorage";
|
||||
|
||||
public string RootPath { get; set; } = "App_Data/blob-storage";
|
||||
|
||||
public string RequestPath { get; set; } = "/api/storage";
|
||||
}
|
||||
@@ -4,5 +4,7 @@ internal static class ContainerNames
|
||||
{
|
||||
public const string Users = "users";
|
||||
public const string Clients = "clients";
|
||||
public const string Workspaces = "workspaces";
|
||||
public const string Creators = "creators";
|
||||
public const string Feedback = "feedback";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||
|
||||
public class AzureBlobStorage : IBlobStorage
|
||||
{
|
||||
private const long MaxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes
|
||||
|
||||
private readonly BlobServiceClient _blobServiceClient;
|
||||
private readonly ILogger<AzureBlobStorage> _logger;
|
||||
|
||||
public AzureBlobStorage(IConfiguration configuration, ILogger<AzureBlobStorage> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
string? connectionString = configuration.GetConnectionString("AzureBlob");
|
||||
_blobServiceClient = new BlobServiceClient(connectionString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upload a file to microsoft azure blob storage.
|
||||
/// </summary>
|
||||
/// <param name="containerName">The name of the container where the file is stored.</param>
|
||||
/// <param name="blobName">The blob name (path within the container, include the file name).</param>
|
||||
/// <param name="stream"></param>
|
||||
/// <param name="contentType">The content type.</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <returns></returns>
|
||||
public async Task<string> UploadFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
Stream stream,
|
||||
string contentType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Read the file stream into a memory stream to determine the length
|
||||
// WATCH FOR MEMORY USAGE USING THE MEMORY STREAM.
|
||||
stream.Position = 0;
|
||||
|
||||
// Check if the file size exceeds the maximum upload size
|
||||
if (stream.Length > MaxUploadSize)
|
||||
{
|
||||
_logger.LogError(
|
||||
$"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
||||
throw new InvalidOperationException(
|
||||
$"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
||||
}
|
||||
|
||||
// Validate content type
|
||||
if (!ContentTypes.IsAllowed(contentType, stream))
|
||||
{
|
||||
_logger.LogError(
|
||||
$"Blob storage: Unsupported file type {contentType}.");
|
||||
throw new InvalidOperationException("Unsupported file type.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get a reference to a container
|
||||
BlobContainerClient? containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
|
||||
|
||||
// Create the container if it does not exist
|
||||
await containerClient.CreateIfNotExistsAsync(
|
||||
PublicAccessType.Blob,
|
||||
cancellationToken: ct);
|
||||
|
||||
// Get a reference to a blob
|
||||
BlobClient? blobClient = containerClient.GetBlobClient(blobName);
|
||||
|
||||
// Define the BlobHttpHeaders to include the content type
|
||||
BlobHttpHeaders blobHttpHeaders = new() { ContentType = contentType };
|
||||
|
||||
// Upload the file
|
||||
Response<BlobContentInfo>? response = await blobClient.UploadAsync(
|
||||
stream,
|
||||
new BlobUploadOptions { HttpHeaders = blobHttpHeaders },
|
||||
ct);
|
||||
|
||||
string fileUri = blobClient.Uri.ToString();
|
||||
|
||||
_logger.LogInformation(
|
||||
"""
|
||||
Blob storage: Status [ {ResponseStatus} ]
|
||||
Uploaded [ {BlobName} ] to the container [ {ContainerName} ]
|
||||
with contentType [ {ContentType} ]
|
||||
with a length of [ {StreamLength} bytes ]
|
||||
with the uri [ {FileUri} ]
|
||||
""",
|
||||
response.GetRawResponse().Status.ToString(),
|
||||
blobName,
|
||||
containerName,
|
||||
contentType,
|
||||
stream.Length,
|
||||
fileUri
|
||||
);
|
||||
|
||||
// Return the URI of the uploaded blob
|
||||
return fileUri;
|
||||
}
|
||||
catch (RequestFailedException ex)
|
||||
{
|
||||
_logger.LogError($"Blob storage: Azure Storage request failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"Blob storage: An error occurred: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download a file to microsoft's azure blob storage.
|
||||
/// </summary>
|
||||
/// <param name="blobName">The blob name (path within the container).</param>
|
||||
/// <param name="containerName">The name of the container where the file is stored. (users)</param>
|
||||
/// <param name="ct">The cancellation token for the request</param>
|
||||
/// <returns></returns>
|
||||
public async Task<MemoryStream> DownloadFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get a reference to a container
|
||||
BlobContainerClient? containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
|
||||
|
||||
// Get a reference to a blob
|
||||
BlobClient? blobClient = containerClient.GetBlobClient(blobName);
|
||||
|
||||
// Download the blob to a stream
|
||||
BlobDownloadInfo download = await blobClient.DownloadAsync(ct);
|
||||
|
||||
MemoryStream memoryStream = new();
|
||||
await download.Content.CopyToAsync(memoryStream, ct);
|
||||
memoryStream.Position = 0; // Ensure the stream is at the beginning
|
||||
|
||||
return memoryStream;
|
||||
}
|
||||
catch (RequestFailedException ex)
|
||||
{
|
||||
_logger.LogError($"Azure Storage request failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"An error occurred: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||
|
||||
public sealed class LocalBlobStorage(
|
||||
IWebHostEnvironment environment,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IOptions<LocalBlobStorageOptions> options,
|
||||
ILogger<LocalBlobStorage> logger)
|
||||
: IBlobStorage
|
||||
{
|
||||
private const long MaxUploadSize = 10 * 1024 * 1024;
|
||||
private const string ContentTypeMetadataSuffix = ".content-type";
|
||||
|
||||
private readonly LocalBlobStorageOptions _options = options.Value;
|
||||
|
||||
public async Task<string> UploadFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
Stream stream,
|
||||
string contentType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
stream.Position = 0;
|
||||
|
||||
if (stream.Length > MaxUploadSize)
|
||||
{
|
||||
logger.LogError("Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.", MaxUploadSize);
|
||||
throw new InvalidOperationException($"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
||||
}
|
||||
|
||||
if (!ContentTypes.IsAllowed(contentType, stream))
|
||||
{
|
||||
logger.LogError("Blob storage: Unsupported file type {ContentType}.", contentType);
|
||||
throw new InvalidOperationException("Unsupported file type.");
|
||||
}
|
||||
|
||||
string relativePath = GetSafeRelativePath(containerName, blobName);
|
||||
string filePath = Path.Combine(GetRootPath(), relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? GetRootPath());
|
||||
|
||||
await using FileStream fileStream = File.Create(filePath);
|
||||
await stream.CopyToAsync(fileStream, ct);
|
||||
await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct);
|
||||
|
||||
string fileUri = BuildPublicUrl(relativePath);
|
||||
logger.LogInformation(
|
||||
"Blob storage: Uploaded [{BlobName}] to local container [{ContainerName}] with contentType [{ContentType}] and uri [{FileUri}]",
|
||||
blobName,
|
||||
containerName,
|
||||
contentType,
|
||||
fileUri);
|
||||
|
||||
return fileUri;
|
||||
}
|
||||
|
||||
public async Task<MemoryStream> DownloadFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName));
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException("Blob storage: Local file was not found.", blobName);
|
||||
}
|
||||
|
||||
MemoryStream memoryStream = new();
|
||||
await using FileStream fileStream = File.OpenRead(filePath);
|
||||
await fileStream.CopyToAsync(memoryStream, ct);
|
||||
memoryStream.Position = 0;
|
||||
|
||||
return memoryStream;
|
||||
}
|
||||
|
||||
internal string GetRootPath()
|
||||
{
|
||||
if (Path.IsPathRooted(_options.RootPath))
|
||||
{
|
||||
return Path.GetFullPath(_options.RootPath);
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(environment.ContentRootPath, _options.RootPath));
|
||||
}
|
||||
|
||||
internal static string? ReadContentType(string filePath)
|
||||
{
|
||||
string metadataPath = GetContentTypeMetadataPath(filePath);
|
||||
return File.Exists(metadataPath)
|
||||
? File.ReadAllText(metadataPath)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string GetContentTypeMetadataPath(string filePath)
|
||||
{
|
||||
return $"{filePath}{ContentTypeMetadataSuffix}";
|
||||
}
|
||||
|
||||
private static string GetSafeRelativePath(string containerName, string blobName)
|
||||
{
|
||||
if (Path.IsPathRooted(containerName) || Path.IsPathRooted(blobName))
|
||||
{
|
||||
throw new InvalidOperationException("Blob storage: Blob paths must be relative.");
|
||||
}
|
||||
|
||||
string[] pathParts = [containerName, .. blobName.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar])];
|
||||
if (pathParts.Any(part => part is "" or "." or ".."))
|
||||
{
|
||||
throw new InvalidOperationException("Blob storage: Blob paths must not contain relative path segments.");
|
||||
}
|
||||
|
||||
return Path.Combine(pathParts);
|
||||
}
|
||||
|
||||
private string BuildPublicUrl(string relativePath)
|
||||
{
|
||||
HttpRequest? request = httpContextAccessor.HttpContext?.Request;
|
||||
string requestPath = NormalizeRequestPath(_options.RequestPath);
|
||||
string urlPath = $"{requestPath}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return urlPath;
|
||||
}
|
||||
|
||||
return $"{request.Scheme}://{request.Host}{request.PathBase}{urlPath}";
|
||||
}
|
||||
|
||||
internal static string NormalizeRequestPath(string requestPath)
|
||||
{
|
||||
string normalized = string.IsNullOrWhiteSpace(requestPath)
|
||||
? "/api/storage"
|
||||
: requestPath.Trim();
|
||||
|
||||
return normalized.StartsWith("/", StringComparison.Ordinal)
|
||||
? normalized.TrimEnd('/')
|
||||
: $"/{normalized.TrimEnd('/')}";
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||
using Socialize.Api.Infrastructure.Configuration;
|
||||
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||
@@ -16,7 +17,10 @@ public static class DependencyInjection
|
||||
builder.Services.Configure<WebsiteOptions>(
|
||||
builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName));
|
||||
|
||||
builder.Services.AddTransient<IBlobStorage, AzureBlobStorage>();
|
||||
builder.Services.Configure<LocalBlobStorageOptions>(
|
||||
builder.Configuration.GetSection(LocalBlobStorageOptions.SectionName));
|
||||
builder.Services.AddTransient<LocalBlobStorage>();
|
||||
builder.Services.AddTransient<IBlobStorage>(services => services.GetRequiredService<LocalBlobStorage>());
|
||||
builder.Services.Configure<StripeOptions>(
|
||||
builder.Configuration.GetSection(StripeOptions.ConfigurationSection));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Socialize.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260430054500_AddWorkspaceLogo")]
|
||||
public partial class AddWorkspaceLogo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LogoUrl",
|
||||
table: "Workspaces",
|
||||
type: "character varying(2048)",
|
||||
maxLength: 2048,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LogoUrl",
|
||||
table: "Workspaces");
|
||||
}
|
||||
}
|
||||
}
|
||||
1105
backend/src/Socialize.Api/Migrations/20260430072517_AddFeedbackFoundation.Designer.cs
generated
Normal file
1105
backend/src/Socialize.Api/Migrations/20260430072517_AddFeedbackFoundation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFeedbackFoundation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FeedbackReports",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: false),
|
||||
ReporterUserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ReporterDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
ReporterEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
SubmittedPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||
BrowserUserAgent = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||
ViewportWidth = table.Column<int>(type: "integer", nullable: true),
|
||||
ViewportHeight = table.Column<int>(type: "integer", nullable: true),
|
||||
AppVersion = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
WorkspaceName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
ClientId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
ClientName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
ProjectId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
ProjectName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
ContentItemId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
ContentItemTitle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
LastActivityAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
CancelledAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
CancelledByUserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CancellationReason = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FeedbackReports", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FeedbackTags",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
NormalizedName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FeedbackTags", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FeedbackTags_FeedbackReports_FeedbackReportId",
|
||||
column: x => x.FeedbackReportId,
|
||||
principalTable: "FeedbackReports",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackReports_LastActivityAt",
|
||||
table: "FeedbackReports",
|
||||
column: "LastActivityAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackReports_ReporterUserId",
|
||||
table: "FeedbackReports",
|
||||
column: "ReporterUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackReports_Status",
|
||||
table: "FeedbackReports",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackReports_Type",
|
||||
table: "FeedbackReports",
|
||||
column: "Type");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackReports_WorkspaceId",
|
||||
table: "FeedbackReports",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackTags_FeedbackReportId_NormalizedName",
|
||||
table: "FeedbackTags",
|
||||
columns: new[] { "FeedbackReportId", "NormalizedName" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackTags_NormalizedName",
|
||||
table: "FeedbackTags",
|
||||
column: "NormalizedName");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "FeedbackTags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "FeedbackReports");
|
||||
}
|
||||
}
|
||||
}
|
||||
1163
backend/src/Socialize.Api/Migrations/20260430171123_AddFeedbackScreenshots.Designer.cs
generated
Normal file
1163
backend/src/Socialize.Api/Migrations/20260430171123_AddFeedbackScreenshots.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1292
backend/src/Socialize.Api/Migrations/20260430171959_AddFeedbackCommentsActivity.Designer.cs
generated
Normal file
1292
backend/src/Socialize.Api/Migrations/20260430171959_AddFeedbackCommentsActivity.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFeedbackCommentsActivity : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FeedbackActivityEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ActorUserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ActorDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
ActorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
ActivityType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
FromValue = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||
ToValue = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||
Note = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FeedbackActivityEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FeedbackActivityEntries_FeedbackReports_FeedbackReportId",
|
||||
column: x => x.FeedbackReportId,
|
||||
principalTable: "FeedbackReports",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FeedbackComments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AuthorUserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AuthorDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
AuthorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
AuthorRole = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Body = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FeedbackComments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FeedbackComments_FeedbackReports_FeedbackReportId",
|
||||
column: x => x.FeedbackReportId,
|
||||
principalTable: "FeedbackReports",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackActivityEntries_ActorUserId",
|
||||
table: "FeedbackActivityEntries",
|
||||
column: "ActorUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackActivityEntries_CreatedAt",
|
||||
table: "FeedbackActivityEntries",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackActivityEntries_FeedbackReportId",
|
||||
table: "FeedbackActivityEntries",
|
||||
column: "FeedbackReportId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackComments_AuthorUserId",
|
||||
table: "FeedbackComments",
|
||||
column: "AuthorUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackComments_CreatedAt",
|
||||
table: "FeedbackComments",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackComments_FeedbackReportId",
|
||||
table: "FeedbackComments",
|
||||
column: "FeedbackReportId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "FeedbackActivityEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "FeedbackComments");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalDecision", b =>
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalDecision", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -168,7 +168,7 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("ApprovalDecisions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalRequest", b =>
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -230,7 +230,7 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("ApprovalRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Assets.Data.Asset", b =>
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -286,7 +286,7 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("Assets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Assets.Data.AssetRevision", b =>
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.AssetRevision", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -329,7 +329,7 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("AssetRevisions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Clients.Data.Client", b =>
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -379,7 +379,7 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("Clients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Comments.Data.Comment", b =>
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Comments.Data.Comment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -434,7 +434,7 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("Comments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItem", b =>
|
||||
modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -500,7 +500,7 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("ContentItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItemRevision", b =>
|
||||
modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -558,7 +558,298 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("ContentItemRevisions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Identity.Data.Role", b =>
|
||||
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")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AppVersion")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("BrowserUserAgent")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<string>("CancellationReason")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<DateTimeOffset?>("CancelledAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("CancelledByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ClientId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ClientName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid?>("ContentItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ContentItemTitle")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8000)
|
||||
.HasColumnType("character varying(8000)");
|
||||
|
||||
b.Property<DateTimeOffset>("LastActivityAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ProjectName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("ReporterDisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("ReporterEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid>("ReporterUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("SubmittedPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int?>("ViewportHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("ViewportWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("WorkspaceName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LastActivityAt");
|
||||
|
||||
b.HasIndex("ReporterUserId");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("Type");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
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")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("FeedbackReportId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName");
|
||||
|
||||
b.HasIndex("FeedbackReportId", "NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("FeedbackTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.Role", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -585,7 +876,7 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Identity.Data.User", b =>
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -688,7 +979,7 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Notifications.Data.NotificationEvent", b =>
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Notifications.Data.NotificationEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -750,7 +1041,7 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("NotificationEvents", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Projects.Data.Project", b =>
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Projects.Data.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -803,7 +1094,7 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("Projects", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.Workspace", b =>
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -814,6 +1105,10 @@ namespace Socialize.Api.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("LogoUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
@@ -842,7 +1137,7 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("Workspaces", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.WorkspaceInvite", b =>
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.WorkspaceInvite", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -885,7 +1180,7 @@ namespace Socialize.Api.Migrations
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
|
||||
b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
@@ -894,7 +1189,7 @@ namespace Socialize.Api.Migrations
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Modules.Identity.Data.User", null)
|
||||
b.HasOne("Socialize.Api.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
@@ -903,7 +1198,7 @@ namespace Socialize.Api.Migrations
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Modules.Identity.Data.User", null)
|
||||
b.HasOne("Socialize.Api.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
@@ -912,13 +1207,13 @@ namespace Socialize.Api.Migrations
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
|
||||
b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Socialize.Modules.Identity.Data.User", null)
|
||||
b.HasOne("Socialize.Api.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
@@ -927,12 +1222,67 @@ namespace Socialize.Api.Migrations
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Modules.Identity.Data.User", null)
|
||||
b.HasOne("Socialize.Api.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.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")
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("FeedbackReportId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("FeedbackReport");
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Contracts;
|
||||
|
||||
public record FeedbackContextDto(
|
||||
Guid? WorkspaceId,
|
||||
string? WorkspaceName,
|
||||
Guid? ClientId,
|
||||
string? ClientName,
|
||||
Guid? ProjectId,
|
||||
string? ProjectName,
|
||||
Guid? ContentItemId,
|
||||
string? ContentItemTitle);
|
||||
|
||||
public record FeedbackMetadataDto(
|
||||
string SubmittedPath,
|
||||
string? BrowserUserAgent,
|
||||
int? ViewportWidth,
|
||||
int? ViewportHeight,
|
||||
string? AppVersion);
|
||||
|
||||
public record FeedbackScreenshotDto(
|
||||
Guid Id,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long SizeBytes,
|
||||
string DownloadPath,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public record FeedbackReportDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
string Status,
|
||||
string Description,
|
||||
Guid ReporterUserId,
|
||||
string ReporterDisplayName,
|
||||
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)
|
||||
{
|
||||
return new FeedbackReportDto(
|
||||
report.Id,
|
||||
ToDisplayString(report.Type),
|
||||
ToDisplayString(report.Status),
|
||||
report.Description,
|
||||
report.ReporterUserId,
|
||||
report.ReporterDisplayName,
|
||||
report.ReporterEmail,
|
||||
new FeedbackMetadataDto(
|
||||
report.SubmittedPath,
|
||||
report.BrowserUserAgent,
|
||||
report.ViewportWidth,
|
||||
report.ViewportHeight,
|
||||
report.AppVersion),
|
||||
new FeedbackContextDto(
|
||||
report.WorkspaceId,
|
||||
report.WorkspaceName,
|
||||
report.ClientId,
|
||||
report.ClientName,
|
||||
report.ProjectId,
|
||||
report.ProjectName,
|
||||
report.ContentItemId,
|
||||
report.ContentItemTitle),
|
||||
report.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,
|
||||
report.CancellationReason);
|
||||
}
|
||||
|
||||
private static string ToDisplayString(FeedbackType type)
|
||||
{
|
||||
return type.ToString();
|
||||
}
|
||||
|
||||
private static string ToDisplayString(FeedbackStatus status)
|
||||
{
|
||||
return status == FeedbackStatus.WontDo ? "Won't Do" : status.ToString();
|
||||
}
|
||||
|
||||
public static FeedbackTimelineItemDto ToTimelineDto(this FeedbackComment comment)
|
||||
{
|
||||
return new FeedbackTimelineItemDto(
|
||||
comment.Id,
|
||||
"Comment",
|
||||
comment.AuthorUserId,
|
||||
comment.AuthorDisplayName,
|
||||
comment.AuthorEmail,
|
||||
comment.AuthorRole,
|
||||
comment.Body,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
comment.CreatedAt);
|
||||
}
|
||||
|
||||
public static FeedbackTimelineItemDto ToTimelineDto(this FeedbackActivityEntry activity)
|
||||
{
|
||||
return new FeedbackTimelineItemDto(
|
||||
activity.Id,
|
||||
"Activity",
|
||||
activity.ActorUserId,
|
||||
activity.ActorDisplayName,
|
||||
activity.ActorEmail,
|
||||
null,
|
||||
null,
|
||||
activity.ActivityType,
|
||||
activity.FromValue,
|
||||
activity.ToValue,
|
||||
activity.Note,
|
||||
activity.CreatedAt);
|
||||
}
|
||||
|
||||
public static string ToFeedbackDisplayString(this FeedbackType type)
|
||||
{
|
||||
return ToDisplayString(type);
|
||||
}
|
||||
|
||||
public static string ToFeedbackDisplayString(this FeedbackStatus status)
|
||||
{
|
||||
return ToDisplayString(status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
public class FeedbackActivityEntry
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid FeedbackReportId { get; set; }
|
||||
public Guid ActorUserId { get; set; }
|
||||
public string ActorDisplayName { get; set; } = string.Empty;
|
||||
public string ActorEmail { get; set; } = string.Empty;
|
||||
public string ActivityType { get; set; } = string.Empty;
|
||||
public string? FromValue { get; set; }
|
||||
public string? ToValue { get; set; }
|
||||
public string? Note { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public FeedbackReport? FeedbackReport { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
public class FeedbackComment
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid FeedbackReportId { get; set; }
|
||||
public Guid AuthorUserId { get; set; }
|
||||
public string AuthorDisplayName { get; set; } = string.Empty;
|
||||
public string AuthorEmail { get; set; } = string.Empty;
|
||||
public string AuthorRole { get; set; } = string.Empty;
|
||||
public string Body { get; set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public FeedbackReport? FeedbackReport { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
public static class FeedbackModelConfiguration
|
||||
{
|
||||
public static ModelBuilder ConfigureFeedbackModule(this ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<FeedbackReport>(feedback =>
|
||||
{
|
||||
feedback.ToTable("FeedbackReports");
|
||||
feedback.HasKey(x => x.Id);
|
||||
feedback.Property(x => x.Type).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||
feedback.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||
feedback.Property(x => x.Description).HasMaxLength(8000).IsRequired();
|
||||
feedback.Property(x => x.ReporterDisplayName).HasMaxLength(256).IsRequired();
|
||||
feedback.Property(x => x.ReporterEmail).HasMaxLength(256).IsRequired();
|
||||
feedback.Property(x => x.SubmittedPath).HasMaxLength(2048).IsRequired();
|
||||
feedback.Property(x => x.BrowserUserAgent).HasMaxLength(1024);
|
||||
feedback.Property(x => x.AppVersion).HasMaxLength(128);
|
||||
feedback.Property(x => x.WorkspaceName).HasMaxLength(256);
|
||||
feedback.Property(x => x.ClientName).HasMaxLength(256);
|
||||
feedback.Property(x => x.ProjectName).HasMaxLength(256);
|
||||
feedback.Property(x => x.ContentItemTitle).HasMaxLength(256);
|
||||
feedback.Property(x => x.CancellationReason).HasMaxLength(2000);
|
||||
feedback.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
feedback.HasIndex(x => x.ReporterUserId);
|
||||
feedback.HasIndex(x => x.Status);
|
||||
feedback.HasIndex(x => x.Type);
|
||||
feedback.HasIndex(x => x.WorkspaceId);
|
||||
feedback.HasIndex(x => x.LastActivityAt);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<FeedbackTag>(tag =>
|
||||
{
|
||||
tag.ToTable("FeedbackTags");
|
||||
tag.HasKey(x => x.Id);
|
||||
tag.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
||||
tag.Property(x => x.NormalizedName).HasMaxLength(64).IsRequired();
|
||||
tag.HasIndex(x => x.NormalizedName);
|
||||
tag.HasIndex(x => new { x.FeedbackReportId, x.NormalizedName }).IsUnique();
|
||||
tag.HasOne(x => x.FeedbackReport)
|
||||
.WithMany(x => x.Tags)
|
||||
.HasForeignKey(x => x.FeedbackReportId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
public class FeedbackReport
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public FeedbackType Type { get; set; }
|
||||
public FeedbackStatus Status { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public Guid ReporterUserId { get; set; }
|
||||
public string ReporterDisplayName { get; set; } = string.Empty;
|
||||
public string ReporterEmail { get; set; } = string.Empty;
|
||||
public string SubmittedPath { get; set; } = string.Empty;
|
||||
public string? BrowserUserAgent { get; set; }
|
||||
public int? ViewportWidth { get; set; }
|
||||
public int? ViewportHeight { get; set; }
|
||||
public string? AppVersion { get; set; }
|
||||
public Guid? WorkspaceId { get; set; }
|
||||
public string? WorkspaceName { get; set; }
|
||||
public Guid? ClientId { get; set; }
|
||||
public string? ClientName { get; set; }
|
||||
public Guid? ProjectId { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public Guid? ContentItemId { get; set; }
|
||||
public string? ContentItemTitle { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset LastActivityAt { get; set; }
|
||||
public DateTimeOffset? CancelledAt { get; set; }
|
||||
public Guid? CancelledByUserId { get; set; }
|
||||
public string? CancellationReason { get; set; }
|
||||
public ICollection<FeedbackTag> Tags { get; } = new List<FeedbackTag>();
|
||||
public ICollection<FeedbackComment> Comments { get; } = new List<FeedbackComment>();
|
||||
public ICollection<FeedbackActivityEntry> ActivityEntries { get; } = new List<FeedbackActivityEntry>();
|
||||
public FeedbackScreenshot? Screenshot { get; set; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
public enum FeedbackStatus
|
||||
{
|
||||
New,
|
||||
Planned,
|
||||
Resolved,
|
||||
WontDo,
|
||||
Cancelled,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
public class FeedbackTag
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid FeedbackReportId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string NormalizedName { get; set; } = string.Empty;
|
||||
public FeedbackReport? FeedbackReport { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
public enum FeedbackType
|
||||
{
|
||||
Bug,
|
||||
Suggestion,
|
||||
Request,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Socialize.Api.Modules.Feedback;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddFeedbackModule(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddScoped<Services.FeedbackNotificationService>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Feedback.Services;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public class AddDeveloperFeedbackCommentHandler(
|
||||
AppDbContext dbContext,
|
||||
FeedbackNotificationService notificationService)
|
||||
: Endpoint<AddFeedbackCommentRequest, FeedbackTimelineItemDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/feedback/{id}/comments");
|
||||
Roles(KnownRoles.Developer);
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(AddFeedbackCommentRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
FeedbackReport? report = await dbContext.FeedbackReports.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
|
||||
if (report is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Guid developerUserId = User.GetUserId();
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
FeedbackComment comment = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FeedbackReportId = report.Id,
|
||||
AuthorUserId = developerUserId,
|
||||
AuthorDisplayName = User.GetAlias() ?? User.GetName(),
|
||||
AuthorEmail = User.GetEmail(),
|
||||
AuthorRole = "Developer",
|
||||
Body = request.Body.Trim(),
|
||||
CreatedAt = now,
|
||||
};
|
||||
|
||||
report.LastActivityAt = now;
|
||||
dbContext.FeedbackComments.Add(comment);
|
||||
notificationService.AddDeveloperCommentNotification(report, developerUserId);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendAsync(comment.ToTimelineDto(), StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Feedback.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public record AddFeedbackCommentRequest(string Body);
|
||||
|
||||
public class AddFeedbackCommentRequestValidator
|
||||
: Validator<AddFeedbackCommentRequest>
|
||||
{
|
||||
public AddFeedbackCommentRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Body).NotEmpty().MaximumLength(8000);
|
||||
}
|
||||
}
|
||||
|
||||
public class AddMyFeedbackCommentHandler(
|
||||
AppDbContext dbContext,
|
||||
FeedbackNotificationService notificationService)
|
||||
: Endpoint<AddFeedbackCommentRequest, FeedbackTimelineItemDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/my-feedback/{id}/comments");
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(AddFeedbackCommentRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
Guid reporterUserId = User.GetUserId();
|
||||
|
||||
FeedbackReport? report = await dbContext.FeedbackReports.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
|
||||
if (report is null || !FeedbackAccessRules.CanReporterComment(report, reporterUserId))
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
FeedbackComment comment = CreateComment(report.Id, reporterUserId, "Reporter", request.Body.Trim(), now);
|
||||
report.LastActivityAt = now;
|
||||
|
||||
dbContext.FeedbackComments.Add(comment);
|
||||
await notificationService.AddReporterCommentNotificationsAsync(report, reporterUserId, ct);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendAsync(comment.ToTimelineDto(), StatusCodes.Status201Created, ct);
|
||||
}
|
||||
|
||||
private FeedbackComment CreateComment(Guid reportId, Guid userId, string authorRole, string body, DateTimeOffset now)
|
||||
{
|
||||
return new FeedbackComment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FeedbackReportId = reportId,
|
||||
AuthorUserId = userId,
|
||||
AuthorDisplayName = User.GetAlias() ?? User.GetName(),
|
||||
AuthorEmail = User.GetEmail(),
|
||||
AuthorRole = authorRole,
|
||||
Body = body,
|
||||
CreatedAt = now,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Feedback.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public record CancelMyFeedbackRequest(string? Reason);
|
||||
|
||||
public class CancelMyFeedbackRequestValidator
|
||||
: Validator<CancelMyFeedbackRequest>
|
||||
{
|
||||
public CancelMyFeedbackRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Reason).MaximumLength(2000);
|
||||
}
|
||||
}
|
||||
|
||||
public class CancelMyFeedbackHandler(AppDbContext dbContext)
|
||||
: Endpoint<CancelMyFeedbackRequest, FeedbackReportDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/my-feedback/{id}/cancel");
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancelMyFeedbackRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
Guid reporterUserId = User.GetUserId();
|
||||
|
||||
FeedbackReport? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Tags)
|
||||
.Include(candidate => candidate.Screenshot)
|
||||
.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId,
|
||||
ct);
|
||||
|
||||
if (report is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!FeedbackAccessRules.CanReporterCancel(report, reporterUserId))
|
||||
{
|
||||
AddError("The feedback report cannot be cancelled.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
|
||||
public class GetDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<FeedbackReportDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/feedback/{id}");
|
||||
Roles(KnownRoles.Developer);
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
FeedbackReport? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Tags)
|
||||
.Include(candidate => candidate.Screenshot)
|
||||
.Include(candidate => candidate.Comments)
|
||||
.Include(candidate => candidate.ActivityEntries)
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
|
||||
if (report is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(report.ToDto(), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public class GetDeveloperFeedbackTimelineHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<IReadOnlyCollection<FeedbackTimelineItemDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/feedback/{id}/timeline");
|
||||
Roles(KnownRoles.Developer);
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
bool exists = await dbContext.FeedbackReports.AnyAsync(candidate => candidate.Id == id, ct);
|
||||
if (!exists)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyCollection<FeedbackTimelineItemDto> timeline =
|
||||
await GetMyFeedbackTimelineHandler.LoadTimelineAsync(dbContext, id, ct);
|
||||
|
||||
await SendOkAsync(timeline, ct);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public class GetMyFeedbackHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<FeedbackReportDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/my-feedback/{id}");
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
Guid reporterUserId = User.GetUserId();
|
||||
|
||||
FeedbackReport? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Tags)
|
||||
.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)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(report.ToDto(), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public class GetMyFeedbackTimelineHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<IReadOnlyCollection<FeedbackTimelineItemDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/my-feedback/{id}/timeline");
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
Guid reporterUserId = User.GetUserId();
|
||||
|
||||
bool canAccess = await dbContext.FeedbackReports
|
||||
.AnyAsync(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId, ct);
|
||||
|
||||
if (!canAccess)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(await LoadTimelineAsync(id, ct), ct);
|
||||
}
|
||||
|
||||
internal static async Task<IReadOnlyCollection<FeedbackTimelineItemDto>> LoadTimelineAsync(
|
||||
AppDbContext dbContext,
|
||||
Guid feedbackReportId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
List<FeedbackTimelineItemDto> comments = await dbContext.FeedbackComments
|
||||
.Where(comment => comment.FeedbackReportId == feedbackReportId)
|
||||
.Select(comment => comment.ToTimelineDto())
|
||||
.ToListAsync(ct);
|
||||
|
||||
List<FeedbackTimelineItemDto> activity = await dbContext.FeedbackActivityEntries
|
||||
.Where(entry => entry.FeedbackReportId == feedbackReportId)
|
||||
.Select(entry => entry.ToTimelineDto())
|
||||
.ToListAsync(ct);
|
||||
|
||||
return comments
|
||||
.Concat(activity)
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ThenBy(item => item.Kind)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private Task<IReadOnlyCollection<FeedbackTimelineItemDto>> LoadTimelineAsync(Guid feedbackReportId, CancellationToken ct)
|
||||
{
|
||||
return LoadTimelineAsync(dbContext, feedbackReportId, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public class ListDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<IReadOnlyCollection<FeedbackReportDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/feedback");
|
||||
Roles(KnownRoles.Developer);
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
|
||||
.Include(report => report.Tags)
|
||||
.Include(report => report.Screenshot)
|
||||
.OrderByDescending(report => report.LastActivityAt)
|
||||
.Select(report => report.ToDto())
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(reports, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public class ListFeedbackTagsHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<IReadOnlyCollection<string>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/feedback/tags");
|
||||
Roles(KnownRoles.Developer);
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
List<string> tags = await dbContext.FeedbackTags
|
||||
.GroupBy(tag => new { tag.NormalizedName, tag.Name })
|
||||
.OrderBy(group => group.Key.Name)
|
||||
.Select(group => group.Key.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(tags, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public class ListMyFeedbackHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<IReadOnlyCollection<FeedbackReportDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/my-feedback");
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid reporterUserId = User.GetUserId();
|
||||
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
|
||||
.Include(report => report.Tags)
|
||||
.Include(report => report.Screenshot)
|
||||
.Where(report => report.ReporterUserId == reporterUserId)
|
||||
.OrderByDescending(report => report.LastActivityAt)
|
||||
.Select(report => report.ToDto())
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(reports, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using FastEndpoints;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Feedback.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||
|
||||
public record SubmitFeedbackRequest(
|
||||
string Type,
|
||||
string Description,
|
||||
string SubmittedPath,
|
||||
string? BrowserUserAgent,
|
||||
int? ViewportWidth,
|
||||
int? ViewportHeight,
|
||||
string? AppVersion,
|
||||
Guid? WorkspaceId,
|
||||
string? WorkspaceName,
|
||||
Guid? ClientId,
|
||||
string? ClientName,
|
||||
Guid? ProjectId,
|
||||
string? ProjectName,
|
||||
Guid? ContentItemId,
|
||||
string? ContentItemTitle);
|
||||
|
||||
public class SubmitFeedbackRequestValidator
|
||||
: Validator<SubmitFeedbackRequest>
|
||||
{
|
||||
public SubmitFeedbackRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Type).NotEmpty().MaximumLength(32);
|
||||
RuleFor(x => x.Description).NotEmpty().MaximumLength(8000);
|
||||
RuleFor(x => x.SubmittedPath).NotEmpty().MaximumLength(2048);
|
||||
RuleFor(x => x.BrowserUserAgent).MaximumLength(1024);
|
||||
RuleFor(x => x.AppVersion).MaximumLength(128);
|
||||
RuleFor(x => x.WorkspaceName).MaximumLength(256);
|
||||
RuleFor(x => x.ClientName).MaximumLength(256);
|
||||
RuleFor(x => x.ProjectName).MaximumLength(256);
|
||||
RuleFor(x => x.ContentItemTitle).MaximumLength(256);
|
||||
RuleFor(x => x.ViewportWidth).GreaterThan(0).When(x => x.ViewportWidth.HasValue);
|
||||
RuleFor(x => x.ViewportHeight).GreaterThan(0).When(x => x.ViewportHeight.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
public class SubmitFeedbackHandler(
|
||||
AppDbContext dbContext,
|
||||
FeedbackNotificationService notificationService)
|
||||
: Endpoint<SubmitFeedbackRequest, FeedbackReportDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/feedback");
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SubmitFeedbackRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!FeedbackRules.TryParseType(request.Type, out FeedbackType type))
|
||||
{
|
||||
AddError(request => request.Type, "The selected feedback type is not valid.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
FeedbackReport report = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = type,
|
||||
Status = FeedbackStatus.New,
|
||||
Description = request.Description.Trim(),
|
||||
ReporterUserId = User.GetUserId(),
|
||||
ReporterDisplayName = User.GetAlias() ?? User.GetName(),
|
||||
ReporterEmail = User.GetEmail(),
|
||||
SubmittedPath = request.SubmittedPath.Trim(),
|
||||
BrowserUserAgent = NormalizeOptional(request.BrowserUserAgent),
|
||||
ViewportWidth = request.ViewportWidth,
|
||||
ViewportHeight = request.ViewportHeight,
|
||||
AppVersion = NormalizeOptional(request.AppVersion),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
WorkspaceName = NormalizeOptional(request.WorkspaceName),
|
||||
ClientId = request.ClientId,
|
||||
ClientName = NormalizeOptional(request.ClientName),
|
||||
ProjectId = request.ProjectId,
|
||||
ProjectName = NormalizeOptional(request.ProjectName),
|
||||
ContentItemId = request.ContentItemId,
|
||||
ContentItemTitle = NormalizeOptional(request.ContentItemTitle),
|
||||
CreatedAt = now,
|
||||
LastActivityAt = now,
|
||||
};
|
||||
|
||||
dbContext.FeedbackReports.Add(report);
|
||||
await notificationService.AddNewReportNotificationsAsync(report, ct);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendAsync(report.ToDto(), StatusCodes.Status201Created, ct);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
string? normalized = value?.Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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 record UpdateDeveloperFeedbackRequest(
|
||||
string? Type,
|
||||
string? Status,
|
||||
IReadOnlyCollection<string>? Tags);
|
||||
|
||||
public class UpdateDeveloperFeedbackRequestValidator
|
||||
: Validator<UpdateDeveloperFeedbackRequest>
|
||||
{
|
||||
public UpdateDeveloperFeedbackRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Type).MaximumLength(32);
|
||||
RuleFor(x => x.Status).MaximumLength(32);
|
||||
RuleForEach(x => x.Tags).MaximumLength(64);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateDeveloperFeedbackHandler(
|
||||
AppDbContext dbContext,
|
||||
FeedbackNotificationService notificationService)
|
||||
: Endpoint<UpdateDeveloperFeedbackRequest, FeedbackReportDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Patch("/api/feedback/{id}");
|
||||
Roles(KnownRoles.Developer);
|
||||
Options(o => o.WithTags("Feedback"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateDeveloperFeedbackRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
FeedbackReport? report = await dbContext.FeedbackReports
|
||||
.Include(candidate => candidate.Tags)
|
||||
.Include(candidate => candidate.Screenshot)
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
|
||||
if (report is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
Guid developerUserId = User.GetUserId();
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
if (!string.IsNullOrWhiteSpace(request.Type))
|
||||
{
|
||||
if (!FeedbackRules.TryParseType(request.Type, out FeedbackType nextType))
|
||||
{
|
||||
AddError(request => request.Type, "The selected feedback type is not valid.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (report.Type != nextType)
|
||||
{
|
||||
AddActivity(
|
||||
report,
|
||||
developerUserId,
|
||||
FeedbackActivityTypes.TypeChanged,
|
||||
report.Type.ToFeedbackDisplayString(),
|
||||
nextType.ToFeedbackDisplayString(),
|
||||
null,
|
||||
now);
|
||||
report.Type = nextType;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||
{
|
||||
if (!FeedbackRules.TryParseStatus(request.Status, out FeedbackStatus nextStatus))
|
||||
{
|
||||
AddError(request => request.Status, "The selected feedback status is not valid.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!FeedbackRules.CanDeveloperSetStatus(report.Status, nextStatus))
|
||||
{
|
||||
AddError(request => request.Status, "The requested status transition is not allowed.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (report.Status != nextStatus)
|
||||
{
|
||||
AddActivity(
|
||||
report,
|
||||
developerUserId,
|
||||
FeedbackActivityTypes.StatusChanged,
|
||||
report.Status.ToFeedbackDisplayString(),
|
||||
nextStatus.ToFeedbackDisplayString(),
|
||||
null,
|
||||
now);
|
||||
report.Status = nextStatus;
|
||||
notificationService.AddDeveloperStatusNotification(report, developerUserId, nextStatus);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Tags is not null)
|
||||
{
|
||||
IReadOnlyCollection<string> normalizedTags = FeedbackRules.NormalizeTags(request.Tags);
|
||||
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 = now;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
await SendOkAsync(report.ToDto(), ct);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
HashSet<string> existingKeys = report.Tags
|
||||
.Select(tag => tag.NormalizedName)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (string tag in tags)
|
||||
{
|
||||
string key = FeedbackRules.NormalizeTagKey(tag);
|
||||
if (existingKeys.Contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
report.Tags.Add(new FeedbackTag
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FeedbackReportId = report.Id,
|
||||
Name = tag,
|
||||
NormalizedName = key,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static string FormatTags(IEnumerable<string> tags)
|
||||
{
|
||||
return string.Join(", ", tags.Order(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Services;
|
||||
|
||||
public static class FeedbackAccessRules
|
||||
{
|
||||
public static bool CanReporterAccess(FeedbackReport report, Guid userId)
|
||||
{
|
||||
return report.ReporterUserId == userId;
|
||||
}
|
||||
|
||||
public static bool CanReporterCancel(FeedbackReport report, Guid userId)
|
||||
{
|
||||
return CanReporterAccess(report, userId) && FeedbackRules.CanReporterCancel(report.Status);
|
||||
}
|
||||
|
||||
public static bool CanReporterComment(FeedbackReport report, Guid userId)
|
||||
{
|
||||
return CanReporterAccess(report, userId);
|
||||
}
|
||||
|
||||
public static bool CanDeveloperComment(bool isDeveloper)
|
||||
{
|
||||
return isDeveloper;
|
||||
}
|
||||
|
||||
public static bool CanAccessScreenshot(FeedbackReport report, Guid userId, bool isDeveloper)
|
||||
{
|
||||
return isDeveloper || CanReporterAccess(report, userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Socialize.Api.Modules.Feedback.Services;
|
||||
|
||||
public static class FeedbackActivityTypes
|
||||
{
|
||||
public const string StatusChanged = "StatusChanged";
|
||||
public const string TypeChanged = "TypeChanged";
|
||||
public const string TagsChanged = "TagsChanged";
|
||||
public const string Cancelled = "Cancelled";
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Services;
|
||||
|
||||
public static class FeedbackNotificationRoutes
|
||||
{
|
||||
public static string ForDeveloper(Guid feedbackReportId)
|
||||
{
|
||||
return $"/app/feedback/{feedbackReportId}";
|
||||
}
|
||||
|
||||
public static string ForReporter(Guid feedbackReportId)
|
||||
{
|
||||
return $"/app/my-feedback/{feedbackReportId}";
|
||||
}
|
||||
|
||||
public static string BuildMetadataJson(Guid feedbackReportId, bool developerRoute)
|
||||
{
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
route = developerRoute ? ForDeveloper(feedbackReportId) : ForReporter(feedbackReportId),
|
||||
feedbackReportId,
|
||||
isFeedbackNotification = true
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
using Socialize.Api.Modules.Notifications.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Services;
|
||||
|
||||
public class FeedbackNotificationService(AppDbContext dbContext)
|
||||
{
|
||||
private const string EntityType = "FeedbackReport";
|
||||
|
||||
public async Task AddNewReportNotificationsAsync(FeedbackReport report, CancellationToken ct)
|
||||
{
|
||||
List<FeedbackNotificationRecipient> developers = await GetDeveloperRecipientsAsync(ct);
|
||||
|
||||
foreach (FeedbackNotificationRecipient developer in developers.Where(developer => developer.UserId != report.ReporterUserId))
|
||||
{
|
||||
AddNotification(
|
||||
report,
|
||||
"Feedback.ReportCreated",
|
||||
$"New feedback from {report.ReporterDisplayName}",
|
||||
developer.UserId,
|
||||
developer.Email,
|
||||
developerRoute: true);
|
||||
}
|
||||
}
|
||||
|
||||
public void AddDeveloperCommentNotification(FeedbackReport report, Guid developerUserId)
|
||||
{
|
||||
if (report.ReporterUserId == developerUserId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddNotification(
|
||||
report,
|
||||
"Feedback.DeveloperCommented",
|
||||
$"A developer commented on your feedback",
|
||||
report.ReporterUserId,
|
||||
report.ReporterEmail,
|
||||
developerRoute: false);
|
||||
}
|
||||
|
||||
public void AddDeveloperStatusNotification(FeedbackReport report, Guid developerUserId, FeedbackStatus nextStatus)
|
||||
{
|
||||
if (report.ReporterUserId == developerUserId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddNotification(
|
||||
report,
|
||||
"Feedback.StatusChanged",
|
||||
$"Your feedback status changed to {nextStatus.ToFeedbackDisplayString()}",
|
||||
report.ReporterUserId,
|
||||
report.ReporterEmail,
|
||||
developerRoute: false);
|
||||
}
|
||||
|
||||
public async Task AddReporterCommentNotificationsAsync(FeedbackReport report, Guid reporterUserId, CancellationToken ct)
|
||||
{
|
||||
List<FeedbackNotificationRecipient> developerParticipants = await dbContext.FeedbackComments
|
||||
.Where(comment => comment.FeedbackReportId == report.Id &&
|
||||
comment.AuthorUserId != reporterUserId &&
|
||||
comment.AuthorRole == "Developer")
|
||||
.Select(comment => new FeedbackNotificationRecipient(comment.AuthorUserId, comment.AuthorEmail))
|
||||
.Distinct()
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (FeedbackNotificationRecipient developer in developerParticipants)
|
||||
{
|
||||
AddNotification(
|
||||
report,
|
||||
"Feedback.ReporterCommented",
|
||||
$"{report.ReporterDisplayName} replied to feedback",
|
||||
developer.UserId,
|
||||
developer.Email,
|
||||
developerRoute: true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<FeedbackNotificationRecipient>> GetDeveloperRecipientsAsync(CancellationToken ct)
|
||||
{
|
||||
return await (
|
||||
from userRole in dbContext.UserRoles
|
||||
join role in dbContext.Roles on userRole.RoleId equals role.Id
|
||||
join user in dbContext.Users on userRole.UserId equals user.Id
|
||||
where role.Name == KnownRoles.Developer
|
||||
select new FeedbackNotificationRecipient(user.Id, user.Email ?? string.Empty))
|
||||
.Distinct()
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private void AddNotification(
|
||||
FeedbackReport report,
|
||||
string eventType,
|
||||
string message,
|
||||
Guid recipientUserId,
|
||||
string? recipientEmail,
|
||||
bool developerRoute)
|
||||
{
|
||||
dbContext.NotificationEvents.Add(new NotificationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = report.WorkspaceId ?? Guid.Empty,
|
||||
ContentItemId = report.ContentItemId,
|
||||
EventType = eventType,
|
||||
EntityType = EntityType,
|
||||
EntityId = report.Id,
|
||||
Message = message,
|
||||
RecipientUserId = recipientUserId,
|
||||
RecipientEmail = recipientEmail,
|
||||
MetadataJson = FeedbackNotificationRoutes.BuildMetadataJson(report.Id, developerRoute),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
private sealed record FeedbackNotificationRecipient(Guid UserId, string? Email);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Feedback.Services;
|
||||
|
||||
public static class FeedbackRules
|
||||
{
|
||||
public static bool TryParseType(string? value, out FeedbackType type)
|
||||
{
|
||||
return Enum.TryParse(value?.Trim(), ignoreCase: true, out type)
|
||||
&& Enum.IsDefined(type);
|
||||
}
|
||||
|
||||
public static bool TryParseStatus(string? value, out FeedbackStatus status)
|
||||
{
|
||||
string? normalized = value?.Trim().Replace("'", string.Empty, StringComparison.Ordinal);
|
||||
if (string.Equals(normalized, "Wont Do", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(normalized, "WontDo", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
status = FeedbackStatus.WontDo;
|
||||
return true;
|
||||
}
|
||||
|
||||
return Enum.TryParse(normalized, ignoreCase: true, out status)
|
||||
&& Enum.IsDefined(status);
|
||||
}
|
||||
|
||||
public static bool IsFinal(FeedbackStatus status)
|
||||
{
|
||||
return status is FeedbackStatus.Cancelled;
|
||||
}
|
||||
|
||||
public static bool CanDeveloperSetStatus(FeedbackStatus currentStatus, FeedbackStatus nextStatus)
|
||||
{
|
||||
return !IsFinal(currentStatus) &&
|
||||
nextStatus is FeedbackStatus.New or FeedbackStatus.Planned or FeedbackStatus.Resolved or FeedbackStatus.WontDo;
|
||||
}
|
||||
|
||||
public static bool CanReporterCancel(FeedbackStatus currentStatus)
|
||||
{
|
||||
return !IsFinal(currentStatus);
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<string> NormalizeTags(IEnumerable<string>? tags)
|
||||
{
|
||||
if (tags is null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return tags
|
||||
.Select(tag => tag.Trim())
|
||||
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(tag => tag.Length > 64 ? tag[..64] : tag)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static string NormalizeTagKey(string tag)
|
||||
{
|
||||
return tag.Trim().ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,5 @@ public static class KnownRoles
|
||||
public const string Client = nameof(Client);
|
||||
public const string Provider = nameof(Provider);
|
||||
public const string WorkspaceMember = nameof(WorkspaceMember);
|
||||
public const string Developer = nameof(Developer);
|
||||
}
|
||||
|
||||
@@ -97,5 +97,11 @@ public static class DependencyInjection
|
||||
{
|
||||
await roleManager.CreateAsync(workspaceMemberRole);
|
||||
}
|
||||
|
||||
Role developerRole = new(KnownRoles.Developer);
|
||||
if (roleManager.Roles.All(r => r.Name != developerRole.Name))
|
||||
{
|
||||
await roleManager.CreateAsync(developerRole);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,13 +54,20 @@ public class GetNotificationsHandler(
|
||||
}
|
||||
|
||||
IQueryable<NotificationEvent> query = dbContext.NotificationEvents.AsQueryable();
|
||||
Guid currentUserId = User.GetUserId();
|
||||
|
||||
if (!accessScopeService.IsManager(User))
|
||||
{
|
||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
||||
query = query.Where(notificationEvent => workspaceScopeIds.Contains(notificationEvent.WorkspaceId));
|
||||
query = query.Where(notificationEvent =>
|
||||
workspaceScopeIds.Contains(notificationEvent.WorkspaceId) ||
|
||||
notificationEvent.RecipientUserId == currentUserId);
|
||||
}
|
||||
|
||||
query = query.Where(notificationEvent =>
|
||||
notificationEvent.RecipientUserId == null ||
|
||||
notificationEvent.RecipientUserId == currentUserId);
|
||||
|
||||
if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
query = query.Where(notificationEvent => notificationEvent.WorkspaceId == request.WorkspaceId.Value);
|
||||
|
||||
@@ -28,7 +28,9 @@ public class MarkNotificationAsReadHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanAccessWorkspace(User, notificationEvent.WorkspaceId))
|
||||
Guid currentUserId = User.GetUserId();
|
||||
bool canReadRecipientNotification = notificationEvent.RecipientUserId == currentUserId;
|
||||
if (!canReadRecipientNotification && !accessScopeService.CanAccessWorkspace(User, notificationEvent.WorkspaceId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
|
||||
@@ -5,6 +5,7 @@ public class Workspace
|
||||
public Guid Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public required string Slug { get; set; }
|
||||
public string? LogoUrl { get; set; }
|
||||
public Guid OwnerUserId { get; set; }
|
||||
public required string TimeZone { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
@@ -12,6 +12,7 @@ public static class WorkspaceModelConfiguration
|
||||
workspace.HasKey(x => x.Id);
|
||||
workspace.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
||||
workspace.Property(x => x.Slug).HasMaxLength(128).IsRequired();
|
||||
workspace.Property(x => x.LogoUrl).HasMaxLength(2048);
|
||||
workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired();
|
||||
workspace.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Workspaces.Handlers;
|
||||
|
||||
public record ChangeWorkspaceLogoRequest(
|
||||
IFormFile File);
|
||||
|
||||
public record ChangeWorkspaceLogoResponse(
|
||||
string BlobUrl);
|
||||
|
||||
public sealed class ChangeWorkspaceLogoRequestValidator : Validator<ChangeWorkspaceLogoRequest>
|
||||
{
|
||||
public ChangeWorkspaceLogoRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.File)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangeWorkspaceLogoHandler(
|
||||
AppDbContext dbContext,
|
||||
IBlobStorage blobStorage,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<ChangeWorkspaceLogoRequest, ChangeWorkspaceLogoResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/workspaces/{id}/logo");
|
||||
Options(o => o.WithTags("Workspaces"));
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ChangeWorkspaceLogoRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (workspace is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string blobUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Workspaces,
|
||||
$"{workspace.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.LogoPicture}",
|
||||
request.File.OpenReadStream(),
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
workspace.LogoUrl = blobUrl;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(new ChangeWorkspaceLogoResponse(blobUrl), ct);
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ public class CreateWorkspaceHandler(
|
||||
workspace.Id,
|
||||
workspace.Name,
|
||||
workspace.Slug,
|
||||
workspace.LogoUrl,
|
||||
workspace.TimeZone,
|
||||
workspace.CreatedAt);
|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@ public record WorkspaceDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Slug,
|
||||
string? LogoUrl,
|
||||
string TimeZone,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public class GetWorkspacesHandler(
|
||||
internal class GetWorkspacesHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: EndpointWithoutRequest<IReadOnlyCollection<WorkspaceDto>>
|
||||
@@ -26,20 +27,21 @@ public class GetWorkspacesHandler(
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
IQueryable<Workspace> query = dbContext.Workspaces.AsQueryable();
|
||||
var query = dbContext.Workspaces.AsQueryable();
|
||||
|
||||
if (!accessScopeService.IsManager(User))
|
||||
{
|
||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
||||
var workspaceScopeIds = User.GetWorkspaceScopeIds();
|
||||
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
|
||||
}
|
||||
|
||||
List<WorkspaceDto> workspaces = await query
|
||||
var workspaces = await query
|
||||
.OrderBy(workspace => workspace.Name)
|
||||
.Select(workspace => new WorkspaceDto(
|
||||
workspace.Id,
|
||||
workspace.Name,
|
||||
workspace.Slug,
|
||||
workspace.LogoUrl,
|
||||
workspace.TimeZone,
|
||||
workspace.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Workspaces.Handlers;
|
||||
|
||||
public record UpdateWorkspaceRequest(
|
||||
string Name,
|
||||
string TimeZone);
|
||||
|
||||
public class UpdateWorkspaceRequestValidator
|
||||
: Validator<UpdateWorkspaceRequest>
|
||||
{
|
||||
public UpdateWorkspaceRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateWorkspaceHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<UpdateWorkspaceRequest, WorkspaceDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/workspaces/{id}");
|
||||
Options(o => o.WithTags("Workspaces"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateWorkspaceRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (workspace is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
workspace.Name = request.Name.Trim();
|
||||
workspace.TimeZone = request.TimeZone.Trim();
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
WorkspaceDto dto = new(
|
||||
workspace.Id,
|
||||
workspace.Name,
|
||||
workspace.Slug,
|
||||
workspace.LogoUrl,
|
||||
workspace.TimeZone,
|
||||
workspace.CreatedAt);
|
||||
|
||||
await SendOkAsync(dto, ct);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ using Azure.Identity;
|
||||
using FastEndpoints;
|
||||
using FastEndpoints.Swagger;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Socialize;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||
using Socialize.Api.Infrastructure;
|
||||
using Socialize.Api.Infrastructure.Development;
|
||||
using Socialize.Api.Modules.Approvals;
|
||||
@@ -10,6 +13,7 @@ using Socialize.Api.Modules.Assets;
|
||||
using Socialize.Api.Modules.Clients;
|
||||
using Socialize.Api.Modules.Comments;
|
||||
using Socialize.Api.Modules.ContentItems;
|
||||
using Socialize.Api.Modules.Feedback;
|
||||
using Socialize.Api.Modules.Identity;
|
||||
using Socialize.Api.Modules.Notifications;
|
||||
using Socialize.Api.Modules.Projects;
|
||||
@@ -66,6 +70,7 @@ builder.AddAssetsModule();
|
||||
builder.AddCommentsModule();
|
||||
builder.AddApprovalsModule();
|
||||
builder.AddNotificationsModule();
|
||||
builder.AddFeedbackModule();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -92,6 +97,38 @@ if (!app.Environment.IsDevelopment())
|
||||
|
||||
app.UseHealthChecks("/health");
|
||||
|
||||
LocalBlobStorageOptions localBlobStorageOptions = app.Services
|
||||
.GetRequiredService<IOptions<LocalBlobStorageOptions>>()
|
||||
.Value;
|
||||
|
||||
string localBlobStorageRoot = app.Services
|
||||
.GetRequiredService<LocalBlobStorage>()
|
||||
.GetRootPath();
|
||||
string localBlobStorageRootWithSeparator = Path.EndsInDirectorySeparator(localBlobStorageRoot)
|
||||
? localBlobStorageRoot
|
||||
: $"{localBlobStorageRoot}{Path.DirectorySeparatorChar}";
|
||||
|
||||
Directory.CreateDirectory(localBlobStorageRoot);
|
||||
|
||||
app.MapGet(
|
||||
$"{LocalBlobStorage.NormalizeRequestPath(localBlobStorageOptions.RequestPath)}/{{**blobPath}}",
|
||||
async (
|
||||
string blobPath,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
string filePath = Path.GetFullPath(Path.Combine(localBlobStorageRoot, blobPath));
|
||||
if (!filePath.StartsWith(localBlobStorageRootWithSeparator, StringComparison.Ordinal) ||
|
||||
filePath.EndsWith(".content-type", StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(filePath))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
string contentType = LocalBlobStorage.ReadContentType(filePath) ?? "application/octet-stream";
|
||||
byte[] bytes = await File.ReadAllBytesAsync(filePath, ct);
|
||||
return Results.File(bytes, contentType);
|
||||
});
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.26.0" />
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.0" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.18.0" />
|
||||
<PackageReference Include="FastEndpoints" Version="5.35.0" />
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
"Website": {
|
||||
"FrontendBaseUrl": "http://localhost:5173"
|
||||
},
|
||||
"LocalBlobStorage": {
|
||||
"RootPath": "App_Data/blob-storage",
|
||||
"RequestPath": "/api/storage"
|
||||
},
|
||||
"Authentication": {
|
||||
"Jwt": {
|
||||
"Issuer": "http://localhost:5080",
|
||||
|
||||
274
backend/tests/Socialize.Tests/Feedback/FeedbackRulesTests.cs
Normal file
274
backend/tests/Socialize.Tests/Feedback/FeedbackRulesTests.cs
Normal file
@@ -0,0 +1,274 @@
|
||||
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;
|
||||
|
||||
public class FeedbackRulesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Bug", FeedbackType.Bug)]
|
||||
[InlineData("suggestion", FeedbackType.Suggestion)]
|
||||
[InlineData("Request", FeedbackType.Request)]
|
||||
public void TryParseType_accepts_supported_types(string value, FeedbackType expected)
|
||||
{
|
||||
bool parsed = FeedbackRules.TryParseType(value, out FeedbackType type);
|
||||
|
||||
Assert.True(parsed);
|
||||
Assert.Equal(expected, type);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("Question")]
|
||||
[InlineData("Incident")]
|
||||
public void TryParseType_rejects_unsupported_types(string value)
|
||||
{
|
||||
bool parsed = FeedbackRules.TryParseType(value, out _);
|
||||
|
||||
Assert.False(parsed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("New", FeedbackStatus.New)]
|
||||
[InlineData("Planned", FeedbackStatus.Planned)]
|
||||
[InlineData("Resolved", FeedbackStatus.Resolved)]
|
||||
[InlineData("Won't Do", FeedbackStatus.WontDo)]
|
||||
[InlineData("WontDo", FeedbackStatus.WontDo)]
|
||||
[InlineData("Cancelled", FeedbackStatus.Cancelled)]
|
||||
public void TryParseStatus_accepts_supported_statuses(string value, FeedbackStatus expected)
|
||||
{
|
||||
bool parsed = FeedbackRules.TryParseStatus(value, out FeedbackStatus status);
|
||||
|
||||
Assert.True(parsed);
|
||||
Assert.Equal(expected, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanDeveloperSetStatus_rejects_cancelled_destination()
|
||||
{
|
||||
bool allowed = FeedbackRules.CanDeveloperSetStatus(FeedbackStatus.New, FeedbackStatus.Cancelled);
|
||||
|
||||
Assert.False(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanDeveloperSetStatus_rejects_changes_after_cancelled()
|
||||
{
|
||||
bool allowed = FeedbackRules.CanDeveloperSetStatus(FeedbackStatus.Cancelled, FeedbackStatus.Planned);
|
||||
|
||||
Assert.False(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanReporterCancel_rejects_cancelled_report()
|
||||
{
|
||||
bool allowed = FeedbackRules.CanReporterCancel(FeedbackStatus.Cancelled);
|
||||
|
||||
Assert.False(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanReporterAccess_allows_report_owner()
|
||||
{
|
||||
Guid reporterUserId = Guid.NewGuid();
|
||||
FeedbackReport report = new() { ReporterUserId = reporterUserId };
|
||||
|
||||
bool allowed = FeedbackAccessRules.CanReporterAccess(report, reporterUserId);
|
||||
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanReporterAccess_rejects_other_users()
|
||||
{
|
||||
FeedbackReport report = new() { ReporterUserId = Guid.NewGuid() };
|
||||
|
||||
bool allowed = FeedbackAccessRules.CanReporterAccess(report, Guid.NewGuid());
|
||||
|
||||
Assert.False(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanReporterCancel_requires_owner_and_non_final_status()
|
||||
{
|
||||
Guid reporterUserId = Guid.NewGuid();
|
||||
FeedbackReport report = new()
|
||||
{
|
||||
ReporterUserId = reporterUserId,
|
||||
Status = FeedbackStatus.New,
|
||||
};
|
||||
|
||||
bool ownerAllowed = FeedbackAccessRules.CanReporterCancel(report, reporterUserId);
|
||||
bool otherUserAllowed = FeedbackAccessRules.CanReporterCancel(report, Guid.NewGuid());
|
||||
|
||||
Assert.True(ownerAllowed);
|
||||
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()
|
||||
{
|
||||
IReadOnlyCollection<string> tags = FeedbackRules.NormalizeTags([" mobile ", "bug", "Mobile", ""]);
|
||||
|
||||
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
@@ -26,6 +26,8 @@ services:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- "8080"
|
||||
volumes:
|
||||
- api-blob-storage:/app/App_Data/blob-storage
|
||||
|
||||
web:
|
||||
build:
|
||||
@@ -37,3 +39,6 @@ services:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
|
||||
volumes:
|
||||
api-blob-storage:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
257
docs/FEATURES/product-feedback.md
Normal file
257
docs/FEATURES/product-feedback.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Feature: Product Feedback
|
||||
|
||||
## Status
|
||||
|
||||
Draft
|
||||
|
||||
## Goal
|
||||
|
||||
Allow authenticated users to report bugs, suggestions, and requests from inside the app without interrupting their workflow, and give developers a lightweight place to review, discuss, and resolve that feedback.
|
||||
|
||||
This is product-level support data for the SaaS operator. It may capture workspace and page context for debugging, but it is not workspace-owned workflow data.
|
||||
|
||||
## User Stories
|
||||
|
||||
- As an authenticated user, I want to submit feedback from any app page so that I can report a bug, suggestion, or request when I notice it.
|
||||
- As an authenticated user, I want to optionally capture and annotate the current app viewport so that I can explain visual issues clearly.
|
||||
- As an authenticated user, I want a global My Feedback page so that I can track my submitted feedback across all workspaces.
|
||||
- As a developer, I want a global feedback review page so that I can see all submitted product feedback.
|
||||
- As a developer, I want to comment, update status/type, and add tags so that feedback can be reviewed without turning the app into a ticketing system.
|
||||
- As a reporter, I want notifications when a developer comments or changes status so that I know when feedback needs my attention.
|
||||
- As a developer, I want notifications for new reports and reporter replies so that feedback does not stall silently.
|
||||
|
||||
## Frontend Areas
|
||||
|
||||
- Global authenticated app shell floating Feedback button
|
||||
- Feedback submission dialog
|
||||
- Screenshot capture and annotation editor
|
||||
- `/app/my-feedback`
|
||||
- `/app/my-feedback/:id`
|
||||
- `/app/feedback`
|
||||
- `/app/feedback/:id`
|
||||
- Existing notification bell
|
||||
- `frontend/src/features/feedback/`
|
||||
|
||||
## Backend Modules
|
||||
|
||||
- Identity
|
||||
- Notifications
|
||||
- Feedback
|
||||
- Infrastructure blob storage
|
||||
|
||||
## Access Rules
|
||||
|
||||
- Only authenticated users can submit feedback.
|
||||
- Any authenticated user can submit feedback from any authenticated app page.
|
||||
- A new `Developer` role can access the global developer feedback review pages and APIs.
|
||||
- Developers can view every feedback report across the SaaS.
|
||||
- Reporters can view only feedback they submitted.
|
||||
- Feedback detail access is limited to the reporter and users with the `Developer` role.
|
||||
- Feedback screenshot access must be authenticated and must follow the same reporter/developer access rules as the report.
|
||||
- Feedback does not have public or shared links in v1.
|
||||
|
||||
## Submission Rules
|
||||
|
||||
- The global Feedback button appears on every authenticated app page.
|
||||
- Submitting feedback is intentionally user-initiated and non-intrusive.
|
||||
- Feedback type is required and must be one of:
|
||||
- `Bug`
|
||||
- `Suggestion`
|
||||
- `Request`
|
||||
- Description is required and plain text.
|
||||
- Screenshot capture is optional.
|
||||
- Users explicitly click `Capture screen`; opening feedback does not automatically capture the page.
|
||||
- Capture is limited to the app viewport.
|
||||
- If capture fails, the user can still submit text-only feedback.
|
||||
- If a user closes a dirty feedback dialog, the app warns that unsent feedback will be discarded.
|
||||
- Draft persistence is out of scope for v1.
|
||||
- Reporters cannot edit or delete submitted feedback in v1.
|
||||
- Reporters can add follow-up comments.
|
||||
|
||||
## Screenshot And Annotation Rules
|
||||
|
||||
- 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.
|
||||
- Annotation tools should support:
|
||||
- crop
|
||||
- arrows
|
||||
- circles or ellipses
|
||||
- lines
|
||||
- freehand marks
|
||||
- text labels
|
||||
- undo
|
||||
- clear/reset
|
||||
- Frontend implementation may use established capture and annotation libraries rather than custom screenshot tooling.
|
||||
- Developers can preview and download/open the annotated screenshot.
|
||||
- Reporters can preview the annotated screenshot.
|
||||
- If feedback deletion is added in the future, associated screenshot blobs must be deleted with the report.
|
||||
- Feedback reports and screenshots are retained indefinitely until a future retention/deletion feature exists.
|
||||
|
||||
## Captured Metadata
|
||||
|
||||
Each report should capture useful debugging context automatically when available:
|
||||
|
||||
- reporter user id
|
||||
- reporter name and email
|
||||
- current app URL/path
|
||||
- active workspace id/name
|
||||
- active client id/name
|
||||
- active project id/name
|
||||
- active content item id/title
|
||||
- browser user agent
|
||||
- viewport size
|
||||
- app version, if available
|
||||
- created timestamp
|
||||
|
||||
## Status Model
|
||||
|
||||
Feedback status is deliberately lightweight:
|
||||
|
||||
- `New`
|
||||
- `Planned`
|
||||
- `Resolved`
|
||||
- `Won't Do`
|
||||
- `Cancelled`
|
||||
|
||||
Status rules:
|
||||
|
||||
- New reports start as `New`.
|
||||
- Developers can move reports to `Planned`, `Resolved`, or `Won't Do`.
|
||||
- Developers can change a report type.
|
||||
- Developers can add, remove, and update free-form tags.
|
||||
- Tags are visible to reporters.
|
||||
- Tag entry should suggest previously used tags.
|
||||
- Reporters can cancel their own report with an optional plain-text reason.
|
||||
- `Cancelled` is final in v1.
|
||||
- Reporters cannot reopen resolved or cancelled feedback; they can add comments where comments remain allowed.
|
||||
- Developer reason/comment on `Won't Do` is optional.
|
||||
- No severity, priority, assignment, duplicate linking, or Jira-style workflow is included in v1.
|
||||
|
||||
## Comments And Activity
|
||||
|
||||
- Feedback comments are visible to both the reporter and developers.
|
||||
- Internal/private developer comments are out of scope for v1.
|
||||
- Status/type/tag changes should be stored as activity history.
|
||||
- Feedback detail should show a simple mixed timeline of comments and activity.
|
||||
- Reporters can comment on their own feedback.
|
||||
- Developers can comment on any feedback report.
|
||||
|
||||
## Notifications
|
||||
|
||||
- New feedback report: notify all users with the `Developer` role.
|
||||
- Developer comment: notify the reporter.
|
||||
- Developer status change: notify the reporter.
|
||||
- Developer type/tag changes do not notify the reporter.
|
||||
- Reporter comment: notify developers who have previously commented on that report.
|
||||
- Feedback notifications use the existing in-app notification system.
|
||||
- The existing notification bell should show feedback notifications and open the relevant feedback detail page.
|
||||
- Email notifications are out of scope for v1.
|
||||
- My Feedback should show an unread indicator for reports with unread developer comments or status changes.
|
||||
|
||||
## Developer Review Page
|
||||
|
||||
The developer review area is global, not workspace-scoped.
|
||||
|
||||
`/app/feedback` should support:
|
||||
|
||||
- list all reports by default, including final statuses
|
||||
- filter by type
|
||||
- filter by status
|
||||
- filter by tag
|
||||
- filter by reporter
|
||||
- filter by workspace context
|
||||
- filter by date range
|
||||
- text search
|
||||
- sort by newest
|
||||
- sort by oldest
|
||||
- sort by last activity
|
||||
|
||||
`/app/feedback/:id` should support:
|
||||
|
||||
- report details and captured metadata
|
||||
- reporter identity details
|
||||
- current URL/path link
|
||||
- screenshot preview and developer download/open-original action
|
||||
- comments
|
||||
- activity timeline
|
||||
- status updates
|
||||
- type updates
|
||||
- tag management with suggestions
|
||||
|
||||
## Reporter Pages
|
||||
|
||||
`/app/my-feedback` is global across workspaces and should default to active reports:
|
||||
|
||||
- `New`
|
||||
- `Planned`
|
||||
|
||||
The page should support:
|
||||
|
||||
- list own reports only
|
||||
- filter by status
|
||||
- filter by type
|
||||
- sort by newest
|
||||
- sort by last activity
|
||||
- unread indicators
|
||||
- open feedback detail
|
||||
- view visible tags
|
||||
|
||||
`/app/my-feedback/:id` should support:
|
||||
|
||||
- report details
|
||||
- current URL/path link
|
||||
- screenshot preview
|
||||
- visible tags
|
||||
- comments
|
||||
- activity timeline
|
||||
- cancel with optional reason when status is not final
|
||||
|
||||
## Localization
|
||||
|
||||
- User-facing feedback UI must be available in English and French.
|
||||
- New strings belong in the existing locale files.
|
||||
|
||||
## API And Data Expectations
|
||||
|
||||
- Backend code should follow the FastEndpoints module pattern under `backend/src/Socialize.Api/Modules/Feedback`.
|
||||
- Feedback entities should be added to `AppDbContext` with explicit model configuration.
|
||||
- The `Developer` role should be seeded with the existing identity role setup.
|
||||
- Screenshot storage should use the existing `IBlobStorage` abstraction.
|
||||
- Protected screenshot access may require a feedback-specific download endpoint instead of public static blob URLs.
|
||||
- Backend contract changes require OpenAPI regeneration while the backend is running.
|
||||
|
||||
## Out Of Scope For V1
|
||||
|
||||
- Public or unauthenticated feedback submission
|
||||
- Shared feedback links
|
||||
- Email notifications
|
||||
- Draft saving
|
||||
- Feedback deletion UI
|
||||
- Automatic retention cleanup
|
||||
- Severity or priority fields
|
||||
- Assignment/owner workflow
|
||||
- Duplicate linking
|
||||
- Internal/private comments
|
||||
- Workspace-owned exports or audit reports
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Authenticated users can open a global Feedback dialog from every app page.
|
||||
- [ ] Users can submit feedback with required type and description.
|
||||
- [ ] Users can optionally capture, annotate, and upload an app viewport screenshot.
|
||||
- [ ] Feedback records capture debugging metadata when available.
|
||||
- [ ] Reporters can view their own global My Feedback list and details.
|
||||
- [ ] Developers with the `Developer` role can view all feedback in `/app/feedback`.
|
||||
- [ ] Developers can update type, status, and tags.
|
||||
- [ ] Reporters and developers can comment on feedback.
|
||||
- [ ] Feedback activity history is shown with comments.
|
||||
- [ ] Feedback notifications appear in the existing in-app notification system.
|
||||
- [ ] Feedback screenshot access is authenticated and scoped to reporter/developer access.
|
||||
- [ ] English and French UI strings are present.
|
||||
- [ ] Backend build and tests pass.
|
||||
- [ ] Frontend build passes.
|
||||
- [ ] OpenAPI is updated after backend contracts are implemented.
|
||||
34
docs/FEATURES/user-profile-settings.md
Normal file
34
docs/FEATURES/user-profile-settings.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Feature: User Profile Settings
|
||||
|
||||
## Status
|
||||
|
||||
Draft
|
||||
|
||||
## Goal
|
||||
|
||||
Allow authenticated users to manage the profile information shown inside the application shell and workspace activity.
|
||||
|
||||
## User Stories
|
||||
|
||||
- As an authenticated user, I want to update my name, alias, email, and portrait so that other workspace members see accurate profile information.
|
||||
|
||||
## Frontend Areas
|
||||
|
||||
- `/app/settings/user-information`
|
||||
- `frontend/src/features/user-profile/`
|
||||
|
||||
## Backend Modules
|
||||
|
||||
- Identity
|
||||
|
||||
## Domain Rules
|
||||
|
||||
- Profile updates apply only to the authenticated user.
|
||||
- Portrait uploads flow through the existing blob storage abstraction.
|
||||
- Email changes use the identity module endpoint and should remain auditable through backend identity behavior.
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] User information settings show editable name, alias, and email fields.
|
||||
- [ ] Portrait upload remains available from the settings page.
|
||||
- [ ] Successful updates refresh the user profile state used by the app shell.
|
||||
40
docs/TASKS/platform-scaffold/003-use-local-blob-storage.md
Normal file
40
docs/TASKS/platform-scaffold/003-use-local-blob-storage.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Task: Use local blob storage
|
||||
|
||||
## Feature
|
||||
|
||||
`docs/FEATURES/platform-scaffold.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Store uploaded portraits and logos on the API server filesystem instead of Azure Blob Storage.
|
||||
|
||||
## Context
|
||||
|
||||
User, client, and workspace portrait uploads already flow through `IBlobStorage`. The implementation can change without altering endpoint contracts or frontend behavior.
|
||||
|
||||
## Files Likely To Change
|
||||
|
||||
- `backend/src/Socialize.Api/Infrastructure/DependencyInjection.cs`
|
||||
- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/*`
|
||||
- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Configuration/*`
|
||||
- `backend/src/Socialize.Api/Program.cs`
|
||||
- `backend/src/Socialize.Api/appsettings.Development.json`
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not change API request or response contracts.
|
||||
- Keep upload validation behavior consistent with the existing blob storage implementation.
|
||||
- Serve returned blob URLs from the API host so the existing frontend can keep using `portraitUrl` and `logoUrl`.
|
||||
|
||||
## Done When
|
||||
|
||||
- [x] `IBlobStorage` resolves to local filesystem storage by default.
|
||||
- [x] Uploaded files are served back from the API host.
|
||||
- [x] Backend build passes.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```bash
|
||||
dotnet build backend/Socialize.slnx
|
||||
dotnet test backend/Socialize.slnx
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
# Task: Improve UI Surface Contrast
|
||||
|
||||
## Goal
|
||||
|
||||
Increase contrast between the app background, panels, and form controls so inputs are easier to identify against white or near-white surfaces.
|
||||
|
||||
## Feature Spec
|
||||
|
||||
`docs/FEATURES/platform-scaffold.md`
|
||||
|
||||
## Scope
|
||||
|
||||
- Update the shared frontend color tokens.
|
||||
- Configure Vuetify to use the Socialize light theme colors.
|
||||
- Add shared form control and surface defaults for native and Vuetify controls.
|
||||
- Avoid feature-specific behavior changes.
|
||||
|
||||
## Likely Files
|
||||
|
||||
- `frontend/src/assets/main.css`
|
||||
- `frontend/src/main.js`
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
# Task: Backend feedback foundation
|
||||
|
||||
## Goal
|
||||
|
||||
Add the backend foundation for product feedback reports.
|
||||
|
||||
## Feature Spec
|
||||
|
||||
- `docs/FEATURES/product-feedback.md`
|
||||
|
||||
## Scope
|
||||
|
||||
- Add a new `Developer` identity role and seed it with the existing role setup.
|
||||
- Add a new FastEndpoints module under `backend/src/Socialize.Api/Modules/Feedback`.
|
||||
- Add feedback report data entities and EF Core model configuration.
|
||||
- Add feedback enum/value support for:
|
||||
- type: `Bug`, `Suggestion`, `Request`
|
||||
- status: `New`, `Planned`, `Resolved`, `Won't Do`, `Cancelled`
|
||||
- Add `DbSet` entries and module configuration to `AppDbContext`.
|
||||
- Capture reporter id, reporter display fields, submitted route, browser metadata, viewport size, app version if available, and optional workspace/client/project/content context.
|
||||
- Add API endpoints for:
|
||||
- submit feedback
|
||||
- list current user's feedback
|
||||
- get current user's feedback detail
|
||||
- list all feedback for `Developer`
|
||||
- get feedback detail for `Developer`
|
||||
- update feedback type/status/tags for `Developer`
|
||||
- cancel own feedback with optional reason
|
||||
- list previously used tags for `Developer`
|
||||
- Enforce access rules:
|
||||
- authenticated users can submit feedback
|
||||
- reporters can view only their own feedback
|
||||
- developers can view all feedback
|
||||
- only developers can update type/status/tags
|
||||
- reporters can only move their own non-final report to `Cancelled`
|
||||
- Keep assignment, priority, severity, duplicate linking, and deletion out of scope.
|
||||
|
||||
## Likely Files
|
||||
|
||||
- `backend/src/Socialize.Api/Modules/Identity/Contracts/KnownRoles.cs`
|
||||
- `backend/src/Socialize.Api/Modules/Identity/DependencyInjection.cs`
|
||||
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
|
||||
- `backend/src/Socialize.Api/Modules/Feedback/**`
|
||||
- `backend/tests/Socialize.Tests/**`
|
||||
|
||||
## Notes
|
||||
|
||||
- Use FastEndpoints handlers and keep request/response records near their handlers unless local module patterns suggest otherwise.
|
||||
- Use FluentValidation for non-trivial inputs.
|
||||
- Treat feedback as global SaaS operator data, not workspace-owned workflow data.
|
||||
- Tags are free-form but should be normalized enough to support search/filter suggestions later.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
dotnet build backend/Socialize.slnx
|
||||
dotnet test backend/Socialize.slnx
|
||||
```
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] `Developer` role exists and is seeded.
|
||||
- [ ] Feedback reports can be submitted by authenticated users.
|
||||
- [ ] Reporters can list and view only their own feedback.
|
||||
- [ ] Developers can list and view all feedback.
|
||||
- [ ] Developers can update type, status, and tags.
|
||||
- [ ] Reporters can cancel their own feedback with an optional reason.
|
||||
- [ ] Backend validation rejects invalid type/status transitions and missing descriptions.
|
||||
- [ ] Backend tests cover access rules and core transitions.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Task: Protected feedback screenshots
|
||||
|
||||
## Goal
|
||||
|
||||
Store feedback screenshots through blob storage and expose them only through authenticated, feedback-scoped access.
|
||||
|
||||
## Feature Spec
|
||||
|
||||
- `docs/FEATURES/product-feedback.md`
|
||||
|
||||
## Scope
|
||||
|
||||
- Add screenshot metadata to feedback reports or a related feedback screenshot entity.
|
||||
- Store uploaded annotated screenshots with the existing `IBlobStorage` abstraction.
|
||||
- Use a dedicated feedback storage container/prefix.
|
||||
- Validate content type and maximum upload size on the backend.
|
||||
- Add API support for attaching a screenshot when creating feedback or immediately after creation.
|
||||
- Add a protected screenshot download/preview endpoint.
|
||||
- Enforce screenshot access:
|
||||
- reporter can access screenshots for their own reports
|
||||
- developers can access all feedback screenshots
|
||||
- no public/static blob URL access for feedback screenshots
|
||||
- Return enough screenshot metadata for frontend preview/download flows without exposing unauthenticated blob URLs.
|
||||
- Document that future feedback deletion must remove associated screenshot blobs.
|
||||
|
||||
## Likely Files
|
||||
|
||||
- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/*`
|
||||
- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/*`
|
||||
- `backend/src/Socialize.Api/Modules/Feedback/**`
|
||||
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
|
||||
- `backend/tests/Socialize.Tests/**`
|
||||
|
||||
## Notes
|
||||
|
||||
- Existing portrait/logo blob behavior may expose static URLs; feedback screenshots must not rely on that public URL pattern.
|
||||
- Prefer an endpoint that streams the blob after checking feedback access.
|
||||
- Annotated screenshots are expected to be compressed PNG or JPEG files.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
dotnet build backend/Socialize.slnx
|
||||
dotnet test backend/Socialize.slnx
|
||||
```
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Feedback screenshots are stored via `IBlobStorage`.
|
||||
- [ ] Feedback screenshots use a dedicated storage area/prefix.
|
||||
- [ ] Invalid or oversized screenshots are rejected with clear API errors.
|
||||
- [ ] Screenshot access requires authentication.
|
||||
- [ ] Reporter/developer access rules are enforced for downloads/previews.
|
||||
- [ ] Backend tests cover authorized and unauthorized screenshot access.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Task: Feedback comments, activity, and notifications
|
||||
|
||||
## Goal
|
||||
|
||||
Add the conversation, activity timeline, and in-app notification behavior for feedback.
|
||||
|
||||
## Feature Spec
|
||||
|
||||
- `docs/FEATURES/product-feedback.md`
|
||||
|
||||
## Scope
|
||||
|
||||
- Add feedback comments visible to both reporters and developers.
|
||||
- Add feedback activity entries for status/type/tag changes and cancellation.
|
||||
- Return a mixed timeline of comments and activity from feedback detail endpoints.
|
||||
- Add API endpoints for:
|
||||
- reporter adds comment to own feedback
|
||||
- developer adds comment to any feedback
|
||||
- detail timeline retrieval if not included in existing detail endpoints
|
||||
- Use the existing Notifications module for:
|
||||
- new feedback report: notify all `Developer` users
|
||||
- developer comment: notify reporter
|
||||
- developer status change: notify reporter
|
||||
- reporter comment: notify developers who have previously commented on that report
|
||||
- Do not notify for developer type/tag changes.
|
||||
- Extend notification payloads so feedback notifications can open feedback detail pages.
|
||||
- Add read/unread support needed for My Feedback unread indicators, or expose enough data for the frontend to derive unread state from notifications.
|
||||
|
||||
## Likely Files
|
||||
|
||||
- `backend/src/Socialize.Api/Modules/Feedback/**`
|
||||
- `backend/src/Socialize.Api/Modules/Notifications/**`
|
||||
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
|
||||
- `backend/tests/Socialize.Tests/**`
|
||||
|
||||
## Notes
|
||||
|
||||
- Internal/private comments are out of scope.
|
||||
- Email notifications are out of scope.
|
||||
- Avoid adding assignment/owner workflow.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
dotnet build backend/Socialize.slnx
|
||||
dotnet test backend/Socialize.slnx
|
||||
```
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Reporters and developers can comment according to access rules.
|
||||
- [ ] Status/type/tag/cancel actions create activity entries.
|
||||
- [ ] Feedback detail includes a mixed comment/activity timeline.
|
||||
- [ ] New reports notify all developers.
|
||||
- [ ] Developer comments and status changes notify the reporter.
|
||||
- [ ] Reporter comments notify participating developers.
|
||||
- [ ] Feedback notifications include route-target data for frontend navigation.
|
||||
- [ ] Backend tests cover comment access and notification side effects.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Task: Frontend feedback submission flow
|
||||
|
||||
## Goal
|
||||
|
||||
Add the global authenticated Feedback button, submission dialog, viewport capture, and annotation flow.
|
||||
|
||||
## Feature Spec
|
||||
|
||||
- `docs/FEATURES/product-feedback.md`
|
||||
|
||||
## Scope
|
||||
|
||||
- Add feature-owned frontend code under `frontend/src/features/feedback/`.
|
||||
- Add a small floating Feedback button to the authenticated app shell on every `/app/*` page.
|
||||
- Keep the button visible on feedback-related pages too.
|
||||
- Add a feedback submission dialog with:
|
||||
- required type: `Bug`, `Suggestion`, `Request`
|
||||
- required plain-text description
|
||||
- optional capture flow
|
||||
- dirty-close warning that discards unsent feedback if confirmed
|
||||
- Capture only the current app viewport when the user explicitly clicks `Capture screen`.
|
||||
- Add screenshot annotation support:
|
||||
- crop
|
||||
- arrows
|
||||
- circles or ellipses
|
||||
- lines
|
||||
- freehand marks
|
||||
- text labels
|
||||
- undo
|
||||
- clear/reset
|
||||
- Export annotated screenshots as compressed PNG or JPEG.
|
||||
- Submit feedback metadata, route context, browser metadata, viewport size, and optional screenshot to the backend.
|
||||
- If capture fails, show a friendly error and allow text-only submission.
|
||||
- Use established libraries for capture/annotation rather than custom screenshot infrastructure.
|
||||
- Add English and French locale strings for the submission flow.
|
||||
|
||||
## Likely Files
|
||||
|
||||
- `frontend/package.json`
|
||||
- `frontend/src/layouts/**`
|
||||
- `frontend/src/features/feedback/**`
|
||||
- `frontend/src/plugins/api.js`
|
||||
- `frontend/src/locales/en.json`
|
||||
- `frontend/src/locales/fr.json`
|
||||
|
||||
## Notes
|
||||
|
||||
- Runtime configuration must continue to flow through `frontend/src/config.js` if new configuration is needed.
|
||||
- Keep the flow non-intrusive and app-shell scoped.
|
||||
- Avoid landing-page or marketing-style UI.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Authenticated users see a floating Feedback button on every app page.
|
||||
- [ ] Users can submit required type and description.
|
||||
- [ ] Users can optionally capture and annotate the app viewport.
|
||||
- [ ] Capture failures do not block text-only feedback.
|
||||
- [ ] Dirty dialog close warns before discarding unsent feedback.
|
||||
- [ ] UI strings exist in English and French.
|
||||
- [ ] Frontend build passes.
|
||||
71
docs/TASKS/product-feedback/005-frontend-my-feedback.md
Normal file
71
docs/TASKS/product-feedback/005-frontend-my-feedback.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Task: Frontend My Feedback pages
|
||||
|
||||
## Goal
|
||||
|
||||
Add reporter-facing pages for tracking submitted feedback.
|
||||
|
||||
## Feature Spec
|
||||
|
||||
- `docs/FEATURES/product-feedback.md`
|
||||
|
||||
## Scope
|
||||
|
||||
- Add routes:
|
||||
- `/app/my-feedback`
|
||||
- `/app/my-feedback/:id`
|
||||
- Add feature-owned views/stores/components under `frontend/src/features/feedback/`.
|
||||
- The list page is global across workspaces and shows only the authenticated user's own reports.
|
||||
- Default the list to active reports:
|
||||
- `New`
|
||||
- `Planned`
|
||||
- Support list filtering by:
|
||||
- status
|
||||
- type
|
||||
- Support sorting by:
|
||||
- newest
|
||||
- last activity
|
||||
- Show unread indicators for reports with unread developer comments or status changes.
|
||||
- Show visible tags.
|
||||
- Detail page should show:
|
||||
- report details
|
||||
- current URL/path link
|
||||
- screenshot preview
|
||||
- tags
|
||||
- comments
|
||||
- activity timeline
|
||||
- cancellation action with optional reason when the report is not final
|
||||
- Allow reporters to add follow-up comments.
|
||||
- Extend navigation/sidebar/user menu as appropriate so users can find My Feedback.
|
||||
- Add English and French locale strings.
|
||||
|
||||
## Likely Files
|
||||
|
||||
- `frontend/src/router/router.js`
|
||||
- `frontend/src/layouts/main/**`
|
||||
- `frontend/src/features/feedback/**`
|
||||
- `frontend/src/locales/en.json`
|
||||
- `frontend/src/locales/fr.json`
|
||||
|
||||
## Notes
|
||||
|
||||
- Reporters cannot edit or delete submitted feedback in v1.
|
||||
- Reporters cannot change status except cancelling their own non-final report.
|
||||
- `Cancelled` is final.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Authenticated users can open My Feedback.
|
||||
- [ ] My Feedback defaults to active reports.
|
||||
- [ ] Users can filter and sort their feedback.
|
||||
- [ ] Unread indicators are visible where applicable.
|
||||
- [ ] Users can open details, preview screenshots, read timeline, and comment.
|
||||
- [ ] Users can cancel their own non-final report with an optional reason.
|
||||
- [ ] UI strings exist in English and French.
|
||||
- [ ] Frontend build passes.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Task: Frontend developer feedback review
|
||||
|
||||
## Goal
|
||||
|
||||
Add the developer-facing global feedback review area.
|
||||
|
||||
## Feature Spec
|
||||
|
||||
- `docs/FEATURES/product-feedback.md`
|
||||
|
||||
## Scope
|
||||
|
||||
- Add routes restricted to the `Developer` role:
|
||||
- `/app/feedback`
|
||||
- `/app/feedback/:id`
|
||||
- Add feature-owned views/stores/components under `frontend/src/features/feedback/`.
|
||||
- Add a discoverable navigation entry for users with the `Developer` role.
|
||||
- The list page is global and shows all reports by default, including final statuses.
|
||||
- Support list filters:
|
||||
- type
|
||||
- status
|
||||
- tag
|
||||
- reporter
|
||||
- workspace context
|
||||
- date range
|
||||
- text search
|
||||
- Support sorting by:
|
||||
- newest
|
||||
- oldest
|
||||
- last activity
|
||||
- Detail page should show:
|
||||
- report details and captured metadata
|
||||
- reporter name/email
|
||||
- current URL/path link
|
||||
- screenshot preview
|
||||
- developer download/open-original screenshot action
|
||||
- comments
|
||||
- activity timeline
|
||||
- status updates
|
||||
- type updates
|
||||
- tag management with suggestions from previously used tags
|
||||
- Allow developers to comment on any feedback report.
|
||||
- Add English and French locale strings.
|
||||
|
||||
## Likely Files
|
||||
|
||||
- `frontend/src/router/router.js`
|
||||
- `frontend/src/layouts/main/**`
|
||||
- `frontend/src/features/feedback/**`
|
||||
- `frontend/src/locales/en.json`
|
||||
- `frontend/src/locales/fr.json`
|
||||
|
||||
## Notes
|
||||
|
||||
- Do not add assignment, priority, severity, duplicate linking, or private comments.
|
||||
- Keep the review page operational and dense, not a Jira-style board.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Only users with the `Developer` role can access `/app/feedback`.
|
||||
- [ ] Developers can list all feedback with required filters and sorting.
|
||||
- [ ] Developers can open details and inspect metadata.
|
||||
- [ ] Developers can preview and download/open screenshots.
|
||||
- [ ] Developers can update type, status, and tags.
|
||||
- [ ] Tag suggestions use previously used tags.
|
||||
- [ ] Developers can comment.
|
||||
- [ ] UI strings exist in English and French.
|
||||
- [ ] Frontend build passes.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Task: Feedback notification UI integration
|
||||
|
||||
## Goal
|
||||
|
||||
Integrate feedback notifications into the existing notification bell and route navigation.
|
||||
|
||||
## Feature Spec
|
||||
|
||||
- `docs/FEATURES/product-feedback.md`
|
||||
|
||||
## Scope
|
||||
|
||||
- Extend frontend notification display to support feedback event types.
|
||||
- Clicking a feedback notification should open:
|
||||
- `/app/my-feedback/:id` for reporters
|
||||
- `/app/feedback/:id` for developers when appropriate
|
||||
- Mark feedback notifications as read using existing notification behavior.
|
||||
- Ensure feedback notification labels are localized in English and French.
|
||||
- Ensure My Feedback unread indicators stay consistent with notification read state or the backend unread model.
|
||||
- Preserve existing content/comment/approval notification behavior.
|
||||
|
||||
## Likely Files
|
||||
|
||||
- `frontend/src/layouts/main/AppSidebar.vue`
|
||||
- `frontend/src/features/notifications/**`
|
||||
- `frontend/src/features/feedback/**`
|
||||
- `frontend/src/locales/en.json`
|
||||
- `frontend/src/locales/fr.json`
|
||||
|
||||
## Notes
|
||||
|
||||
- This task depends on backend feedback notification payloads from `003-feedback-comments-activity-notifications.md`.
|
||||
- Do not introduce email notification behavior.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Feedback notifications appear in the existing notification bell.
|
||||
- [ ] Feedback notification clicks navigate to the correct detail page.
|
||||
- [ ] Feedback notifications can be marked read.
|
||||
- [ ] My Feedback unread indicators reflect unread feedback activity.
|
||||
- [ ] Existing notification flows still work.
|
||||
- [ ] UI strings exist in English and French.
|
||||
- [ ] Frontend build passes.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Task: OpenAPI sync and end-to-end feedback polish
|
||||
|
||||
## Goal
|
||||
|
||||
Finalize contract sync, validation, and end-to-end behavior after the feedback backend and frontend tasks are implemented.
|
||||
|
||||
## Feature Spec
|
||||
|
||||
- `docs/FEATURES/product-feedback.md`
|
||||
|
||||
## Scope
|
||||
|
||||
- Run the backend and regenerate OpenAPI after feedback API contracts are complete.
|
||||
- Update generated frontend API types.
|
||||
- Resolve frontend build issues caused by contract changes.
|
||||
- Verify reporter and developer access flows manually.
|
||||
- Verify protected screenshot preview/download behavior.
|
||||
- Verify feedback notifications open the expected pages.
|
||||
- Verify English/French feedback UI coverage.
|
||||
- Review `docs/FEATURES/product-feedback.md` and update it if implementation intentionally changed behavior.
|
||||
- Add or update follow-up task files for deferred work discovered during implementation.
|
||||
|
||||
## Likely Files
|
||||
|
||||
- `shared/openapi/openapi.json`
|
||||
- `frontend/src/api/schema.d.ts`
|
||||
- `docs/FEATURES/product-feedback.md`
|
||||
- `docs/TASKS/product-feedback/**`
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
dotnet build backend/Socialize.slnx
|
||||
dotnet test backend/Socialize.slnx
|
||||
./scripts/update-openapi.sh
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] OpenAPI snapshot is updated.
|
||||
- [ ] Generated frontend schema is updated.
|
||||
- [ ] Backend build passes.
|
||||
- [ ] Backend tests pass.
|
||||
- [ ] Frontend build passes.
|
||||
- [ ] Reporter can submit, view, comment, and cancel feedback.
|
||||
- [ ] Developer can review, filter, comment, update status/type/tags, and access screenshots.
|
||||
- [ ] Feedback notifications work from the notification bell.
|
||||
- [ ] Feature spec still matches implemented behavior.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Task: Edit user information settings
|
||||
|
||||
## Goal
|
||||
|
||||
Allow users to edit their profile details from the user information settings page.
|
||||
|
||||
## Feature Spec
|
||||
|
||||
- `docs/FEATURES/user-profile-settings.md`
|
||||
|
||||
## Scope
|
||||
|
||||
- Replace read-only user information details with editable first name, last name, alias, and email fields.
|
||||
- Keep portrait upload available on the page.
|
||||
- Use the existing Identity endpoints for full name, alias, email, and portrait updates.
|
||||
- Keep the profile store as the source of truth for app-shell user identity.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Task: Edit workspace settings
|
||||
|
||||
## Goal
|
||||
|
||||
Allow managers to update the active workspace name and time zone from the workspace settings page.
|
||||
|
||||
## Feature Spec
|
||||
|
||||
- `docs/FEATURES/workspace-review-workflow.md`
|
||||
|
||||
## Scope
|
||||
|
||||
- Add a backend workspace update endpoint for `name` and `timeZone`.
|
||||
- Add a backend workspace logo upload endpoint.
|
||||
- Add a frontend workspace store update action.
|
||||
- Replace the workspace settings general summary with editable details and logo controls.
|
||||
- Do not display workspace slug or workspace creation date on the workspace settings page.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
dotnet build backend/Socialize.slnx
|
||||
cd frontend && npm run build
|
||||
```
|
||||
59
frontend/package-lock.json
generated
59
frontend/package-lock.json
generated
@@ -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",
|
||||
@@ -2313,9 +2323,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001722",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001722.tgz",
|
||||
"integrity": "sha512-DCQHBBZtiK6JVkAGw7drvAMK0Q0POD/xZvEmDp6baiMMP6QXXk9HpD6mNYBZWhOPG6LvIDb82ITqtWjhDckHCA==",
|
||||
"version": "1.0.30001791",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
|
||||
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2330,7 +2340,8 @@
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
]
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
@@ -2496,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",
|
||||
@@ -3564,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",
|
||||
@@ -5340,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",
|
||||
@@ -5538,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
3333
frontend/src/api/schema.d.ts
vendored
3333
frontend/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,18 @@
|
||||
--socialize-primary: #172033;
|
||||
--socialize-accent: #ff8a3d;
|
||||
--socialize-highlight: #2fa58d;
|
||||
--h-background: #fffaf2;
|
||||
--h-background: #f4f6f3;
|
||||
--h-on-background: #172033;
|
||||
--h-surface: #ffffff;
|
||||
--h-surface: #fbfaf6;
|
||||
--h-surface-muted: #f1f5f2;
|
||||
--h-on-surface: #172033;
|
||||
--h-control: #eef3ef;
|
||||
--h-control-hover: #e7eee9;
|
||||
--h-control-focus: #ffffff;
|
||||
--h-border: #c7d2cc;
|
||||
--h-border-strong: #94a39d;
|
||||
--h-primary: #172033;
|
||||
--h-on-primary: #fffaf2;
|
||||
--h-on-primary: #fbfaf6;
|
||||
--h-secondary: #fff3e2;
|
||||
--h-on-secondary: #172033;
|
||||
--h-tertiary: #d9f6ee;
|
||||
@@ -20,6 +26,93 @@
|
||||
--h-on-error: #ffffff;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
background: var(--h-background);
|
||||
}
|
||||
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']),
|
||||
select,
|
||||
textarea {
|
||||
background-color: var(--h-control) !important;
|
||||
border-color: var(--h-border) !important;
|
||||
color: var(--h-on-surface);
|
||||
}
|
||||
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']):hover,
|
||||
select:hover,
|
||||
textarea:hover {
|
||||
background-color: var(--h-control-hover) !important;
|
||||
border-color: var(--h-border-strong) !important;
|
||||
}
|
||||
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']):focus,
|
||||
select:focus,
|
||||
textarea:focus,
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']):focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
background-color: var(--h-control-focus) !important;
|
||||
border-color: var(--socialize-highlight) !important;
|
||||
box-shadow: 0 0 0 3px rgba(47, 165, 141, 0.16);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: #68778a;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.v-application {
|
||||
background: var(--h-background) !important;
|
||||
color: var(--h-on-background);
|
||||
}
|
||||
|
||||
.v-card,
|
||||
.v-sheet,
|
||||
.v-list,
|
||||
.v-menu > .v-overlay__content,
|
||||
.v-dialog > .v-overlay__content {
|
||||
background-color: var(--h-surface) !important;
|
||||
border: 1px solid var(--h-border);
|
||||
}
|
||||
|
||||
.v-field {
|
||||
background-color: var(--h-control) !important;
|
||||
color: var(--h-on-surface);
|
||||
}
|
||||
|
||||
.v-field:hover {
|
||||
background-color: var(--h-control-hover) !important;
|
||||
}
|
||||
|
||||
.v-field--focused {
|
||||
background-color: var(--h-control-focus) !important;
|
||||
}
|
||||
|
||||
.v-field__outline {
|
||||
color: var(--h-border-strong);
|
||||
}
|
||||
|
||||
.v-field--focused .v-field__outline {
|
||||
color: var(--socialize-highlight);
|
||||
}
|
||||
|
||||
.v-field__input,
|
||||
.v-field-label {
|
||||
color: var(--h-on-surface);
|
||||
}
|
||||
|
||||
.panel,
|
||||
[class$='-panel'],
|
||||
[class$='-card'],
|
||||
div.card {
|
||||
border-color: var(--h-border) !important;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply min-w-24 w-full;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
290
frontend/src/features/feedback/stores/developerFeedbackStore.js
Normal file
290
frontend/src/features/feedback/stores/developerFeedbackStore.js
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
179
frontend/src/features/feedback/stores/myFeedbackStore.js
Normal file
179
frontend/src/features/feedback/stores/myFeedbackStore.js
Normal 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();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
394
frontend/src/features/feedback/views/MyFeedbackDetailView.vue
Normal file
394
frontend/src/features/feedback/views/MyFeedbackDetailView.vue
Normal 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>
|
||||
311
frontend/src/features/feedback/views/MyFeedbackListView.vue
Normal file
311
frontend/src/features/feedback/views/MyFeedbackListView.vue
Normal 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>
|
||||
45
frontend/src/features/notifications/notificationRoutes.js
Normal file
45
frontend/src/features/notifications/notificationRoutes.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {computed, watch} from 'vue'
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import {defineStore} from 'pinia'
|
||||
import {useAuthStore} from "@/features/auth/stores/authStore.js";
|
||||
import {useClient} from "@/plugins/api.js";
|
||||
@@ -9,6 +9,9 @@ export const useUserProfileStore = defineStore(
|
||||
() => {
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isUpdating = ref(false)
|
||||
const isUploadingPortrait = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const authWatcher = watch(
|
||||
() => authStore.isAuthenticated,
|
||||
@@ -64,12 +67,15 @@ export const useUserProfileStore = defineStore(
|
||||
const client = useClient()
|
||||
const userResponse = await client.get("/api/users/profile");
|
||||
value.value = userResponse.data
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} catch (fetchError) {
|
||||
console.error(fetchError)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeFullname(firstname, lastname) {
|
||||
isUpdating.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const client = useClient()
|
||||
await client.post(
|
||||
@@ -80,12 +86,19 @@ export const useUserProfileStore = defineStore(
|
||||
})
|
||||
value.value.firstname = firstname;
|
||||
value.value.lastname = lastname;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} catch (updateError) {
|
||||
console.error(updateError)
|
||||
error.value = 'Failed to update profile.'
|
||||
throw updateError
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changeAlias(alias) {
|
||||
isUpdating.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const client = useClient()
|
||||
await client.post(
|
||||
@@ -94,8 +107,12 @@ export const useUserProfileStore = defineStore(
|
||||
alias: alias
|
||||
})
|
||||
value.value.alias = alias;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} catch (updateError) {
|
||||
console.error(updateError)
|
||||
error.value = 'Failed to update profile.'
|
||||
throw updateError
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +145,9 @@ export const useUserProfileStore = defineStore(
|
||||
}
|
||||
|
||||
async function changeEmail(email) {
|
||||
isUpdating.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const client = useClient()
|
||||
await client.post(
|
||||
@@ -136,8 +156,12 @@ export const useUserProfileStore = defineStore(
|
||||
email: email
|
||||
})
|
||||
value.value.email = email;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} catch (updateError) {
|
||||
console.error(updateError)
|
||||
error.value = 'Failed to update profile.'
|
||||
throw updateError
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +180,9 @@ export const useUserProfileStore = defineStore(
|
||||
}
|
||||
|
||||
async function changePortrait(selectedFile) {
|
||||
isUploadingPortrait.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const client = useClient()
|
||||
const formData = new FormData();
|
||||
@@ -166,8 +193,12 @@ export const useUserProfileStore = defineStore(
|
||||
formData)
|
||||
|
||||
value.value.portraitUrl = `${response.data.blobUrl}?${Date.now()}` // the Date.now() is for cache-busting
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} catch (uploadError) {
|
||||
console.error(uploadError)
|
||||
error.value = 'Failed to update portrait.'
|
||||
throw uploadError
|
||||
} finally {
|
||||
isUploadingPortrait.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +207,9 @@ export const useUserProfileStore = defineStore(
|
||||
alias,
|
||||
fullname,
|
||||
portraitUrl,
|
||||
isUpdating,
|
||||
isUploadingPortrait,
|
||||
error,
|
||||
roles,
|
||||
persona,
|
||||
authorizedWorkspaceIds,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
|
||||
@@ -9,21 +9,86 @@
|
||||
const { t } = useI18n();
|
||||
const isPortraitDialogOpen = ref(false);
|
||||
const isSavingPortrait = ref(false);
|
||||
const settingsError = ref(null);
|
||||
const settingsStatus = ref(null);
|
||||
const form = reactive({
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
alias: '',
|
||||
email: '',
|
||||
});
|
||||
|
||||
const email = computed(() => userProfileStore.user?.email || t('userSettings.noEmail'));
|
||||
const alias = computed(() => userProfileStore.alias);
|
||||
const fullname = computed(() => userProfileStore.fullname);
|
||||
const canSave = computed(() => Boolean(form.email.trim()) && !userProfileStore.isUpdating);
|
||||
|
||||
function syncFormFromUser(user) {
|
||||
form.firstname = user?.firstname ?? '';
|
||||
form.lastname = user?.lastname ?? '';
|
||||
form.alias = user?.alias ?? '';
|
||||
form.email = user?.email ?? '';
|
||||
}
|
||||
|
||||
async function submitSettings() {
|
||||
if (!form.email.trim()) {
|
||||
settingsError.value = t('userSettings.errors.emailRequired');
|
||||
settingsStatus.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const user = userProfileStore.user ?? {};
|
||||
const nextFirstname = form.firstname.trim();
|
||||
const nextLastname = form.lastname.trim();
|
||||
const nextAlias = form.alias.trim();
|
||||
const nextEmail = form.email.trim();
|
||||
|
||||
settingsError.value = null;
|
||||
settingsStatus.value = null;
|
||||
|
||||
try {
|
||||
if (nextFirstname !== (user.firstname ?? '') || nextLastname !== (user.lastname ?? '')) {
|
||||
await userProfileStore.changeFullname(nextFirstname, nextLastname);
|
||||
}
|
||||
|
||||
if (nextAlias !== (user.alias ?? '')) {
|
||||
await userProfileStore.changeAlias(nextAlias || null);
|
||||
}
|
||||
|
||||
if (nextEmail !== (user.email ?? '')) {
|
||||
await userProfileStore.changeEmail(nextEmail);
|
||||
}
|
||||
|
||||
settingsStatus.value = t('userSettings.saved');
|
||||
syncFormFromUser(userProfileStore.user);
|
||||
} catch (error) {
|
||||
console.error('Failed to update user settings:', error);
|
||||
settingsError.value = t('userSettings.errors.saveFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async function savePortrait(result) {
|
||||
isSavingPortrait.value = true;
|
||||
settingsError.value = null;
|
||||
settingsStatus.value = null;
|
||||
|
||||
try {
|
||||
await userProfileStore.changePortrait(result.file);
|
||||
isPortraitDialogOpen.value = false;
|
||||
settingsStatus.value = t('userSettings.portraitSaved');
|
||||
} catch (error) {
|
||||
console.error('Failed to update user portrait:', error);
|
||||
settingsError.value = t('userSettings.errors.portraitFailed');
|
||||
} finally {
|
||||
isSavingPortrait.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => userProfileStore.user,
|
||||
syncFormFromUser,
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -50,6 +115,7 @@
|
||||
|
||||
<button
|
||||
class="primary-button"
|
||||
type="button"
|
||||
@click="isPortraitDialogOpen = true"
|
||||
>
|
||||
{{ t('userSettings.updatePortrait') }}
|
||||
@@ -62,20 +128,77 @@
|
||||
<span>{{ t('userSettings.accountDetailsDescription') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="settingsError"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ settingsError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="settingsStatus"
|
||||
class="page-message success"
|
||||
>
|
||||
{{ settingsStatus }}
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="form-stack"
|
||||
@submit.prevent="submitSettings"
|
||||
>
|
||||
<div class="details-grid">
|
||||
<div class="detail-row">
|
||||
<label class="field">
|
||||
<span>{{ t('userSettings.firstname') }}</span>
|
||||
<input
|
||||
v-model="form.firstname"
|
||||
type="text"
|
||||
autocomplete="given-name"
|
||||
:disabled="userProfileStore.isUpdating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('userSettings.lastname') }}</span>
|
||||
<input
|
||||
v-model="form.lastname"
|
||||
type="text"
|
||||
autocomplete="family-name"
|
||||
:disabled="userProfileStore.isUpdating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('userSettings.alias') }}</span>
|
||||
<strong>{{ alias }}</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>{{ t('userSettings.fullName') }}</span>
|
||||
<strong>{{ fullname }}</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<input
|
||||
v-model="form.alias"
|
||||
type="text"
|
||||
autocomplete="nickname"
|
||||
:placeholder="fullname"
|
||||
:disabled="userProfileStore.isUpdating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('userSettings.email') }}</span>
|
||||
<strong>{{ email }}</strong>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
:disabled="userProfileStore.isUpdating"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="primary-button"
|
||||
type="submit"
|
||||
:disabled="!canSave"
|
||||
>
|
||||
{{ userProfileStore.isUpdating ? t('common.saving') : t('userSettings.saveDetails') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<ImageCropperDialog
|
||||
@@ -84,6 +207,7 @@
|
||||
:confirm-label="t('userSettings.savePortrait')"
|
||||
:upload-label="t('userSettings.choosePortrait')"
|
||||
:is-saving="isSavingPortrait"
|
||||
:initial-url="userProfileStore.portraitUrl"
|
||||
@save="savePortrait"
|
||||
/>
|
||||
</section>
|
||||
@@ -107,8 +231,7 @@
|
||||
.page-header p,
|
||||
.panel-heading span,
|
||||
.hero-identity span,
|
||||
.hero-identity small,
|
||||
.detail-row span {
|
||||
.hero-identity small {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
@@ -128,8 +251,7 @@
|
||||
}
|
||||
|
||||
.hero-identity strong,
|
||||
.panel-heading strong,
|
||||
.detail-row strong {
|
||||
.panel-heading strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
@@ -149,10 +271,45 @@
|
||||
@apply grid gap-4 md:grid-cols-2;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
@apply flex flex-col gap-1 rounded-[1.25rem] border p-4;
|
||||
.form-stack {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.field span {
|
||||
@apply text-sm font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.field input {
|
||||
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.field input:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
@apply flex justify-end;
|
||||
}
|
||||
|
||||
.page-message {
|
||||
@apply rounded-[1rem] border px-4 py-3 text-sm font-semibold;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
border-color: rgba(15, 118, 110, 0.18);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.page-message.error {
|
||||
background: rgba(185, 28, 28, 0.08);
|
||||
border-color: rgba(185, 28, 28, 0.16);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
@@ -160,4 +317,9 @@
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { getTimeZoneOptions } from '@/features/workspaces/timeZones.js';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const timeZoneOptions = computed(() => getTimeZoneOptions(props.modelValue));
|
||||
|
||||
function updateValue(event) {
|
||||
emit('update:modelValue', event.target.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select
|
||||
class="time-zone-select"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
@change="updateValue"
|
||||
>
|
||||
<option
|
||||
v-for="timeZone in timeZoneOptions"
|
||||
:key="timeZone.value"
|
||||
:value="timeZone.value"
|
||||
>
|
||||
{{ timeZone.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.time-zone-select {
|
||||
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
||||
background: #fffdf8;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
color: #172033;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user