17 Commits

Author SHA1 Message Date
07458c1541 chore: remove unused bootstop-vdp-agentic.sh script
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-04-30 14:25:17 -04:00
a9bfdc460d chore: update docs to include feedback module 2026-04-30 14:22:09 -04:00
258554f9d4 chore: add a dev user 2026-04-30 14:09:52 -04:00
6731fb5d3a feat: add feedback review notification UI 2026-04-30 13:53:00 -04:00
5aaddbca40 feat: add feedback submission flow 2026-04-30 13:33:10 -04:00
1263e28c00 feat: add feedback comments activity notifications 2026-04-30 13:24:23 -04:00
4873f39192 feat: protect feedback screenshots 2026-04-30 13:15:19 -04:00
cb6948aa14 feat: add feedback backend foundation 2026-04-30 03:31:42 -04:00
f9960b4fc9 docs: add product feedback feature plan 2026-04-30 03:30:48 -04:00
2e4c16621d feat: allow editing user profile settings 2026-04-30 02:24:10 -04:00
60ce08ee86 fix: improve frontend surface contrast 2026-04-30 02:15:43 -04:00
0f3652c1a1 chore: fix some warnings 2026-04-30 02:04:27 -04:00
63738ad027 feat: update workspace settings 2026-04-30 02:03:42 -04:00
6177eec2bf fix: show workspace logo in selector 2026-04-30 02:02:31 -04:00
b51b8b4185 feat: use local blob storage 2026-04-30 01:57:37 -04:00
d222e33667 refactor: extract workspace selector 2026-04-30 01:44:03 -04:00
fcd80cd30f chore: update the browserlist db 2026-04-30 01:27:26 -04:00
112 changed files with 19942 additions and 1891 deletions

1
.gitignore vendored
View File

@@ -33,6 +33,7 @@ dist/
*.local
.env.local
.env.*.local
App_Data/
# Local SSL certificates
*.pem

View File

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

View File

@@ -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).
@@ -324,4 +327,4 @@ scripts/ai-task review docs/tasks/TASK-XXX.md
---
End of document.
End of document.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddFeedbackCommentsActivity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FeedbackActivityEntries",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
ActorUserId = table.Column<Guid>(type: "uuid", nullable: false),
ActorDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ActorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ActivityType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
FromValue = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
ToValue = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
Note = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackActivityEntries", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackActivityEntries_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "FeedbackComments",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
AuthorUserId = table.Column<Guid>(type: "uuid", nullable: false),
AuthorDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
AuthorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
AuthorRole = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Body = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackComments", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackComments_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_FeedbackActivityEntries_ActorUserId",
table: "FeedbackActivityEntries",
column: "ActorUserId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackActivityEntries_CreatedAt",
table: "FeedbackActivityEntries",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_FeedbackActivityEntries_FeedbackReportId",
table: "FeedbackActivityEntries",
column: "FeedbackReportId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackComments_AuthorUserId",
table: "FeedbackComments",
column: "AuthorUserId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackComments_CreatedAt",
table: "FeedbackComments",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_FeedbackComments_FeedbackReportId",
table: "FeedbackComments",
column: "FeedbackReportId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FeedbackActivityEntries");
migrationBuilder.DropTable(
name: "FeedbackComments");
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
namespace Socialize.Api.Modules.Feedback.Data;
public class FeedbackActivityEntry
{
public Guid Id { get; set; }
public Guid FeedbackReportId { get; set; }
public Guid ActorUserId { get; set; }
public string ActorDisplayName { get; set; } = string.Empty;
public string ActorEmail { get; set; } = string.Empty;
public string ActivityType { get; set; } = string.Empty;
public string? FromValue { get; set; }
public string? ToValue { get; set; }
public string? Note { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public FeedbackReport? FeedbackReport { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace Socialize.Api.Modules.Feedback.Data;
public class FeedbackComment
{
public Guid Id { get; set; }
public Guid FeedbackReportId { get; set; }
public Guid AuthorUserId { get; set; }
public string AuthorDisplayName { get; set; } = string.Empty;
public string AuthorEmail { get; set; } = string.Empty;
public string AuthorRole { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public FeedbackReport? FeedbackReport { get; set; }
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.Feedback.Data;
public enum FeedbackStatus
{
New,
Planned,
Resolved,
WontDo,
Cancelled,
}

View File

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

View File

@@ -0,0 +1,8 @@
namespace Socialize.Api.Modules.Feedback.Data;
public enum FeedbackType
{
Bug,
Suggestion,
Request,
}

View File

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

View File

@@ -0,0 +1,56 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Feedback.Contracts;
using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Feedback.Services;
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Feedback.Handlers;
public class AddDeveloperFeedbackCommentHandler(
AppDbContext dbContext,
FeedbackNotificationService notificationService)
: Endpoint<AddFeedbackCommentRequest, FeedbackTimelineItemDto>
{
public override void Configure()
{
Post("/api/feedback/{id}/comments");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(AddFeedbackCommentRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
FeedbackReport? report = await dbContext.FeedbackReports.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (report is null)
{
await SendNotFoundAsync(ct);
return;
}
Guid developerUserId = User.GetUserId();
DateTimeOffset now = DateTimeOffset.UtcNow;
FeedbackComment comment = new()
{
Id = Guid.NewGuid(),
FeedbackReportId = report.Id,
AuthorUserId = developerUserId,
AuthorDisplayName = User.GetAlias() ?? User.GetName(),
AuthorEmail = User.GetEmail(),
AuthorRole = "Developer",
Body = request.Body.Trim(),
CreatedAt = now,
};
report.LastActivityAt = now;
dbContext.FeedbackComments.Add(comment);
notificationService.AddDeveloperCommentNotification(report, developerUserId);
await dbContext.SaveChangesAsync(ct);
await SendAsync(comment.ToTimelineDto(), StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,71 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Feedback.Contracts;
using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Feedback.Services;
namespace Socialize.Api.Modules.Feedback.Handlers;
public record AddFeedbackCommentRequest(string Body);
public class AddFeedbackCommentRequestValidator
: Validator<AddFeedbackCommentRequest>
{
public AddFeedbackCommentRequestValidator()
{
RuleFor(x => x.Body).NotEmpty().MaximumLength(8000);
}
}
public class AddMyFeedbackCommentHandler(
AppDbContext dbContext,
FeedbackNotificationService notificationService)
: Endpoint<AddFeedbackCommentRequest, FeedbackTimelineItemDto>
{
public override void Configure()
{
Post("/api/my-feedback/{id}/comments");
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(AddFeedbackCommentRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
Guid reporterUserId = User.GetUserId();
FeedbackReport? report = await dbContext.FeedbackReports.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (report is null || !FeedbackAccessRules.CanReporterComment(report, reporterUserId))
{
await SendNotFoundAsync(ct);
return;
}
DateTimeOffset now = DateTimeOffset.UtcNow;
FeedbackComment comment = CreateComment(report.Id, reporterUserId, "Reporter", request.Body.Trim(), now);
report.LastActivityAt = now;
dbContext.FeedbackComments.Add(comment);
await notificationService.AddReporterCommentNotificationsAsync(report, reporterUserId, ct);
await dbContext.SaveChangesAsync(ct);
await SendAsync(comment.ToTimelineDto(), StatusCodes.Status201Created, ct);
}
private FeedbackComment CreateComment(Guid reportId, Guid userId, string authorRole, string body, DateTimeOffset now)
{
return new FeedbackComment
{
Id = Guid.NewGuid(),
FeedbackReportId = reportId,
AuthorUserId = userId,
AuthorDisplayName = User.GetAlias() ?? User.GetName(),
AuthorEmail = User.GetEmail(),
AuthorRole = authorRole,
Body = body,
CreatedAt = now,
};
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Feedback.Contracts;
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Feedback.Handlers;
public class GetDeveloperFeedbackTimelineHandler(AppDbContext dbContext)
: EndpointWithoutRequest<IReadOnlyCollection<FeedbackTimelineItemDto>>
{
public override void Configure()
{
Get("/api/feedback/{id}/timeline");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
bool exists = await dbContext.FeedbackReports.AnyAsync(candidate => candidate.Id == id, ct);
if (!exists)
{
await SendNotFoundAsync(ct);
return;
}
IReadOnlyCollection<FeedbackTimelineItemDto> timeline =
await GetMyFeedbackTimelineHandler.LoadTimelineAsync(dbContext, id, ct);
await SendOkAsync(timeline, ct);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Feedback.Contracts;
namespace Socialize.Api.Modules.Feedback.Handlers;
public class GetMyFeedbackTimelineHandler(AppDbContext dbContext)
: EndpointWithoutRequest<IReadOnlyCollection<FeedbackTimelineItemDto>>
{
public override void Configure()
{
Get("/api/my-feedback/{id}/timeline");
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
Guid reporterUserId = User.GetUserId();
bool canAccess = await dbContext.FeedbackReports
.AnyAsync(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId, ct);
if (!canAccess)
{
await SendNotFoundAsync(ct);
return;
}
await SendOkAsync(await LoadTimelineAsync(id, ct), ct);
}
internal static async Task<IReadOnlyCollection<FeedbackTimelineItemDto>> LoadTimelineAsync(
AppDbContext dbContext,
Guid feedbackReportId,
CancellationToken ct)
{
List<FeedbackTimelineItemDto> comments = await dbContext.FeedbackComments
.Where(comment => comment.FeedbackReportId == feedbackReportId)
.Select(comment => comment.ToTimelineDto())
.ToListAsync(ct);
List<FeedbackTimelineItemDto> activity = await dbContext.FeedbackActivityEntries
.Where(entry => entry.FeedbackReportId == feedbackReportId)
.Select(entry => entry.ToTimelineDto())
.ToListAsync(ct);
return comments
.Concat(activity)
.OrderBy(item => item.CreatedAt)
.ThenBy(item => item.Kind)
.ToArray();
}
private Task<IReadOnlyCollection<FeedbackTimelineItemDto>> LoadTimelineAsync(Guid feedbackReportId, CancellationToken ct)
{
return LoadTimelineAsync(dbContext, feedbackReportId, ct);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
namespace Socialize.Api.Modules.Feedback.Services;
public static class FeedbackActivityTypes
{
public const string StatusChanged = "StatusChanged";
public const string TypeChanged = "TypeChanged";
public const string TagsChanged = "TagsChanged";
public const string Cancelled = "Cancelled";
}

View File

@@ -0,0 +1,26 @@
using System.Text.Json;
namespace Socialize.Api.Modules.Feedback.Services;
public static class FeedbackNotificationRoutes
{
public static string ForDeveloper(Guid feedbackReportId)
{
return $"/app/feedback/{feedbackReportId}";
}
public static string ForReporter(Guid feedbackReportId)
{
return $"/app/my-feedback/{feedbackReportId}";
}
public static string BuildMetadataJson(Guid feedbackReportId, bool developerRoute)
{
return JsonSerializer.Serialize(new
{
route = developerRoute ? ForDeveloper(feedbackReportId) : ForReporter(feedbackReportId),
feedbackReportId,
isFeedbackNotification = true
});
}
}

View File

@@ -0,0 +1,121 @@
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Feedback.Contracts;
using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Notifications.Data;
namespace Socialize.Api.Modules.Feedback.Services;
public class FeedbackNotificationService(AppDbContext dbContext)
{
private const string EntityType = "FeedbackReport";
public async Task AddNewReportNotificationsAsync(FeedbackReport report, CancellationToken ct)
{
List<FeedbackNotificationRecipient> developers = await GetDeveloperRecipientsAsync(ct);
foreach (FeedbackNotificationRecipient developer in developers.Where(developer => developer.UserId != report.ReporterUserId))
{
AddNotification(
report,
"Feedback.ReportCreated",
$"New feedback from {report.ReporterDisplayName}",
developer.UserId,
developer.Email,
developerRoute: true);
}
}
public void AddDeveloperCommentNotification(FeedbackReport report, Guid developerUserId)
{
if (report.ReporterUserId == developerUserId)
{
return;
}
AddNotification(
report,
"Feedback.DeveloperCommented",
$"A developer commented on your feedback",
report.ReporterUserId,
report.ReporterEmail,
developerRoute: false);
}
public void AddDeveloperStatusNotification(FeedbackReport report, Guid developerUserId, FeedbackStatus nextStatus)
{
if (report.ReporterUserId == developerUserId)
{
return;
}
AddNotification(
report,
"Feedback.StatusChanged",
$"Your feedback status changed to {nextStatus.ToFeedbackDisplayString()}",
report.ReporterUserId,
report.ReporterEmail,
developerRoute: false);
}
public async Task AddReporterCommentNotificationsAsync(FeedbackReport report, Guid reporterUserId, CancellationToken ct)
{
List<FeedbackNotificationRecipient> developerParticipants = await dbContext.FeedbackComments
.Where(comment => comment.FeedbackReportId == report.Id &&
comment.AuthorUserId != reporterUserId &&
comment.AuthorRole == "Developer")
.Select(comment => new FeedbackNotificationRecipient(comment.AuthorUserId, comment.AuthorEmail))
.Distinct()
.ToListAsync(ct);
foreach (FeedbackNotificationRecipient developer in developerParticipants)
{
AddNotification(
report,
"Feedback.ReporterCommented",
$"{report.ReporterDisplayName} replied to feedback",
developer.UserId,
developer.Email,
developerRoute: true);
}
}
private async Task<List<FeedbackNotificationRecipient>> GetDeveloperRecipientsAsync(CancellationToken ct)
{
return await (
from userRole in dbContext.UserRoles
join role in dbContext.Roles on userRole.RoleId equals role.Id
join user in dbContext.Users on userRole.UserId equals user.Id
where role.Name == KnownRoles.Developer
select new FeedbackNotificationRecipient(user.Id, user.Email ?? string.Empty))
.Distinct()
.ToListAsync(ct);
}
private void AddNotification(
FeedbackReport report,
string eventType,
string message,
Guid recipientUserId,
string? recipientEmail,
bool developerRoute)
{
dbContext.NotificationEvents.Add(new NotificationEvent
{
Id = Guid.NewGuid(),
WorkspaceId = report.WorkspaceId ?? Guid.Empty,
ContentItemId = report.ContentItemId,
EventType = eventType,
EntityType = EntityType,
EntityId = report.Id,
Message = message,
RecipientUserId = recipientUserId,
RecipientEmail = recipientEmail,
MetadataJson = FeedbackNotificationRoutes.BuildMetadataJson(report.Id, developerRoute),
CreatedAt = DateTimeOffset.UtcNow,
});
}
private sealed record FeedbackNotificationRecipient(Guid UserId, string? Email);
}

View File

@@ -0,0 +1,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();
}
}

View File

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

View File

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

View File

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

View File

@@ -54,13 +54,20 @@ public class GetNotificationsHandler(
}
IQueryable<NotificationEvent> query = dbContext.NotificationEvents.AsQueryable();
Guid currentUserId = User.GetUserId();
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
query = query.Where(notificationEvent => workspaceScopeIds.Contains(notificationEvent.WorkspaceId));
query = query.Where(notificationEvent =>
workspaceScopeIds.Contains(notificationEvent.WorkspaceId) ||
notificationEvent.RecipientUserId == currentUserId);
}
query = query.Where(notificationEvent =>
notificationEvent.RecipientUserId == null ||
notificationEvent.RecipientUserId == currentUserId);
if (request.WorkspaceId.HasValue)
{
query = query.Where(notificationEvent => notificationEvent.WorkspaceId == request.WorkspaceId.Value);

View File

@@ -28,7 +28,9 @@ public class MarkNotificationAsReadHandler(
return;
}
if (!accessScopeService.CanAccessWorkspace(User, notificationEvent.WorkspaceId))
Guid currentUserId = User.GetUserId();
bool canReadRecipientNotification = notificationEvent.RecipientUserId == currentUserId;
if (!canReadRecipientNotification && !accessScopeService.CanAccessWorkspace(User, notificationEvent.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;

View File

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

View File

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

View File

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

View File

@@ -75,6 +75,7 @@ public class CreateWorkspaceHandler(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.CreatedAt);

View File

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

View File

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

View File

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

View File

@@ -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" />

View File

@@ -10,6 +10,10 @@
"Website": {
"FrontendBaseUrl": "http://localhost:5173"
},
"LocalBlobStorage": {
"RootPath": "App_Data/blob-storage",
"RequestPath": "/api/storage"
},
"Authentication": {
"Jwt": {
"Issuer": "http://localhost:5080",

View 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

View File

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

View File

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

View File

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

View 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.

View 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
```

View File

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

View File

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

View File

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

View File

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

View File

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

View 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@
"@vueuse/head": "^2.0.0",
"@xtiannyeto/vue-auth-social": "^0.1.9",
"axios": "^1.6.7",
"html2canvas": "^1.4.1",
"i18n": "^0.15.1",
"jwt-decode": "^4.0.0",
"pinia": "^2.1.7",
@@ -2161,6 +2162,15 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -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",

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 class="details-grid">
<div class="detail-row">
<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">
<span>{{ t('userSettings.email') }}</span>
<strong>{{ email }}</strong>
</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">
<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>
<input
v-model="form.alias"
type="text"
autocomplete="nickname"
:placeholder="fullname"
:disabled="userProfileStore.isUpdating"
/>
</label>
<label class="field">
<span>{{ t('userSettings.email') }}</span>
<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>

View File

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