Compare commits
10 Commits
43bcf449fd
...
cb6948aa14
| Author | SHA1 | Date | |
|---|---|---|---|
| cb6948aa14 | |||
| f9960b4fc9 | |||
| 2e4c16621d | |||
| 60ce08ee86 | |||
| 0f3652c1a1 | |||
| 63738ad027 | |||
| 6177eec2bf | |||
| b51b8b4185 | |||
| d222e33667 | |||
| fcd80cd30f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ dist/
|
|||||||
*.local
|
*.local
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
App_Data/
|
||||||
|
|
||||||
# Local SSL certificates
|
# Local SSL certificates
|
||||||
*.pem
|
*.pem
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# PROMPT TEMPLATES
|
# 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
|
## Purpose
|
||||||
This document standardizes how we interact with AI coding agents (Codex, Claude, etc).
|
This document standardizes how we interact with AI coding agents (Codex, Claude, etc).
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Socialize.Api.Modules.Assets.Data;
|
|||||||
using Socialize.Api.Modules.Clients.Data;
|
using Socialize.Api.Modules.Clients.Data;
|
||||||
using Socialize.Api.Modules.Comments.Data;
|
using Socialize.Api.Modules.Comments.Data;
|
||||||
using Socialize.Api.Modules.ContentItems.Data;
|
using Socialize.Api.Modules.ContentItems.Data;
|
||||||
|
using Socialize.Api.Modules.Feedback.Data;
|
||||||
using Socialize.Api.Modules.Identity.Data;
|
using Socialize.Api.Modules.Identity.Data;
|
||||||
using Socialize.Api.Modules.Notifications.Data;
|
using Socialize.Api.Modules.Notifications.Data;
|
||||||
using Socialize.Api.Modules.Projects.Data;
|
using Socialize.Api.Modules.Projects.Data;
|
||||||
@@ -28,18 +29,21 @@ public class AppDbContext(
|
|||||||
public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
|
public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
|
||||||
public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>();
|
public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>();
|
||||||
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
|
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
|
||||||
|
public DbSet<FeedbackReport> FeedbackReports => Set<FeedbackReport>();
|
||||||
|
public DbSet<FeedbackTag> FeedbackTags => Set<FeedbackTag>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(builder);
|
||||||
|
|
||||||
modelBuilder.ConfigureWorkspacesModule();
|
builder.ConfigureWorkspacesModule();
|
||||||
modelBuilder.ConfigureClientsModule();
|
builder.ConfigureClientsModule();
|
||||||
modelBuilder.ConfigureProjectsModule();
|
builder.ConfigureProjectsModule();
|
||||||
modelBuilder.ConfigureContentItemsModule();
|
builder.ConfigureContentItemsModule();
|
||||||
modelBuilder.ConfigureAssetsModule();
|
builder.ConfigureAssetsModule();
|
||||||
modelBuilder.ConfigureCommentsModule();
|
builder.ConfigureCommentsModule();
|
||||||
modelBuilder.ConfigureApprovalsModule();
|
builder.ConfigureApprovalsModule();
|
||||||
modelBuilder.ConfigureNotificationsModule();
|
builder.ConfigureNotificationsModule();
|
||||||
|
builder.ConfigureFeedbackModule();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ public static class DependencyInjection
|
|||||||
{
|
{
|
||||||
using IServiceScope scope = app.ApplicationServices.CreateScope();
|
using IServiceScope scope = app.ApplicationServices.CreateScope();
|
||||||
await using AppDbContext context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
await using AppDbContext context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
await context.Database.EnsureCreatedAsync(cancellationToken);
|
await context.Database.MigrateAsync(cancellationToken);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||||
|
|
||||||
|
public sealed class LocalBlobStorageOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "LocalBlobStorage";
|
||||||
|
|
||||||
|
public string RootPath { get; set; } = "App_Data/blob-storage";
|
||||||
|
|
||||||
|
public string RequestPath { get; set; } = "/api/storage";
|
||||||
|
}
|
||||||
@@ -4,5 +4,6 @@ internal static class ContainerNames
|
|||||||
{
|
{
|
||||||
public const string Users = "users";
|
public const string Users = "users";
|
||||||
public const string Clients = "clients";
|
public const string Clients = "clients";
|
||||||
|
public const string Workspaces = "workspaces";
|
||||||
public const string Creators = "creators";
|
public const string Creators = "creators";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
using Azure;
|
|
||||||
using Azure.Storage.Blobs;
|
|
||||||
using Azure.Storage.Blobs.Models;
|
|
||||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
|
||||||
|
|
||||||
namespace Socialize.Api.Infrastructure.BlobStorage.Services;
|
|
||||||
|
|
||||||
public class AzureBlobStorage : IBlobStorage
|
|
||||||
{
|
|
||||||
private const long MaxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes
|
|
||||||
|
|
||||||
private readonly BlobServiceClient _blobServiceClient;
|
|
||||||
private readonly ILogger<AzureBlobStorage> _logger;
|
|
||||||
|
|
||||||
public AzureBlobStorage(IConfiguration configuration, ILogger<AzureBlobStorage> logger)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
string? connectionString = configuration.GetConnectionString("AzureBlob");
|
|
||||||
_blobServiceClient = new BlobServiceClient(connectionString);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Upload a file to microsoft azure blob storage.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="containerName">The name of the container where the file is stored.</param>
|
|
||||||
/// <param name="blobName">The blob name (path within the container, include the file name).</param>
|
|
||||||
/// <param name="stream"></param>
|
|
||||||
/// <param name="contentType">The content type.</param>
|
|
||||||
/// <param name="ct">The cancellation token</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task<string> UploadFileAsync(
|
|
||||||
string containerName,
|
|
||||||
string blobName,
|
|
||||||
Stream stream,
|
|
||||||
string contentType,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
// Read the file stream into a memory stream to determine the length
|
|
||||||
// WATCH FOR MEMORY USAGE USING THE MEMORY STREAM.
|
|
||||||
stream.Position = 0;
|
|
||||||
|
|
||||||
// Check if the file size exceeds the maximum upload size
|
|
||||||
if (stream.Length > MaxUploadSize)
|
|
||||||
{
|
|
||||||
_logger.LogError(
|
|
||||||
$"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate content type
|
|
||||||
if (!ContentTypes.IsAllowed(contentType, stream))
|
|
||||||
{
|
|
||||||
_logger.LogError(
|
|
||||||
$"Blob storage: Unsupported file type {contentType}.");
|
|
||||||
throw new InvalidOperationException("Unsupported file type.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Get a reference to a container
|
|
||||||
BlobContainerClient? containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
|
|
||||||
|
|
||||||
// Create the container if it does not exist
|
|
||||||
await containerClient.CreateIfNotExistsAsync(
|
|
||||||
PublicAccessType.Blob,
|
|
||||||
cancellationToken: ct);
|
|
||||||
|
|
||||||
// Get a reference to a blob
|
|
||||||
BlobClient? blobClient = containerClient.GetBlobClient(blobName);
|
|
||||||
|
|
||||||
// Define the BlobHttpHeaders to include the content type
|
|
||||||
BlobHttpHeaders blobHttpHeaders = new() { ContentType = contentType };
|
|
||||||
|
|
||||||
// Upload the file
|
|
||||||
Response<BlobContentInfo>? response = await blobClient.UploadAsync(
|
|
||||||
stream,
|
|
||||||
new BlobUploadOptions { HttpHeaders = blobHttpHeaders },
|
|
||||||
ct);
|
|
||||||
|
|
||||||
string fileUri = blobClient.Uri.ToString();
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
|
||||||
"""
|
|
||||||
Blob storage: Status [ {ResponseStatus} ]
|
|
||||||
Uploaded [ {BlobName} ] to the container [ {ContainerName} ]
|
|
||||||
with contentType [ {ContentType} ]
|
|
||||||
with a length of [ {StreamLength} bytes ]
|
|
||||||
with the uri [ {FileUri} ]
|
|
||||||
""",
|
|
||||||
response.GetRawResponse().Status.ToString(),
|
|
||||||
blobName,
|
|
||||||
containerName,
|
|
||||||
contentType,
|
|
||||||
stream.Length,
|
|
||||||
fileUri
|
|
||||||
);
|
|
||||||
|
|
||||||
// Return the URI of the uploaded blob
|
|
||||||
return fileUri;
|
|
||||||
}
|
|
||||||
catch (RequestFailedException ex)
|
|
||||||
{
|
|
||||||
_logger.LogError($"Blob storage: Azure Storage request failed: {ex.Message}");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError($"Blob storage: An error occurred: {ex.Message}");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Download a file to microsoft's azure blob storage.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="blobName">The blob name (path within the container).</param>
|
|
||||||
/// <param name="containerName">The name of the container where the file is stored. (users)</param>
|
|
||||||
/// <param name="ct">The cancellation token for the request</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task<MemoryStream> DownloadFileAsync(
|
|
||||||
string containerName,
|
|
||||||
string blobName,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Get a reference to a container
|
|
||||||
BlobContainerClient? containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
|
|
||||||
|
|
||||||
// Get a reference to a blob
|
|
||||||
BlobClient? blobClient = containerClient.GetBlobClient(blobName);
|
|
||||||
|
|
||||||
// Download the blob to a stream
|
|
||||||
BlobDownloadInfo download = await blobClient.DownloadAsync(ct);
|
|
||||||
|
|
||||||
MemoryStream memoryStream = new();
|
|
||||||
await download.Content.CopyToAsync(memoryStream, ct);
|
|
||||||
memoryStream.Position = 0; // Ensure the stream is at the beginning
|
|
||||||
|
|
||||||
return memoryStream;
|
|
||||||
}
|
|
||||||
catch (RequestFailedException ex)
|
|
||||||
{
|
|
||||||
_logger.LogError($"Azure Storage request failed: {ex.Message}");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError($"An error occurred: {ex.Message}");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||||
|
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||||
|
|
||||||
|
public sealed class LocalBlobStorage(
|
||||||
|
IWebHostEnvironment environment,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
IOptions<LocalBlobStorageOptions> options,
|
||||||
|
ILogger<LocalBlobStorage> logger)
|
||||||
|
: IBlobStorage
|
||||||
|
{
|
||||||
|
private const long MaxUploadSize = 10 * 1024 * 1024;
|
||||||
|
private const string ContentTypeMetadataSuffix = ".content-type";
|
||||||
|
|
||||||
|
private readonly LocalBlobStorageOptions _options = options.Value;
|
||||||
|
|
||||||
|
public async Task<string> UploadFileAsync(
|
||||||
|
string containerName,
|
||||||
|
string blobName,
|
||||||
|
Stream stream,
|
||||||
|
string contentType,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
stream.Position = 0;
|
||||||
|
|
||||||
|
if (stream.Length > MaxUploadSize)
|
||||||
|
{
|
||||||
|
logger.LogError("Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.", MaxUploadSize);
|
||||||
|
throw new InvalidOperationException($"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ContentTypes.IsAllowed(contentType, stream))
|
||||||
|
{
|
||||||
|
logger.LogError("Blob storage: Unsupported file type {ContentType}.", contentType);
|
||||||
|
throw new InvalidOperationException("Unsupported file type.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string relativePath = GetSafeRelativePath(containerName, blobName);
|
||||||
|
string filePath = Path.Combine(GetRootPath(), relativePath);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? GetRootPath());
|
||||||
|
|
||||||
|
await using FileStream fileStream = File.Create(filePath);
|
||||||
|
await stream.CopyToAsync(fileStream, ct);
|
||||||
|
await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct);
|
||||||
|
|
||||||
|
string fileUri = BuildPublicUrl(relativePath);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Blob storage: Uploaded [{BlobName}] to local container [{ContainerName}] with contentType [{ContentType}] and uri [{FileUri}]",
|
||||||
|
blobName,
|
||||||
|
containerName,
|
||||||
|
contentType,
|
||||||
|
fileUri);
|
||||||
|
|
||||||
|
return fileUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MemoryStream> DownloadFileAsync(
|
||||||
|
string containerName,
|
||||||
|
string blobName,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName));
|
||||||
|
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException("Blob storage: Local file was not found.", blobName);
|
||||||
|
}
|
||||||
|
|
||||||
|
MemoryStream memoryStream = new();
|
||||||
|
await using FileStream fileStream = File.OpenRead(filePath);
|
||||||
|
await fileStream.CopyToAsync(memoryStream, ct);
|
||||||
|
memoryStream.Position = 0;
|
||||||
|
|
||||||
|
return memoryStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal string GetRootPath()
|
||||||
|
{
|
||||||
|
if (Path.IsPathRooted(_options.RootPath))
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(_options.RootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(environment.ContentRootPath, _options.RootPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string? ReadContentType(string filePath)
|
||||||
|
{
|
||||||
|
string metadataPath = GetContentTypeMetadataPath(filePath);
|
||||||
|
return File.Exists(metadataPath)
|
||||||
|
? File.ReadAllText(metadataPath)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetContentTypeMetadataPath(string filePath)
|
||||||
|
{
|
||||||
|
return $"{filePath}{ContentTypeMetadataSuffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSafeRelativePath(string containerName, string blobName)
|
||||||
|
{
|
||||||
|
if (Path.IsPathRooted(containerName) || Path.IsPathRooted(blobName))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Blob storage: Blob paths must be relative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] pathParts = [containerName, .. blobName.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar])];
|
||||||
|
if (pathParts.Any(part => part is "" or "." or ".."))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Blob storage: Blob paths must not contain relative path segments.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(pathParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildPublicUrl(string relativePath)
|
||||||
|
{
|
||||||
|
HttpRequest? request = httpContextAccessor.HttpContext?.Request;
|
||||||
|
string requestPath = NormalizeRequestPath(_options.RequestPath);
|
||||||
|
string urlPath = $"{requestPath}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
|
||||||
|
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
return urlPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{request.Scheme}://{request.Host}{request.PathBase}{urlPath}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string NormalizeRequestPath(string requestPath)
|
||||||
|
{
|
||||||
|
string normalized = string.IsNullOrWhiteSpace(requestPath)
|
||||||
|
? "/api/storage"
|
||||||
|
: requestPath.Trim();
|
||||||
|
|
||||||
|
return normalized.StartsWith("/", StringComparison.Ordinal)
|
||||||
|
? normalized.TrimEnd('/')
|
||||||
|
: $"/{normalized.TrimEnd('/')}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||||
using Socialize.Api.Infrastructure.BlobStorage.Services;
|
using Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||||
|
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||||
using Socialize.Api.Infrastructure.Configuration;
|
using Socialize.Api.Infrastructure.Configuration;
|
||||||
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||||
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||||
@@ -16,7 +17,10 @@ public static class DependencyInjection
|
|||||||
builder.Services.Configure<WebsiteOptions>(
|
builder.Services.Configure<WebsiteOptions>(
|
||||||
builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName));
|
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.Services.Configure<StripeOptions>(
|
||||||
builder.Configuration.GetSection(StripeOptions.ConfigurationSection));
|
builder.Configuration.GetSection(StripeOptions.ConfigurationSection));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Socialize.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260430054500_AddWorkspaceLogo")]
|
||||||
|
public partial class AddWorkspaceLogo : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "LogoUrl",
|
||||||
|
table: "Workspaces",
|
||||||
|
type: "character varying(2048)",
|
||||||
|
maxLength: 2048,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LogoUrl",
|
||||||
|
table: "Workspaces");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1105
backend/src/Socialize.Api/Migrations/20260430072517_AddFeedbackFoundation.Designer.cs
generated
Normal file
1105
backend/src/Socialize.Api/Migrations/20260430072517_AddFeedbackFoundation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Socialize.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFeedbackFoundation : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "FeedbackReports",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
Description = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: false),
|
||||||
|
ReporterUserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ReporterDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
ReporterEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
SubmittedPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||||
|
BrowserUserAgent = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
ViewportWidth = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
ViewportHeight = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
AppVersion = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
|
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
WorkspaceName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
ClientId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
ClientName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
ProjectId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
ProjectName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
ContentItemId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
ContentItemTitle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||||
|
LastActivityAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CancelledAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
CancelledByUserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
CancellationReason = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_FeedbackReports", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "FeedbackTags",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
NormalizedName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_FeedbackTags", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_FeedbackTags_FeedbackReports_FeedbackReportId",
|
||||||
|
column: x => x.FeedbackReportId,
|
||||||
|
principalTable: "FeedbackReports",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FeedbackReports_LastActivityAt",
|
||||||
|
table: "FeedbackReports",
|
||||||
|
column: "LastActivityAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FeedbackReports_ReporterUserId",
|
||||||
|
table: "FeedbackReports",
|
||||||
|
column: "ReporterUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FeedbackReports_Status",
|
||||||
|
table: "FeedbackReports",
|
||||||
|
column: "Status");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FeedbackReports_Type",
|
||||||
|
table: "FeedbackReports",
|
||||||
|
column: "Type");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FeedbackReports_WorkspaceId",
|
||||||
|
table: "FeedbackReports",
|
||||||
|
column: "WorkspaceId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FeedbackTags_FeedbackReportId_NormalizedName",
|
||||||
|
table: "FeedbackTags",
|
||||||
|
columns: new[] { "FeedbackReportId", "NormalizedName" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FeedbackTags_NormalizedName",
|
||||||
|
table: "FeedbackTags",
|
||||||
|
column: "NormalizedName");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "FeedbackTags");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "FeedbackReports");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,7 +125,7 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("AspNetUserTokens", (string)null);
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -168,7 +168,7 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("ApprovalDecisions", (string)null);
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -230,7 +230,7 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("ApprovalRequests", (string)null);
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -286,7 +286,7 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("Assets", (string)null);
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -329,7 +329,7 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("AssetRevisions", (string)null);
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -379,7 +379,7 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("Clients", (string)null);
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -434,7 +434,7 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("Comments", (string)null);
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -500,7 +500,7 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("ContentItems", (string)null);
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -558,7 +558,150 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("ContentItemRevisions", (string)null);
|
b.ToTable("ContentItemRevisions", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Socialize.Modules.Identity.Data.Role", b =>
|
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.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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -585,7 +728,7 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("AspNetRoles", (string)null);
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -688,7 +831,7 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("AspNetUsers", (string)null);
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -750,7 +893,7 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("NotificationEvents", (string)null);
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -803,7 +946,7 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("Projects", (string)null);
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -814,6 +957,10 @@ namespace Socialize.Api.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
|
||||||
|
b.Property<string>("LogoUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
@@ -842,7 +989,7 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("Workspaces", (string)null);
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -885,7 +1032,7 @@ namespace Socialize.Api.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
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()
|
.WithMany()
|
||||||
.HasForeignKey("RoleId")
|
.HasForeignKey("RoleId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
@@ -894,7 +1041,7 @@ namespace Socialize.Api.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
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()
|
.WithMany()
|
||||||
.HasForeignKey("UserId")
|
.HasForeignKey("UserId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
@@ -903,7 +1050,7 @@ namespace Socialize.Api.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
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()
|
.WithMany()
|
||||||
.HasForeignKey("UserId")
|
.HasForeignKey("UserId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
@@ -912,13 +1059,13 @@ namespace Socialize.Api.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
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()
|
.WithMany()
|
||||||
.HasForeignKey("RoleId")
|
.HasForeignKey("RoleId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("Socialize.Modules.Identity.Data.User", null)
|
b.HasOne("Socialize.Api.Modules.Identity.Data.User", null)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("UserId")
|
.HasForeignKey("UserId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
@@ -927,12 +1074,28 @@ namespace Socialize.Api.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
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()
|
.WithMany()
|
||||||
.HasForeignKey("UserId")
|
.HasForeignKey("UserId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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("Tags");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Socialize.Api.Modules.Feedback.Data;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Feedback.Contracts;
|
||||||
|
|
||||||
|
public record FeedbackContextDto(
|
||||||
|
Guid? WorkspaceId,
|
||||||
|
string? WorkspaceName,
|
||||||
|
Guid? ClientId,
|
||||||
|
string? ClientName,
|
||||||
|
Guid? ProjectId,
|
||||||
|
string? ProjectName,
|
||||||
|
Guid? ContentItemId,
|
||||||
|
string? ContentItemTitle);
|
||||||
|
|
||||||
|
public record FeedbackMetadataDto(
|
||||||
|
string SubmittedPath,
|
||||||
|
string? BrowserUserAgent,
|
||||||
|
int? ViewportWidth,
|
||||||
|
int? ViewportHeight,
|
||||||
|
string? AppVersion);
|
||||||
|
|
||||||
|
public record FeedbackReportDto(
|
||||||
|
Guid Id,
|
||||||
|
string Type,
|
||||||
|
string Status,
|
||||||
|
string Description,
|
||||||
|
Guid ReporterUserId,
|
||||||
|
string ReporterDisplayName,
|
||||||
|
string ReporterEmail,
|
||||||
|
FeedbackMetadataDto Metadata,
|
||||||
|
FeedbackContextDto Context,
|
||||||
|
IReadOnlyCollection<string> Tags,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset LastActivityAt,
|
||||||
|
DateTimeOffset? CancelledAt,
|
||||||
|
string? CancellationReason);
|
||||||
|
|
||||||
|
public static class FeedbackDtoMapper
|
||||||
|
{
|
||||||
|
public static FeedbackReportDto ToDto(this FeedbackReport report)
|
||||||
|
{
|
||||||
|
return new FeedbackReportDto(
|
||||||
|
report.Id,
|
||||||
|
ToDisplayString(report.Type),
|
||||||
|
ToDisplayString(report.Status),
|
||||||
|
report.Description,
|
||||||
|
report.ReporterUserId,
|
||||||
|
report.ReporterDisplayName,
|
||||||
|
report.ReporterEmail,
|
||||||
|
new FeedbackMetadataDto(
|
||||||
|
report.SubmittedPath,
|
||||||
|
report.BrowserUserAgent,
|
||||||
|
report.ViewportWidth,
|
||||||
|
report.ViewportHeight,
|
||||||
|
report.AppVersion),
|
||||||
|
new FeedbackContextDto(
|
||||||
|
report.WorkspaceId,
|
||||||
|
report.WorkspaceName,
|
||||||
|
report.ClientId,
|
||||||
|
report.ClientName,
|
||||||
|
report.ProjectId,
|
||||||
|
report.ProjectName,
|
||||||
|
report.ContentItemId,
|
||||||
|
report.ContentItemTitle),
|
||||||
|
report.Tags.OrderBy(tag => tag.Name).Select(tag => tag.Name).ToArray(),
|
||||||
|
report.CreatedAt,
|
||||||
|
report.LastActivityAt,
|
||||||
|
report.CancelledAt,
|
||||||
|
report.CancellationReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToDisplayString(FeedbackType type)
|
||||||
|
{
|
||||||
|
return type.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToDisplayString(FeedbackStatus status)
|
||||||
|
{
|
||||||
|
return status == FeedbackStatus.WontDo ? "Won't Do" : status.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Feedback.Data;
|
||||||
|
|
||||||
|
public static class FeedbackModelConfiguration
|
||||||
|
{
|
||||||
|
public static ModelBuilder ConfigureFeedbackModule(this ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<FeedbackReport>(feedback =>
|
||||||
|
{
|
||||||
|
feedback.ToTable("FeedbackReports");
|
||||||
|
feedback.HasKey(x => x.Id);
|
||||||
|
feedback.Property(x => x.Type).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||||
|
feedback.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||||
|
feedback.Property(x => x.Description).HasMaxLength(8000).IsRequired();
|
||||||
|
feedback.Property(x => x.ReporterDisplayName).HasMaxLength(256).IsRequired();
|
||||||
|
feedback.Property(x => x.ReporterEmail).HasMaxLength(256).IsRequired();
|
||||||
|
feedback.Property(x => x.SubmittedPath).HasMaxLength(2048).IsRequired();
|
||||||
|
feedback.Property(x => x.BrowserUserAgent).HasMaxLength(1024);
|
||||||
|
feedback.Property(x => x.AppVersion).HasMaxLength(128);
|
||||||
|
feedback.Property(x => x.WorkspaceName).HasMaxLength(256);
|
||||||
|
feedback.Property(x => x.ClientName).HasMaxLength(256);
|
||||||
|
feedback.Property(x => x.ProjectName).HasMaxLength(256);
|
||||||
|
feedback.Property(x => x.ContentItemTitle).HasMaxLength(256);
|
||||||
|
feedback.Property(x => x.CancellationReason).HasMaxLength(2000);
|
||||||
|
feedback.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
feedback.HasIndex(x => x.ReporterUserId);
|
||||||
|
feedback.HasIndex(x => x.Status);
|
||||||
|
feedback.HasIndex(x => x.Type);
|
||||||
|
feedback.HasIndex(x => x.WorkspaceId);
|
||||||
|
feedback.HasIndex(x => x.LastActivityAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<FeedbackTag>(tag =>
|
||||||
|
{
|
||||||
|
tag.ToTable("FeedbackTags");
|
||||||
|
tag.HasKey(x => x.Id);
|
||||||
|
tag.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
||||||
|
tag.Property(x => x.NormalizedName).HasMaxLength(64).IsRequired();
|
||||||
|
tag.HasIndex(x => x.NormalizedName);
|
||||||
|
tag.HasIndex(x => new { x.FeedbackReportId, x.NormalizedName }).IsUnique();
|
||||||
|
tag.HasOne(x => x.FeedbackReport)
|
||||||
|
.WithMany(x => x.Tags)
|
||||||
|
.HasForeignKey(x => x.FeedbackReportId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
return modelBuilder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Socialize.Api.Modules.Feedback.Data;
|
||||||
|
|
||||||
|
public class FeedbackReport
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public FeedbackType Type { get; set; }
|
||||||
|
public FeedbackStatus Status { get; set; }
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public Guid ReporterUserId { get; set; }
|
||||||
|
public string ReporterDisplayName { get; set; } = string.Empty;
|
||||||
|
public string ReporterEmail { get; set; } = string.Empty;
|
||||||
|
public string SubmittedPath { get; set; } = string.Empty;
|
||||||
|
public string? BrowserUserAgent { get; set; }
|
||||||
|
public int? ViewportWidth { get; set; }
|
||||||
|
public int? ViewportHeight { get; set; }
|
||||||
|
public string? AppVersion { get; set; }
|
||||||
|
public Guid? WorkspaceId { get; set; }
|
||||||
|
public string? WorkspaceName { get; set; }
|
||||||
|
public Guid? ClientId { get; set; }
|
||||||
|
public string? ClientName { get; set; }
|
||||||
|
public Guid? ProjectId { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
public Guid? ContentItemId { get; set; }
|
||||||
|
public string? ContentItemTitle { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
public DateTimeOffset LastActivityAt { get; set; }
|
||||||
|
public DateTimeOffset? CancelledAt { get; set; }
|
||||||
|
public Guid? CancelledByUserId { get; set; }
|
||||||
|
public string? CancellationReason { get; set; }
|
||||||
|
public ICollection<FeedbackTag> Tags { get; } = new List<FeedbackTag>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Socialize.Api.Modules.Feedback.Data;
|
||||||
|
|
||||||
|
public enum FeedbackStatus
|
||||||
|
{
|
||||||
|
New,
|
||||||
|
Planned,
|
||||||
|
Resolved,
|
||||||
|
WontDo,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Socialize.Api.Modules.Feedback.Data;
|
||||||
|
|
||||||
|
public class FeedbackTag
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid FeedbackReportId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string NormalizedName { get; set; } = string.Empty;
|
||||||
|
public FeedbackReport? FeedbackReport { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Socialize.Api.Modules.Feedback.Data;
|
||||||
|
|
||||||
|
public enum FeedbackType
|
||||||
|
{
|
||||||
|
Bug,
|
||||||
|
Suggestion,
|
||||||
|
Request,
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Socialize.Api.Modules.Feedback;
|
||||||
|
|
||||||
|
public static class DependencyInjection
|
||||||
|
{
|
||||||
|
public static WebApplicationBuilder AddFeedbackModule(this WebApplicationBuilder builder)
|
||||||
|
{
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Feedback.Contracts;
|
||||||
|
using Socialize.Api.Modules.Feedback.Data;
|
||||||
|
using Socialize.Api.Modules.Feedback.Services;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||||
|
|
||||||
|
public record CancelMyFeedbackRequest(string? Reason);
|
||||||
|
|
||||||
|
public class CancelMyFeedbackRequestValidator
|
||||||
|
: Validator<CancelMyFeedbackRequest>
|
||||||
|
{
|
||||||
|
public CancelMyFeedbackRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Reason).MaximumLength(2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CancelMyFeedbackHandler(AppDbContext dbContext)
|
||||||
|
: Endpoint<CancelMyFeedbackRequest, FeedbackReportDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/my-feedback/{id}/cancel");
|
||||||
|
Options(o => o.WithTags("Feedback"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancelMyFeedbackRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Guid id = Route<Guid>("id");
|
||||||
|
Guid reporterUserId = User.GetUserId();
|
||||||
|
|
||||||
|
FeedbackReport? report = await dbContext.FeedbackReports
|
||||||
|
.Include(candidate => candidate.Tags)
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
if (report is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!FeedbackAccessRules.CanReporterCancel(report, reporterUserId))
|
||||||
|
{
|
||||||
|
AddError("The feedback report cannot be cancelled.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
|
report.Status = FeedbackStatus.Cancelled;
|
||||||
|
report.CancelledAt = now;
|
||||||
|
report.CancelledByUserId = reporterUserId;
|
||||||
|
report.CancellationReason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
|
||||||
|
report.LastActivityAt = now;
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
await SendOkAsync(report.ToDto(), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Modules.Feedback.Contracts;
|
||||||
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||||
|
|
||||||
|
public class GetDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||||
|
: EndpointWithoutRequest<FeedbackReportDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/api/feedback/{id}");
|
||||||
|
Roles(KnownRoles.Developer);
|
||||||
|
Options(o => o.WithTags("Feedback"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
Guid id = Route<Guid>("id");
|
||||||
|
FeedbackReportDto? report = await dbContext.FeedbackReports
|
||||||
|
.Include(candidate => candidate.Tags)
|
||||||
|
.Where(candidate => candidate.Id == id)
|
||||||
|
.Select(candidate => candidate.ToDto())
|
||||||
|
.SingleOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (report is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendOkAsync(report, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Feedback.Contracts;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||||
|
|
||||||
|
public class GetMyFeedbackHandler(AppDbContext dbContext)
|
||||||
|
: EndpointWithoutRequest<FeedbackReportDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/api/my-feedback/{id}");
|
||||||
|
Options(o => o.WithTags("Feedback"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
Guid id = Route<Guid>("id");
|
||||||
|
Guid reporterUserId = User.GetUserId();
|
||||||
|
|
||||||
|
FeedbackReportDto? report = await dbContext.FeedbackReports
|
||||||
|
.Include(candidate => candidate.Tags)
|
||||||
|
.Where(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId)
|
||||||
|
.Select(candidate => candidate.ToDto())
|
||||||
|
.SingleOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (report is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendOkAsync(report, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Modules.Feedback.Contracts;
|
||||||
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||||
|
|
||||||
|
public class ListDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||||
|
: EndpointWithoutRequest<IReadOnlyCollection<FeedbackReportDto>>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/api/feedback");
|
||||||
|
Roles(KnownRoles.Developer);
|
||||||
|
Options(o => o.WithTags("Feedback"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
|
||||||
|
.Include(report => report.Tags)
|
||||||
|
.OrderByDescending(report => report.LastActivityAt)
|
||||||
|
.Select(report => report.ToDto())
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
await SendOkAsync(reports, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||||
|
|
||||||
|
public class ListFeedbackTagsHandler(AppDbContext dbContext)
|
||||||
|
: EndpointWithoutRequest<IReadOnlyCollection<string>>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/api/feedback/tags");
|
||||||
|
Roles(KnownRoles.Developer);
|
||||||
|
Options(o => o.WithTags("Feedback"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
List<string> tags = await dbContext.FeedbackTags
|
||||||
|
.GroupBy(tag => new { tag.NormalizedName, tag.Name })
|
||||||
|
.OrderBy(group => group.Key.Name)
|
||||||
|
.Select(group => group.Key.Name)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
await SendOkAsync(tags, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Feedback.Contracts;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||||
|
|
||||||
|
public class ListMyFeedbackHandler(AppDbContext dbContext)
|
||||||
|
: EndpointWithoutRequest<IReadOnlyCollection<FeedbackReportDto>>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/api/my-feedback");
|
||||||
|
Options(o => o.WithTags("Feedback"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
Guid reporterUserId = User.GetUserId();
|
||||||
|
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
|
||||||
|
.Include(report => report.Tags)
|
||||||
|
.Where(report => report.ReporterUserId == reporterUserId)
|
||||||
|
.OrderByDescending(report => report.LastActivityAt)
|
||||||
|
.Select(report => report.ToDto())
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
await SendOkAsync(reports, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Feedback.Contracts;
|
||||||
|
using Socialize.Api.Modules.Feedback.Data;
|
||||||
|
using Socialize.Api.Modules.Feedback.Services;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||||
|
|
||||||
|
public record SubmitFeedbackRequest(
|
||||||
|
string Type,
|
||||||
|
string Description,
|
||||||
|
string SubmittedPath,
|
||||||
|
string? BrowserUserAgent,
|
||||||
|
int? ViewportWidth,
|
||||||
|
int? ViewportHeight,
|
||||||
|
string? AppVersion,
|
||||||
|
Guid? WorkspaceId,
|
||||||
|
string? WorkspaceName,
|
||||||
|
Guid? ClientId,
|
||||||
|
string? ClientName,
|
||||||
|
Guid? ProjectId,
|
||||||
|
string? ProjectName,
|
||||||
|
Guid? ContentItemId,
|
||||||
|
string? ContentItemTitle);
|
||||||
|
|
||||||
|
public class SubmitFeedbackRequestValidator
|
||||||
|
: Validator<SubmitFeedbackRequest>
|
||||||
|
{
|
||||||
|
public SubmitFeedbackRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Type).NotEmpty().MaximumLength(32);
|
||||||
|
RuleFor(x => x.Description).NotEmpty().MaximumLength(8000);
|
||||||
|
RuleFor(x => x.SubmittedPath).NotEmpty().MaximumLength(2048);
|
||||||
|
RuleFor(x => x.BrowserUserAgent).MaximumLength(1024);
|
||||||
|
RuleFor(x => x.AppVersion).MaximumLength(128);
|
||||||
|
RuleFor(x => x.WorkspaceName).MaximumLength(256);
|
||||||
|
RuleFor(x => x.ClientName).MaximumLength(256);
|
||||||
|
RuleFor(x => x.ProjectName).MaximumLength(256);
|
||||||
|
RuleFor(x => x.ContentItemTitle).MaximumLength(256);
|
||||||
|
RuleFor(x => x.ViewportWidth).GreaterThan(0).When(x => x.ViewportWidth.HasValue);
|
||||||
|
RuleFor(x => x.ViewportHeight).GreaterThan(0).When(x => x.ViewportHeight.HasValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SubmitFeedbackHandler(AppDbContext dbContext)
|
||||||
|
: Endpoint<SubmitFeedbackRequest, FeedbackReportDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/feedback");
|
||||||
|
Options(o => o.WithTags("Feedback"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(SubmitFeedbackRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!FeedbackRules.TryParseType(request.Type, out FeedbackType type))
|
||||||
|
{
|
||||||
|
AddError(request => request.Type, "The selected feedback type is not valid.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
|
FeedbackReport report = new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = type,
|
||||||
|
Status = FeedbackStatus.New,
|
||||||
|
Description = request.Description.Trim(),
|
||||||
|
ReporterUserId = User.GetUserId(),
|
||||||
|
ReporterDisplayName = User.GetAlias() ?? User.GetName(),
|
||||||
|
ReporterEmail = User.GetEmail(),
|
||||||
|
SubmittedPath = request.SubmittedPath.Trim(),
|
||||||
|
BrowserUserAgent = NormalizeOptional(request.BrowserUserAgent),
|
||||||
|
ViewportWidth = request.ViewportWidth,
|
||||||
|
ViewportHeight = request.ViewportHeight,
|
||||||
|
AppVersion = NormalizeOptional(request.AppVersion),
|
||||||
|
WorkspaceId = request.WorkspaceId,
|
||||||
|
WorkspaceName = NormalizeOptional(request.WorkspaceName),
|
||||||
|
ClientId = request.ClientId,
|
||||||
|
ClientName = NormalizeOptional(request.ClientName),
|
||||||
|
ProjectId = request.ProjectId,
|
||||||
|
ProjectName = NormalizeOptional(request.ProjectName),
|
||||||
|
ContentItemId = request.ContentItemId,
|
||||||
|
ContentItemTitle = NormalizeOptional(request.ContentItemTitle),
|
||||||
|
CreatedAt = now,
|
||||||
|
LastActivityAt = now,
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.FeedbackReports.Add(report);
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await SendAsync(report.ToDto(), StatusCodes.Status201Created, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeOptional(string? value)
|
||||||
|
{
|
||||||
|
string? normalized = value?.Trim();
|
||||||
|
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Modules.Feedback.Contracts;
|
||||||
|
using Socialize.Api.Modules.Feedback.Data;
|
||||||
|
using Socialize.Api.Modules.Feedback.Services;
|
||||||
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Feedback.Handlers;
|
||||||
|
|
||||||
|
public record UpdateDeveloperFeedbackRequest(
|
||||||
|
string? Type,
|
||||||
|
string? Status,
|
||||||
|
IReadOnlyCollection<string>? Tags);
|
||||||
|
|
||||||
|
public class UpdateDeveloperFeedbackRequestValidator
|
||||||
|
: Validator<UpdateDeveloperFeedbackRequest>
|
||||||
|
{
|
||||||
|
public UpdateDeveloperFeedbackRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Type).MaximumLength(32);
|
||||||
|
RuleFor(x => x.Status).MaximumLength(32);
|
||||||
|
RuleForEach(x => x.Tags).MaximumLength(64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateDeveloperFeedbackHandler(AppDbContext dbContext)
|
||||||
|
: Endpoint<UpdateDeveloperFeedbackRequest, FeedbackReportDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Patch("/api/feedback/{id}");
|
||||||
|
Roles(KnownRoles.Developer);
|
||||||
|
Options(o => o.WithTags("Feedback"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(UpdateDeveloperFeedbackRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Guid id = Route<Guid>("id");
|
||||||
|
FeedbackReport? report = await dbContext.FeedbackReports
|
||||||
|
.Include(candidate => candidate.Tags)
|
||||||
|
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||||
|
|
||||||
|
if (report is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool changed = false;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Type))
|
||||||
|
{
|
||||||
|
if (!FeedbackRules.TryParseType(request.Type, out FeedbackType nextType))
|
||||||
|
{
|
||||||
|
AddError(request => request.Type, "The selected feedback type is not valid.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.Type != nextType)
|
||||||
|
{
|
||||||
|
report.Type = nextType;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||||
|
{
|
||||||
|
if (!FeedbackRules.TryParseStatus(request.Status, out FeedbackStatus nextStatus))
|
||||||
|
{
|
||||||
|
AddError(request => request.Status, "The selected feedback status is not valid.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!FeedbackRules.CanDeveloperSetStatus(report.Status, nextStatus))
|
||||||
|
{
|
||||||
|
AddError(request => request.Status, "The requested status transition is not allowed.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.Status != nextStatus)
|
||||||
|
{
|
||||||
|
report.Status = nextStatus;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Tags is not null)
|
||||||
|
{
|
||||||
|
IReadOnlyCollection<string> normalizedTags = FeedbackRules.NormalizeTags(request.Tags);
|
||||||
|
ApplyTags(report, normalizedTags);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
report.LastActivityAt = DateTimeOffset.UtcNow;
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendOkAsync(report.ToDto(), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyTags(FeedbackReport report, IReadOnlyCollection<string> tags)
|
||||||
|
{
|
||||||
|
HashSet<string> requestedKeys = tags
|
||||||
|
.Select(FeedbackRules.NormalizeTagKey)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (FeedbackTag existingTag in report.Tags.ToArray())
|
||||||
|
{
|
||||||
|
if (!requestedKeys.Contains(existingTag.NormalizedName))
|
||||||
|
{
|
||||||
|
report.Tags.Remove(existingTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<string> existingKeys = report.Tags
|
||||||
|
.Select(tag => tag.NormalizedName)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (string tag in tags)
|
||||||
|
{
|
||||||
|
string key = FeedbackRules.NormalizeTagKey(tag);
|
||||||
|
if (existingKeys.Contains(key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
report.Tags.Add(new FeedbackTag
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
FeedbackReportId = report.Id,
|
||||||
|
Name = tag,
|
||||||
|
NormalizedName = key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Socialize.Api.Modules.Feedback.Data;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Feedback.Services;
|
||||||
|
|
||||||
|
public static class FeedbackAccessRules
|
||||||
|
{
|
||||||
|
public static bool CanReporterAccess(FeedbackReport report, Guid userId)
|
||||||
|
{
|
||||||
|
return report.ReporterUserId == userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool CanReporterCancel(FeedbackReport report, Guid userId)
|
||||||
|
{
|
||||||
|
return CanReporterAccess(report, userId) && FeedbackRules.CanReporterCancel(report.Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Socialize.Api.Modules.Feedback.Data;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Feedback.Services;
|
||||||
|
|
||||||
|
public static class FeedbackRules
|
||||||
|
{
|
||||||
|
public static bool TryParseType(string? value, out FeedbackType type)
|
||||||
|
{
|
||||||
|
return Enum.TryParse(value?.Trim(), ignoreCase: true, out type)
|
||||||
|
&& Enum.IsDefined(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryParseStatus(string? value, out FeedbackStatus status)
|
||||||
|
{
|
||||||
|
string? normalized = value?.Trim().Replace("'", string.Empty, StringComparison.Ordinal);
|
||||||
|
if (string.Equals(normalized, "Wont Do", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(normalized, "WontDo", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
status = FeedbackStatus.WontDo;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Enum.TryParse(normalized, ignoreCase: true, out status)
|
||||||
|
&& Enum.IsDefined(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsFinal(FeedbackStatus status)
|
||||||
|
{
|
||||||
|
return status is FeedbackStatus.Cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool CanDeveloperSetStatus(FeedbackStatus currentStatus, FeedbackStatus nextStatus)
|
||||||
|
{
|
||||||
|
return !IsFinal(currentStatus) &&
|
||||||
|
nextStatus is FeedbackStatus.New or FeedbackStatus.Planned or FeedbackStatus.Resolved or FeedbackStatus.WontDo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool CanReporterCancel(FeedbackStatus currentStatus)
|
||||||
|
{
|
||||||
|
return !IsFinal(currentStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyCollection<string> NormalizeTags(IEnumerable<string>? tags)
|
||||||
|
{
|
||||||
|
if (tags is null)
|
||||||
|
{
|
||||||
|
return Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
.Select(tag => tag.Trim())
|
||||||
|
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||||
|
.Select(tag => tag.Length > 64 ? tag[..64] : tag)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Order(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizeTagKey(string tag)
|
||||||
|
{
|
||||||
|
return tag.Trim().ToUpperInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,4 +7,5 @@ public static class KnownRoles
|
|||||||
public const string Client = nameof(Client);
|
public const string Client = nameof(Client);
|
||||||
public const string Provider = nameof(Provider);
|
public const string Provider = nameof(Provider);
|
||||||
public const string WorkspaceMember = nameof(WorkspaceMember);
|
public const string WorkspaceMember = nameof(WorkspaceMember);
|
||||||
|
public const string Developer = nameof(Developer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,5 +97,11 @@ public static class DependencyInjection
|
|||||||
{
|
{
|
||||||
await roleManager.CreateAsync(workspaceMemberRole);
|
await roleManager.CreateAsync(workspaceMemberRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Role developerRole = new(KnownRoles.Developer);
|
||||||
|
if (roleManager.Roles.All(r => r.Name != developerRole.Name))
|
||||||
|
{
|
||||||
|
await roleManager.CreateAsync(developerRole);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ public class Workspace
|
|||||||
public Guid Id { get; init; }
|
public Guid Id { get; init; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public required string Slug { get; set; }
|
public required string Slug { get; set; }
|
||||||
|
public string? LogoUrl { get; set; }
|
||||||
public Guid OwnerUserId { get; set; }
|
public Guid OwnerUserId { get; set; }
|
||||||
public required string TimeZone { get; set; }
|
public required string TimeZone { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; init; }
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public static class WorkspaceModelConfiguration
|
|||||||
workspace.HasKey(x => x.Id);
|
workspace.HasKey(x => x.Id);
|
||||||
workspace.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
workspace.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
||||||
workspace.Property(x => x.Slug).HasMaxLength(128).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.TimeZone).HasMaxLength(128).IsRequired();
|
||||||
workspace.Property(x => x.CreatedAt)
|
workspace.Property(x => x.CreatedAt)
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Workspaces.Handlers;
|
||||||
|
|
||||||
|
public record ChangeWorkspaceLogoRequest(
|
||||||
|
IFormFile File);
|
||||||
|
|
||||||
|
public record ChangeWorkspaceLogoResponse(
|
||||||
|
string BlobUrl);
|
||||||
|
|
||||||
|
public sealed class ChangeWorkspaceLogoRequestValidator : Validator<ChangeWorkspaceLogoRequest>
|
||||||
|
{
|
||||||
|
public ChangeWorkspaceLogoRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.File)
|
||||||
|
.NotNull()
|
||||||
|
.NotEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ChangeWorkspaceLogoHandler(
|
||||||
|
AppDbContext dbContext,
|
||||||
|
IBlobStorage blobStorage,
|
||||||
|
AccessScopeService accessScopeService)
|
||||||
|
: Endpoint<ChangeWorkspaceLogoRequest, ChangeWorkspaceLogoResponse>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/workspaces/{id}/logo");
|
||||||
|
Options(o => o.WithTags("Workspaces"));
|
||||||
|
AllowFileUploads();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(ChangeWorkspaceLogoRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Guid id = Route<Guid>("id");
|
||||||
|
|
||||||
|
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||||
|
if (workspace is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
|
||||||
|
{
|
||||||
|
await SendForbiddenAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string blobUrl = await blobStorage.UploadFileAsync(
|
||||||
|
ContainerNames.Workspaces,
|
||||||
|
$"{workspace.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.LogoPicture}",
|
||||||
|
request.File.OpenReadStream(),
|
||||||
|
request.File.ContentType,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
workspace.LogoUrl = blobUrl;
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await SendOkAsync(new ChangeWorkspaceLogoResponse(blobUrl), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ public class CreateWorkspaceHandler(
|
|||||||
workspace.Id,
|
workspace.Id,
|
||||||
workspace.Name,
|
workspace.Name,
|
||||||
workspace.Slug,
|
workspace.Slug,
|
||||||
|
workspace.LogoUrl,
|
||||||
workspace.TimeZone,
|
workspace.TimeZone,
|
||||||
workspace.CreatedAt);
|
workspace.CreatedAt);
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ public record WorkspaceDto(
|
|||||||
Guid Id,
|
Guid Id,
|
||||||
string Name,
|
string Name,
|
||||||
string Slug,
|
string Slug,
|
||||||
|
string? LogoUrl,
|
||||||
string TimeZone,
|
string TimeZone,
|
||||||
DateTimeOffset CreatedAt);
|
DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
public class GetWorkspacesHandler(
|
internal class GetWorkspacesHandler(
|
||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
AccessScopeService accessScopeService)
|
AccessScopeService accessScopeService)
|
||||||
: EndpointWithoutRequest<IReadOnlyCollection<WorkspaceDto>>
|
: EndpointWithoutRequest<IReadOnlyCollection<WorkspaceDto>>
|
||||||
@@ -26,20 +27,21 @@ public class GetWorkspacesHandler(
|
|||||||
|
|
||||||
public override async Task HandleAsync(CancellationToken ct)
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
IQueryable<Workspace> query = dbContext.Workspaces.AsQueryable();
|
var query = dbContext.Workspaces.AsQueryable();
|
||||||
|
|
||||||
if (!accessScopeService.IsManager(User))
|
if (!accessScopeService.IsManager(User))
|
||||||
{
|
{
|
||||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
var workspaceScopeIds = User.GetWorkspaceScopeIds();
|
||||||
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
|
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
List<WorkspaceDto> workspaces = await query
|
var workspaces = await query
|
||||||
.OrderBy(workspace => workspace.Name)
|
.OrderBy(workspace => workspace.Name)
|
||||||
.Select(workspace => new WorkspaceDto(
|
.Select(workspace => new WorkspaceDto(
|
||||||
workspace.Id,
|
workspace.Id,
|
||||||
workspace.Name,
|
workspace.Name,
|
||||||
workspace.Slug,
|
workspace.Slug,
|
||||||
|
workspace.LogoUrl,
|
||||||
workspace.TimeZone,
|
workspace.TimeZone,
|
||||||
workspace.CreatedAt))
|
workspace.CreatedAt))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Workspaces.Handlers;
|
||||||
|
|
||||||
|
public record UpdateWorkspaceRequest(
|
||||||
|
string Name,
|
||||||
|
string TimeZone);
|
||||||
|
|
||||||
|
public class UpdateWorkspaceRequestValidator
|
||||||
|
: Validator<UpdateWorkspaceRequest>
|
||||||
|
{
|
||||||
|
public UpdateWorkspaceRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||||
|
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateWorkspaceHandler(
|
||||||
|
AppDbContext dbContext,
|
||||||
|
AccessScopeService accessScopeService)
|
||||||
|
: Endpoint<UpdateWorkspaceRequest, WorkspaceDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Put("/api/workspaces/{id}");
|
||||||
|
Options(o => o.WithTags("Workspaces"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(UpdateWorkspaceRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Guid id = Route<Guid>("id");
|
||||||
|
|
||||||
|
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||||
|
if (workspace is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
|
||||||
|
{
|
||||||
|
await SendForbiddenAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace.Name = request.Name.Trim();
|
||||||
|
workspace.TimeZone = request.TimeZone.Trim();
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
WorkspaceDto dto = new(
|
||||||
|
workspace.Id,
|
||||||
|
workspace.Name,
|
||||||
|
workspace.Slug,
|
||||||
|
workspace.LogoUrl,
|
||||||
|
workspace.TimeZone,
|
||||||
|
workspace.CreatedAt);
|
||||||
|
|
||||||
|
await SendOkAsync(dto, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ using Azure.Identity;
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FastEndpoints.Swagger;
|
using FastEndpoints.Swagger;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Socialize;
|
using Socialize;
|
||||||
|
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||||
|
using Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||||
using Socialize.Api.Infrastructure;
|
using Socialize.Api.Infrastructure;
|
||||||
using Socialize.Api.Infrastructure.Development;
|
using Socialize.Api.Infrastructure.Development;
|
||||||
using Socialize.Api.Modules.Approvals;
|
using Socialize.Api.Modules.Approvals;
|
||||||
@@ -10,6 +13,7 @@ using Socialize.Api.Modules.Assets;
|
|||||||
using Socialize.Api.Modules.Clients;
|
using Socialize.Api.Modules.Clients;
|
||||||
using Socialize.Api.Modules.Comments;
|
using Socialize.Api.Modules.Comments;
|
||||||
using Socialize.Api.Modules.ContentItems;
|
using Socialize.Api.Modules.ContentItems;
|
||||||
|
using Socialize.Api.Modules.Feedback;
|
||||||
using Socialize.Api.Modules.Identity;
|
using Socialize.Api.Modules.Identity;
|
||||||
using Socialize.Api.Modules.Notifications;
|
using Socialize.Api.Modules.Notifications;
|
||||||
using Socialize.Api.Modules.Projects;
|
using Socialize.Api.Modules.Projects;
|
||||||
@@ -66,6 +70,7 @@ builder.AddAssetsModule();
|
|||||||
builder.AddCommentsModule();
|
builder.AddCommentsModule();
|
||||||
builder.AddApprovalsModule();
|
builder.AddApprovalsModule();
|
||||||
builder.AddNotificationsModule();
|
builder.AddNotificationsModule();
|
||||||
|
builder.AddFeedbackModule();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -92,6 +97,38 @@ if (!app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHealthChecks("/health");
|
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())
|
if (!app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.26.0" />
|
|
||||||
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.0" />
|
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.0" />
|
||||||
<PackageReference Include="Azure.Identity" Version="1.18.0" />
|
<PackageReference Include="Azure.Identity" Version="1.18.0" />
|
||||||
<PackageReference Include="FastEndpoints" Version="5.35.0" />
|
<PackageReference Include="FastEndpoints" Version="5.35.0" />
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
"Website": {
|
"Website": {
|
||||||
"FrontendBaseUrl": "http://localhost:5173"
|
"FrontendBaseUrl": "http://localhost:5173"
|
||||||
},
|
},
|
||||||
|
"LocalBlobStorage": {
|
||||||
|
"RootPath": "App_Data/blob-storage",
|
||||||
|
"RequestPath": "/api/storage"
|
||||||
|
},
|
||||||
"Authentication": {
|
"Authentication": {
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Issuer": "http://localhost:5080",
|
"Issuer": "http://localhost:5080",
|
||||||
|
|||||||
115
backend/tests/Socialize.Tests/Feedback/FeedbackRulesTests.cs
Normal file
115
backend/tests/Socialize.Tests/Feedback/FeedbackRulesTests.cs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
using Socialize.Api.Modules.Feedback.Data;
|
||||||
|
using Socialize.Api.Modules.Feedback.Services;
|
||||||
|
|
||||||
|
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 NormalizeTags_trims_deduplicates_and_orders()
|
||||||
|
{
|
||||||
|
IReadOnlyCollection<string> tags = FeedbackRules.NormalizeTags([" mobile ", "bug", "Mobile", ""]);
|
||||||
|
|
||||||
|
Assert.Equal(["bug", "mobile"], tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
expose:
|
expose:
|
||||||
- "8080"
|
- "8080"
|
||||||
|
volumes:
|
||||||
|
- api-blob-storage:/app/App_Data/blob-storage
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
@@ -37,3 +39,6 @@ services:
|
|||||||
- "8080:80"
|
- "8080:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
api-blob-storage:
|
||||||
|
|||||||
256
docs/FEATURES/product-feedback.md
Normal file
256
docs/FEATURES/product-feedback.md
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# 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.
|
||||||
|
- Annotated captures are exported as compressed image files.
|
||||||
|
- Backend upload size and content type validation must be enforced.
|
||||||
|
- The UI must show a friendly error when an image is too large or invalid.
|
||||||
|
- Annotation tools should support:
|
||||||
|
- crop
|
||||||
|
- arrows
|
||||||
|
- circles or ellipses
|
||||||
|
- lines
|
||||||
|
- freehand marks
|
||||||
|
- text labels
|
||||||
|
- undo
|
||||||
|
- clear/reset
|
||||||
|
- Frontend implementation may use established capture and annotation libraries rather than custom screenshot tooling.
|
||||||
|
- Developers can preview and download/open the annotated screenshot.
|
||||||
|
- Reporters can preview the annotated screenshot.
|
||||||
|
- If feedback deletion is added in the future, associated screenshot blobs must be deleted with the report.
|
||||||
|
- Feedback reports and screenshots are retained indefinitely until a future retention/deletion feature exists.
|
||||||
|
|
||||||
|
## Captured Metadata
|
||||||
|
|
||||||
|
Each report should capture useful debugging context automatically when available:
|
||||||
|
|
||||||
|
- reporter user id
|
||||||
|
- reporter name and email
|
||||||
|
- current app URL/path
|
||||||
|
- active workspace id/name
|
||||||
|
- active client id/name
|
||||||
|
- active project id/name
|
||||||
|
- active content item id/title
|
||||||
|
- browser user agent
|
||||||
|
- viewport size
|
||||||
|
- app version, if available
|
||||||
|
- created timestamp
|
||||||
|
|
||||||
|
## Status Model
|
||||||
|
|
||||||
|
Feedback status is deliberately lightweight:
|
||||||
|
|
||||||
|
- `New`
|
||||||
|
- `Planned`
|
||||||
|
- `Resolved`
|
||||||
|
- `Won't Do`
|
||||||
|
- `Cancelled`
|
||||||
|
|
||||||
|
Status rules:
|
||||||
|
|
||||||
|
- New reports start as `New`.
|
||||||
|
- Developers can move reports to `Planned`, `Resolved`, or `Won't Do`.
|
||||||
|
- Developers can change a report type.
|
||||||
|
- Developers can add, remove, and update free-form tags.
|
||||||
|
- Tags are visible to reporters.
|
||||||
|
- Tag entry should suggest previously used tags.
|
||||||
|
- Reporters can cancel their own report with an optional plain-text reason.
|
||||||
|
- `Cancelled` is final in v1.
|
||||||
|
- Reporters cannot reopen resolved or cancelled feedback; they can add comments where comments remain allowed.
|
||||||
|
- Developer reason/comment on `Won't Do` is optional.
|
||||||
|
- No severity, priority, assignment, duplicate linking, or Jira-style workflow is included in v1.
|
||||||
|
|
||||||
|
## Comments And Activity
|
||||||
|
|
||||||
|
- Feedback comments are visible to both the reporter and developers.
|
||||||
|
- Internal/private developer comments are out of scope for v1.
|
||||||
|
- Status/type/tag changes should be stored as activity history.
|
||||||
|
- Feedback detail should show a simple mixed timeline of comments and activity.
|
||||||
|
- Reporters can comment on their own feedback.
|
||||||
|
- Developers can comment on any feedback report.
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
- New feedback report: notify all users with the `Developer` role.
|
||||||
|
- Developer comment: notify the reporter.
|
||||||
|
- Developer status change: notify the reporter.
|
||||||
|
- Developer type/tag changes do not notify the reporter.
|
||||||
|
- Reporter comment: notify developers who have previously commented on that report.
|
||||||
|
- Feedback notifications use the existing in-app notification system.
|
||||||
|
- The existing notification bell should show feedback notifications and open the relevant feedback detail page.
|
||||||
|
- Email notifications are out of scope for v1.
|
||||||
|
- My Feedback should show an unread indicator for reports with unread developer comments or status changes.
|
||||||
|
|
||||||
|
## Developer Review Page
|
||||||
|
|
||||||
|
The developer review area is global, not workspace-scoped.
|
||||||
|
|
||||||
|
`/app/feedback` should support:
|
||||||
|
|
||||||
|
- list all reports by default, including final statuses
|
||||||
|
- filter by type
|
||||||
|
- filter by status
|
||||||
|
- filter by tag
|
||||||
|
- filter by reporter
|
||||||
|
- filter by workspace context
|
||||||
|
- filter by date range
|
||||||
|
- text search
|
||||||
|
- sort by newest
|
||||||
|
- sort by oldest
|
||||||
|
- sort by last activity
|
||||||
|
|
||||||
|
`/app/feedback/:id` should support:
|
||||||
|
|
||||||
|
- report details and captured metadata
|
||||||
|
- reporter identity details
|
||||||
|
- current URL/path link
|
||||||
|
- screenshot preview and developer download/open-original action
|
||||||
|
- comments
|
||||||
|
- activity timeline
|
||||||
|
- status updates
|
||||||
|
- type updates
|
||||||
|
- tag management with suggestions
|
||||||
|
|
||||||
|
## Reporter Pages
|
||||||
|
|
||||||
|
`/app/my-feedback` is global across workspaces and should default to active reports:
|
||||||
|
|
||||||
|
- `New`
|
||||||
|
- `Planned`
|
||||||
|
|
||||||
|
The page should support:
|
||||||
|
|
||||||
|
- list own reports only
|
||||||
|
- filter by status
|
||||||
|
- filter by type
|
||||||
|
- sort by newest
|
||||||
|
- sort by last activity
|
||||||
|
- unread indicators
|
||||||
|
- open feedback detail
|
||||||
|
- view visible tags
|
||||||
|
|
||||||
|
`/app/my-feedback/:id` should support:
|
||||||
|
|
||||||
|
- report details
|
||||||
|
- current URL/path link
|
||||||
|
- screenshot preview
|
||||||
|
- visible tags
|
||||||
|
- comments
|
||||||
|
- activity timeline
|
||||||
|
- cancel with optional reason when status is not final
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
- User-facing feedback UI must be available in English and French.
|
||||||
|
- New strings belong in the existing locale files.
|
||||||
|
|
||||||
|
## API And Data Expectations
|
||||||
|
|
||||||
|
- Backend code should follow the FastEndpoints module pattern under `backend/src/Socialize.Api/Modules/Feedback`.
|
||||||
|
- Feedback entities should be added to `AppDbContext` with explicit model configuration.
|
||||||
|
- The `Developer` role should be seeded with the existing identity role setup.
|
||||||
|
- Screenshot storage should use the existing `IBlobStorage` abstraction.
|
||||||
|
- Protected screenshot access may require a feedback-specific download endpoint instead of public static blob URLs.
|
||||||
|
- Backend contract changes require OpenAPI regeneration while the backend is running.
|
||||||
|
|
||||||
|
## Out Of Scope For V1
|
||||||
|
|
||||||
|
- Public or unauthenticated feedback submission
|
||||||
|
- Shared feedback links
|
||||||
|
- Email notifications
|
||||||
|
- Draft saving
|
||||||
|
- Feedback deletion UI
|
||||||
|
- Automatic retention cleanup
|
||||||
|
- Severity or priority fields
|
||||||
|
- Assignment/owner workflow
|
||||||
|
- Duplicate linking
|
||||||
|
- Internal/private comments
|
||||||
|
- Workspace-owned exports or audit reports
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [ ] Authenticated users can open a global Feedback dialog from every app page.
|
||||||
|
- [ ] Users can submit feedback with required type and description.
|
||||||
|
- [ ] Users can optionally capture, annotate, and upload an app viewport screenshot.
|
||||||
|
- [ ] Feedback records capture debugging metadata when available.
|
||||||
|
- [ ] Reporters can view their own global My Feedback list and details.
|
||||||
|
- [ ] Developers with the `Developer` role can view all feedback in `/app/feedback`.
|
||||||
|
- [ ] Developers can update type, status, and tags.
|
||||||
|
- [ ] Reporters and developers can comment on feedback.
|
||||||
|
- [ ] Feedback activity history is shown with comments.
|
||||||
|
- [ ] Feedback notifications appear in the existing in-app notification system.
|
||||||
|
- [ ] Feedback screenshot access is authenticated and scoped to reporter/developer access.
|
||||||
|
- [ ] English and French UI strings are present.
|
||||||
|
- [ ] Backend build and tests pass.
|
||||||
|
- [ ] Frontend build passes.
|
||||||
|
- [ ] OpenAPI is updated after backend contracts are implemented.
|
||||||
34
docs/FEATURES/user-profile-settings.md
Normal file
34
docs/FEATURES/user-profile-settings.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Feature: User Profile Settings
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Draft
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Allow authenticated users to manage the profile information shown inside the application shell and workspace activity.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
- As an authenticated user, I want to update my name, alias, email, and portrait so that other workspace members see accurate profile information.
|
||||||
|
|
||||||
|
## Frontend Areas
|
||||||
|
|
||||||
|
- `/app/settings/user-information`
|
||||||
|
- `frontend/src/features/user-profile/`
|
||||||
|
|
||||||
|
## Backend Modules
|
||||||
|
|
||||||
|
- Identity
|
||||||
|
|
||||||
|
## Domain Rules
|
||||||
|
|
||||||
|
- Profile updates apply only to the authenticated user.
|
||||||
|
- Portrait uploads flow through the existing blob storage abstraction.
|
||||||
|
- Email changes use the identity module endpoint and should remain auditable through backend identity behavior.
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [ ] User information settings show editable name, alias, and email fields.
|
||||||
|
- [ ] Portrait upload remains available from the settings page.
|
||||||
|
- [ ] Successful updates refresh the user profile state used by the app shell.
|
||||||
40
docs/TASKS/platform-scaffold/003-use-local-blob-storage.md
Normal file
40
docs/TASKS/platform-scaffold/003-use-local-blob-storage.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Task: Use local blob storage
|
||||||
|
|
||||||
|
## Feature
|
||||||
|
|
||||||
|
`docs/FEATURES/platform-scaffold.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Store uploaded portraits and logos on the API server filesystem instead of Azure Blob Storage.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
User, client, and workspace portrait uploads already flow through `IBlobStorage`. The implementation can change without altering endpoint contracts or frontend behavior.
|
||||||
|
|
||||||
|
## Files Likely To Change
|
||||||
|
|
||||||
|
- `backend/src/Socialize.Api/Infrastructure/DependencyInjection.cs`
|
||||||
|
- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/*`
|
||||||
|
- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Configuration/*`
|
||||||
|
- `backend/src/Socialize.Api/Program.cs`
|
||||||
|
- `backend/src/Socialize.Api/appsettings.Development.json`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not change API request or response contracts.
|
||||||
|
- Keep upload validation behavior consistent with the existing blob storage implementation.
|
||||||
|
- Serve returned blob URLs from the API host so the existing frontend can keep using `portraitUrl` and `logoUrl`.
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [x] `IBlobStorage` resolves to local filesystem storage by default.
|
||||||
|
- [x] Uploaded files are served back from the API host.
|
||||||
|
- [x] Backend build passes.
|
||||||
|
|
||||||
|
## Validation Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
dotnet test backend/Socialize.slnx
|
||||||
|
```
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Task: Improve UI Surface Contrast
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Increase contrast between the app background, panels, and form controls so inputs are easier to identify against white or near-white surfaces.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
`docs/FEATURES/platform-scaffold.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Update the shared frontend color tokens.
|
||||||
|
- Configure Vuetify to use the Socialize light theme colors.
|
||||||
|
- Add shared form control and surface defaults for native and Vuetify controls.
|
||||||
|
- Avoid feature-specific behavior changes.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `frontend/src/assets/main.css`
|
||||||
|
- `frontend/src/main.js`
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Task: Backend feedback foundation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add the backend foundation for product feedback reports.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/product-feedback.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add a new `Developer` identity role and seed it with the existing role setup.
|
||||||
|
- Add a new FastEndpoints module under `backend/src/Socialize.Api/Modules/Feedback`.
|
||||||
|
- Add feedback report data entities and EF Core model configuration.
|
||||||
|
- Add feedback enum/value support for:
|
||||||
|
- type: `Bug`, `Suggestion`, `Request`
|
||||||
|
- status: `New`, `Planned`, `Resolved`, `Won't Do`, `Cancelled`
|
||||||
|
- Add `DbSet` entries and module configuration to `AppDbContext`.
|
||||||
|
- Capture reporter id, reporter display fields, submitted route, browser metadata, viewport size, app version if available, and optional workspace/client/project/content context.
|
||||||
|
- Add API endpoints for:
|
||||||
|
- submit feedback
|
||||||
|
- list current user's feedback
|
||||||
|
- get current user's feedback detail
|
||||||
|
- list all feedback for `Developer`
|
||||||
|
- get feedback detail for `Developer`
|
||||||
|
- update feedback type/status/tags for `Developer`
|
||||||
|
- cancel own feedback with optional reason
|
||||||
|
- list previously used tags for `Developer`
|
||||||
|
- Enforce access rules:
|
||||||
|
- authenticated users can submit feedback
|
||||||
|
- reporters can view only their own feedback
|
||||||
|
- developers can view all feedback
|
||||||
|
- only developers can update type/status/tags
|
||||||
|
- reporters can only move their own non-final report to `Cancelled`
|
||||||
|
- Keep assignment, priority, severity, duplicate linking, and deletion out of scope.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `backend/src/Socialize.Api/Modules/Identity/Contracts/KnownRoles.cs`
|
||||||
|
- `backend/src/Socialize.Api/Modules/Identity/DependencyInjection.cs`
|
||||||
|
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
|
||||||
|
- `backend/src/Socialize.Api/Modules/Feedback/**`
|
||||||
|
- `backend/tests/Socialize.Tests/**`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Use FastEndpoints handlers and keep request/response records near their handlers unless local module patterns suggest otherwise.
|
||||||
|
- Use FluentValidation for non-trivial inputs.
|
||||||
|
- Treat feedback as global SaaS operator data, not workspace-owned workflow data.
|
||||||
|
- Tags are free-form but should be normalized enough to support search/filter suggestions later.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
dotnet test backend/Socialize.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [ ] `Developer` role exists and is seeded.
|
||||||
|
- [ ] Feedback reports can be submitted by authenticated users.
|
||||||
|
- [ ] Reporters can list and view only their own feedback.
|
||||||
|
- [ ] Developers can list and view all feedback.
|
||||||
|
- [ ] Developers can update type, status, and tags.
|
||||||
|
- [ ] Reporters can cancel their own feedback with an optional reason.
|
||||||
|
- [ ] Backend validation rejects invalid type/status transitions and missing descriptions.
|
||||||
|
- [ ] Backend tests cover access rules and core transitions.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Task: Protected feedback screenshots
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Store feedback screenshots through blob storage and expose them only through authenticated, feedback-scoped access.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/product-feedback.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add screenshot metadata to feedback reports or a related feedback screenshot entity.
|
||||||
|
- Store uploaded annotated screenshots with the existing `IBlobStorage` abstraction.
|
||||||
|
- Use a dedicated feedback storage container/prefix.
|
||||||
|
- Validate content type and maximum upload size on the backend.
|
||||||
|
- Add API support for attaching a screenshot when creating feedback or immediately after creation.
|
||||||
|
- Add a protected screenshot download/preview endpoint.
|
||||||
|
- Enforce screenshot access:
|
||||||
|
- reporter can access screenshots for their own reports
|
||||||
|
- developers can access all feedback screenshots
|
||||||
|
- no public/static blob URL access for feedback screenshots
|
||||||
|
- Return enough screenshot metadata for frontend preview/download flows without exposing unauthenticated blob URLs.
|
||||||
|
- Document that future feedback deletion must remove associated screenshot blobs.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/*`
|
||||||
|
- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/*`
|
||||||
|
- `backend/src/Socialize.Api/Modules/Feedback/**`
|
||||||
|
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
|
||||||
|
- `backend/tests/Socialize.Tests/**`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Existing portrait/logo blob behavior may expose static URLs; feedback screenshots must not rely on that public URL pattern.
|
||||||
|
- Prefer an endpoint that streams the blob after checking feedback access.
|
||||||
|
- Annotated screenshots are expected to be compressed PNG or JPEG files.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
dotnet test backend/Socialize.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [ ] Feedback screenshots are stored via `IBlobStorage`.
|
||||||
|
- [ ] Feedback screenshots use a dedicated storage area/prefix.
|
||||||
|
- [ ] Invalid or oversized screenshots are rejected with clear API errors.
|
||||||
|
- [ ] Screenshot access requires authentication.
|
||||||
|
- [ ] Reporter/developer access rules are enforced for downloads/previews.
|
||||||
|
- [ ] Backend tests cover authorized and unauthorized screenshot access.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Task: Feedback comments, activity, and notifications
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add the conversation, activity timeline, and in-app notification behavior for feedback.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/product-feedback.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add feedback comments visible to both reporters and developers.
|
||||||
|
- Add feedback activity entries for status/type/tag changes and cancellation.
|
||||||
|
- Return a mixed timeline of comments and activity from feedback detail endpoints.
|
||||||
|
- Add API endpoints for:
|
||||||
|
- reporter adds comment to own feedback
|
||||||
|
- developer adds comment to any feedback
|
||||||
|
- detail timeline retrieval if not included in existing detail endpoints
|
||||||
|
- Use the existing Notifications module for:
|
||||||
|
- new feedback report: notify all `Developer` users
|
||||||
|
- developer comment: notify reporter
|
||||||
|
- developer status change: notify reporter
|
||||||
|
- reporter comment: notify developers who have previously commented on that report
|
||||||
|
- Do not notify for developer type/tag changes.
|
||||||
|
- Extend notification payloads so feedback notifications can open feedback detail pages.
|
||||||
|
- Add read/unread support needed for My Feedback unread indicators, or expose enough data for the frontend to derive unread state from notifications.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `backend/src/Socialize.Api/Modules/Feedback/**`
|
||||||
|
- `backend/src/Socialize.Api/Modules/Notifications/**`
|
||||||
|
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
|
||||||
|
- `backend/tests/Socialize.Tests/**`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Internal/private comments are out of scope.
|
||||||
|
- Email notifications are out of scope.
|
||||||
|
- Avoid adding assignment/owner workflow.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
dotnet test backend/Socialize.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [ ] Reporters and developers can comment according to access rules.
|
||||||
|
- [ ] Status/type/tag/cancel actions create activity entries.
|
||||||
|
- [ ] Feedback detail includes a mixed comment/activity timeline.
|
||||||
|
- [ ] New reports notify all developers.
|
||||||
|
- [ ] Developer comments and status changes notify the reporter.
|
||||||
|
- [ ] Reporter comments notify participating developers.
|
||||||
|
- [ ] Feedback notifications include route-target data for frontend navigation.
|
||||||
|
- [ ] Backend tests cover comment access and notification side effects.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Task: Frontend feedback submission flow
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add the global authenticated Feedback button, submission dialog, viewport capture, and annotation flow.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/product-feedback.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add feature-owned frontend code under `frontend/src/features/feedback/`.
|
||||||
|
- Add a small floating Feedback button to the authenticated app shell on every `/app/*` page.
|
||||||
|
- Keep the button visible on feedback-related pages too.
|
||||||
|
- Add a feedback submission dialog with:
|
||||||
|
- required type: `Bug`, `Suggestion`, `Request`
|
||||||
|
- required plain-text description
|
||||||
|
- optional capture flow
|
||||||
|
- dirty-close warning that discards unsent feedback if confirmed
|
||||||
|
- Capture only the current app viewport when the user explicitly clicks `Capture screen`.
|
||||||
|
- Add screenshot annotation support:
|
||||||
|
- crop
|
||||||
|
- arrows
|
||||||
|
- circles or ellipses
|
||||||
|
- lines
|
||||||
|
- freehand marks
|
||||||
|
- text labels
|
||||||
|
- undo
|
||||||
|
- clear/reset
|
||||||
|
- Export annotated screenshots as compressed PNG or JPEG.
|
||||||
|
- Submit feedback metadata, route context, browser metadata, viewport size, and optional screenshot to the backend.
|
||||||
|
- If capture fails, show a friendly error and allow text-only submission.
|
||||||
|
- Use established libraries for capture/annotation rather than custom screenshot infrastructure.
|
||||||
|
- Add English and French locale strings for the submission flow.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `frontend/package.json`
|
||||||
|
- `frontend/src/layouts/**`
|
||||||
|
- `frontend/src/features/feedback/**`
|
||||||
|
- `frontend/src/plugins/api.js`
|
||||||
|
- `frontend/src/locales/en.json`
|
||||||
|
- `frontend/src/locales/fr.json`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Runtime configuration must continue to flow through `frontend/src/config.js` if new configuration is needed.
|
||||||
|
- Keep the flow non-intrusive and app-shell scoped.
|
||||||
|
- Avoid landing-page or marketing-style UI.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [ ] Authenticated users see a floating Feedback button on every app page.
|
||||||
|
- [ ] Users can submit required type and description.
|
||||||
|
- [ ] Users can optionally capture and annotate the app viewport.
|
||||||
|
- [ ] Capture failures do not block text-only feedback.
|
||||||
|
- [ ] Dirty dialog close warns before discarding unsent feedback.
|
||||||
|
- [ ] UI strings exist in English and French.
|
||||||
|
- [ ] Frontend build passes.
|
||||||
71
docs/TASKS/product-feedback/005-frontend-my-feedback.md
Normal file
71
docs/TASKS/product-feedback/005-frontend-my-feedback.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Task: Frontend My Feedback pages
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add reporter-facing pages for tracking submitted feedback.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/product-feedback.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add routes:
|
||||||
|
- `/app/my-feedback`
|
||||||
|
- `/app/my-feedback/:id`
|
||||||
|
- Add feature-owned views/stores/components under `frontend/src/features/feedback/`.
|
||||||
|
- The list page is global across workspaces and shows only the authenticated user's own reports.
|
||||||
|
- Default the list to active reports:
|
||||||
|
- `New`
|
||||||
|
- `Planned`
|
||||||
|
- Support list filtering by:
|
||||||
|
- status
|
||||||
|
- type
|
||||||
|
- Support sorting by:
|
||||||
|
- newest
|
||||||
|
- last activity
|
||||||
|
- Show unread indicators for reports with unread developer comments or status changes.
|
||||||
|
- Show visible tags.
|
||||||
|
- Detail page should show:
|
||||||
|
- report details
|
||||||
|
- current URL/path link
|
||||||
|
- screenshot preview
|
||||||
|
- tags
|
||||||
|
- comments
|
||||||
|
- activity timeline
|
||||||
|
- cancellation action with optional reason when the report is not final
|
||||||
|
- Allow reporters to add follow-up comments.
|
||||||
|
- Extend navigation/sidebar/user menu as appropriate so users can find My Feedback.
|
||||||
|
- Add English and French locale strings.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `frontend/src/router/router.js`
|
||||||
|
- `frontend/src/layouts/main/**`
|
||||||
|
- `frontend/src/features/feedback/**`
|
||||||
|
- `frontend/src/locales/en.json`
|
||||||
|
- `frontend/src/locales/fr.json`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Reporters cannot edit or delete submitted feedback in v1.
|
||||||
|
- Reporters cannot change status except cancelling their own non-final report.
|
||||||
|
- `Cancelled` is final.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [ ] Authenticated users can open My Feedback.
|
||||||
|
- [ ] My Feedback defaults to active reports.
|
||||||
|
- [ ] Users can filter and sort their feedback.
|
||||||
|
- [ ] Unread indicators are visible where applicable.
|
||||||
|
- [ ] Users can open details, preview screenshots, read timeline, and comment.
|
||||||
|
- [ ] Users can cancel their own non-final report with an optional reason.
|
||||||
|
- [ ] UI strings exist in English and French.
|
||||||
|
- [ ] Frontend build passes.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Task: Frontend developer feedback review
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add the developer-facing global feedback review area.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/product-feedback.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add routes restricted to the `Developer` role:
|
||||||
|
- `/app/feedback`
|
||||||
|
- `/app/feedback/:id`
|
||||||
|
- Add feature-owned views/stores/components under `frontend/src/features/feedback/`.
|
||||||
|
- Add a discoverable navigation entry for users with the `Developer` role.
|
||||||
|
- The list page is global and shows all reports by default, including final statuses.
|
||||||
|
- Support list filters:
|
||||||
|
- type
|
||||||
|
- status
|
||||||
|
- tag
|
||||||
|
- reporter
|
||||||
|
- workspace context
|
||||||
|
- date range
|
||||||
|
- text search
|
||||||
|
- Support sorting by:
|
||||||
|
- newest
|
||||||
|
- oldest
|
||||||
|
- last activity
|
||||||
|
- Detail page should show:
|
||||||
|
- report details and captured metadata
|
||||||
|
- reporter name/email
|
||||||
|
- current URL/path link
|
||||||
|
- screenshot preview
|
||||||
|
- developer download/open-original screenshot action
|
||||||
|
- comments
|
||||||
|
- activity timeline
|
||||||
|
- status updates
|
||||||
|
- type updates
|
||||||
|
- tag management with suggestions from previously used tags
|
||||||
|
- Allow developers to comment on any feedback report.
|
||||||
|
- Add English and French locale strings.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `frontend/src/router/router.js`
|
||||||
|
- `frontend/src/layouts/main/**`
|
||||||
|
- `frontend/src/features/feedback/**`
|
||||||
|
- `frontend/src/locales/en.json`
|
||||||
|
- `frontend/src/locales/fr.json`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Do not add assignment, priority, severity, duplicate linking, or private comments.
|
||||||
|
- Keep the review page operational and dense, not a Jira-style board.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [ ] Only users with the `Developer` role can access `/app/feedback`.
|
||||||
|
- [ ] Developers can list all feedback with required filters and sorting.
|
||||||
|
- [ ] Developers can open details and inspect metadata.
|
||||||
|
- [ ] Developers can preview and download/open screenshots.
|
||||||
|
- [ ] Developers can update type, status, and tags.
|
||||||
|
- [ ] Tag suggestions use previously used tags.
|
||||||
|
- [ ] Developers can comment.
|
||||||
|
- [ ] UI strings exist in English and French.
|
||||||
|
- [ ] Frontend build passes.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Task: Feedback notification UI integration
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Integrate feedback notifications into the existing notification bell and route navigation.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/product-feedback.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Extend frontend notification display to support feedback event types.
|
||||||
|
- Clicking a feedback notification should open:
|
||||||
|
- `/app/my-feedback/:id` for reporters
|
||||||
|
- `/app/feedback/:id` for developers when appropriate
|
||||||
|
- Mark feedback notifications as read using existing notification behavior.
|
||||||
|
- Ensure feedback notification labels are localized in English and French.
|
||||||
|
- Ensure My Feedback unread indicators stay consistent with notification read state or the backend unread model.
|
||||||
|
- Preserve existing content/comment/approval notification behavior.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `frontend/src/layouts/main/AppSidebar.vue`
|
||||||
|
- `frontend/src/features/notifications/**`
|
||||||
|
- `frontend/src/features/feedback/**`
|
||||||
|
- `frontend/src/locales/en.json`
|
||||||
|
- `frontend/src/locales/fr.json`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This task depends on backend feedback notification payloads from `003-feedback-comments-activity-notifications.md`.
|
||||||
|
- Do not introduce email notification behavior.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [ ] Feedback notifications appear in the existing notification bell.
|
||||||
|
- [ ] Feedback notification clicks navigate to the correct detail page.
|
||||||
|
- [ ] Feedback notifications can be marked read.
|
||||||
|
- [ ] My Feedback unread indicators reflect unread feedback activity.
|
||||||
|
- [ ] Existing notification flows still work.
|
||||||
|
- [ ] UI strings exist in English and French.
|
||||||
|
- [ ] Frontend build passes.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Task: OpenAPI sync and end-to-end feedback polish
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Finalize contract sync, validation, and end-to-end behavior after the feedback backend and frontend tasks are implemented.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/product-feedback.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Run the backend and regenerate OpenAPI after feedback API contracts are complete.
|
||||||
|
- Update generated frontend API types.
|
||||||
|
- Resolve frontend build issues caused by contract changes.
|
||||||
|
- Verify reporter and developer access flows manually.
|
||||||
|
- Verify protected screenshot preview/download behavior.
|
||||||
|
- Verify feedback notifications open the expected pages.
|
||||||
|
- Verify English/French feedback UI coverage.
|
||||||
|
- Review `docs/FEATURES/product-feedback.md` and update it if implementation intentionally changed behavior.
|
||||||
|
- Add or update follow-up task files for deferred work discovered during implementation.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `shared/openapi/openapi.json`
|
||||||
|
- `frontend/src/api/schema.d.ts`
|
||||||
|
- `docs/FEATURES/product-feedback.md`
|
||||||
|
- `docs/TASKS/product-feedback/**`
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
dotnet test backend/Socialize.slnx
|
||||||
|
./scripts/update-openapi.sh
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [ ] OpenAPI snapshot is updated.
|
||||||
|
- [ ] Generated frontend schema is updated.
|
||||||
|
- [ ] Backend build passes.
|
||||||
|
- [ ] Backend tests pass.
|
||||||
|
- [ ] Frontend build passes.
|
||||||
|
- [ ] Reporter can submit, view, comment, and cancel feedback.
|
||||||
|
- [ ] Developer can review, filter, comment, update status/type/tags, and access screenshots.
|
||||||
|
- [ ] Feedback notifications work from the notification bell.
|
||||||
|
- [ ] Feature spec still matches implemented behavior.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Task: Edit user information settings
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Allow users to edit their profile details from the user information settings page.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/user-profile-settings.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Replace read-only user information details with editable first name, last name, alias, and email fields.
|
||||||
|
- Keep portrait upload available on the page.
|
||||||
|
- Use the existing Identity endpoints for full name, alias, email, and portrait updates.
|
||||||
|
- Keep the profile store as the source of truth for app-shell user identity.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Task: Edit workspace settings
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Allow managers to update the active workspace name and time zone from the workspace settings page.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/workspace-review-workflow.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add a backend workspace update endpoint for `name` and `timeZone`.
|
||||||
|
- Add a backend workspace logo upload endpoint.
|
||||||
|
- Add a frontend workspace store update action.
|
||||||
|
- Replace the workspace settings general summary with editable details and logo controls.
|
||||||
|
- Do not display workspace slug or workspace creation date on the workspace settings page.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
cd frontend && npm run build
|
||||||
|
```
|
||||||
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
@@ -2313,9 +2313,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001722",
|
"version": "1.0.30001791",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001722.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
|
||||||
"integrity": "sha512-DCQHBBZtiK6JVkAGw7drvAMK0Q0POD/xZvEmDp6baiMMP6QXXk9HpD6mNYBZWhOPG6LvIDb82ITqtWjhDckHCA==",
|
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2330,7 +2330,8 @@
|
|||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
|
|||||||
3214
frontend/src/api/schema.d.ts
vendored
3214
frontend/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,18 @@
|
|||||||
--socialize-primary: #172033;
|
--socialize-primary: #172033;
|
||||||
--socialize-accent: #ff8a3d;
|
--socialize-accent: #ff8a3d;
|
||||||
--socialize-highlight: #2fa58d;
|
--socialize-highlight: #2fa58d;
|
||||||
--h-background: #fffaf2;
|
--h-background: #f4f6f3;
|
||||||
--h-on-background: #172033;
|
--h-on-background: #172033;
|
||||||
--h-surface: #ffffff;
|
--h-surface: #fbfaf6;
|
||||||
|
--h-surface-muted: #f1f5f2;
|
||||||
--h-on-surface: #172033;
|
--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-primary: #172033;
|
||||||
--h-on-primary: #fffaf2;
|
--h-on-primary: #fbfaf6;
|
||||||
--h-secondary: #fff3e2;
|
--h-secondary: #fff3e2;
|
||||||
--h-on-secondary: #172033;
|
--h-on-secondary: #172033;
|
||||||
--h-tertiary: #d9f6ee;
|
--h-tertiary: #d9f6ee;
|
||||||
@@ -20,6 +26,93 @@
|
|||||||
--h-on-error: #ffffff;
|
--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 {
|
@layer components {
|
||||||
.btn {
|
.btn {
|
||||||
@apply min-w-24 w-full;
|
@apply min-w-24 w-full;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {computed, watch} from 'vue'
|
import {computed, ref, watch} from 'vue'
|
||||||
import {defineStore} from 'pinia'
|
import {defineStore} from 'pinia'
|
||||||
import {useAuthStore} from "@/features/auth/stores/authStore.js";
|
import {useAuthStore} from "@/features/auth/stores/authStore.js";
|
||||||
import {useClient} from "@/plugins/api.js";
|
import {useClient} from "@/plugins/api.js";
|
||||||
@@ -9,6 +9,9 @@ export const useUserProfileStore = defineStore(
|
|||||||
() => {
|
() => {
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const isUpdating = ref(false)
|
||||||
|
const isUploadingPortrait = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
const authWatcher = watch(
|
const authWatcher = watch(
|
||||||
() => authStore.isAuthenticated,
|
() => authStore.isAuthenticated,
|
||||||
@@ -64,12 +67,15 @@ export const useUserProfileStore = defineStore(
|
|||||||
const client = useClient()
|
const client = useClient()
|
||||||
const userResponse = await client.get("/api/users/profile");
|
const userResponse = await client.get("/api/users/profile");
|
||||||
value.value = userResponse.data
|
value.value = userResponse.data
|
||||||
} catch (error) {
|
} catch (fetchError) {
|
||||||
console.error(error)
|
console.error(fetchError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changeFullname(firstname, lastname) {
|
async function changeFullname(firstname, lastname) {
|
||||||
|
isUpdating.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
await client.post(
|
await client.post(
|
||||||
@@ -80,12 +86,19 @@ export const useUserProfileStore = defineStore(
|
|||||||
})
|
})
|
||||||
value.value.firstname = firstname;
|
value.value.firstname = firstname;
|
||||||
value.value.lastname = lastname;
|
value.value.lastname = lastname;
|
||||||
} catch (error) {
|
} catch (updateError) {
|
||||||
console.error(error)
|
console.error(updateError)
|
||||||
|
error.value = 'Failed to update profile.'
|
||||||
|
throw updateError
|
||||||
|
} finally {
|
||||||
|
isUpdating.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changeAlias(alias) {
|
async function changeAlias(alias) {
|
||||||
|
isUpdating.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
await client.post(
|
await client.post(
|
||||||
@@ -94,8 +107,12 @@ export const useUserProfileStore = defineStore(
|
|||||||
alias: alias
|
alias: alias
|
||||||
})
|
})
|
||||||
value.value.alias = alias;
|
value.value.alias = alias;
|
||||||
} catch (error) {
|
} catch (updateError) {
|
||||||
console.error(error)
|
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) {
|
async function changeEmail(email) {
|
||||||
|
isUpdating.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
await client.post(
|
await client.post(
|
||||||
@@ -136,8 +156,12 @@ export const useUserProfileStore = defineStore(
|
|||||||
email: email
|
email: email
|
||||||
})
|
})
|
||||||
value.value.email = email;
|
value.value.email = email;
|
||||||
} catch (error) {
|
} catch (updateError) {
|
||||||
console.error(error)
|
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) {
|
async function changePortrait(selectedFile) {
|
||||||
|
isUploadingPortrait.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -166,8 +193,12 @@ export const useUserProfileStore = defineStore(
|
|||||||
formData)
|
formData)
|
||||||
|
|
||||||
value.value.portraitUrl = `${response.data.blobUrl}?${Date.now()}` // the Date.now() is for cache-busting
|
value.value.portraitUrl = `${response.data.blobUrl}?${Date.now()}` // the Date.now() is for cache-busting
|
||||||
} catch (error) {
|
} catch (uploadError) {
|
||||||
console.error(error)
|
console.error(uploadError)
|
||||||
|
error.value = 'Failed to update portrait.'
|
||||||
|
throw uploadError
|
||||||
|
} finally {
|
||||||
|
isUploadingPortrait.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +207,9 @@ export const useUserProfileStore = defineStore(
|
|||||||
alias,
|
alias,
|
||||||
fullname,
|
fullname,
|
||||||
portraitUrl,
|
portraitUrl,
|
||||||
|
isUpdating,
|
||||||
|
isUploadingPortrait,
|
||||||
|
error,
|
||||||
roles,
|
roles,
|
||||||
persona,
|
persona,
|
||||||
authorizedWorkspaceIds,
|
authorizedWorkspaceIds,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import AppAvatar from '@/components/AppAvatar.vue';
|
import AppAvatar from '@/components/AppAvatar.vue';
|
||||||
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
|
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
|
||||||
@@ -9,21 +9,86 @@
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isPortraitDialogOpen = ref(false);
|
const isPortraitDialogOpen = ref(false);
|
||||||
const isSavingPortrait = 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 email = computed(() => userProfileStore.user?.email || t('userSettings.noEmail'));
|
||||||
const alias = computed(() => userProfileStore.alias);
|
const alias = computed(() => userProfileStore.alias);
|
||||||
const fullname = computed(() => userProfileStore.fullname);
|
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) {
|
async function savePortrait(result) {
|
||||||
isSavingPortrait.value = true;
|
isSavingPortrait.value = true;
|
||||||
|
settingsError.value = null;
|
||||||
|
settingsStatus.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await userProfileStore.changePortrait(result.file);
|
await userProfileStore.changePortrait(result.file);
|
||||||
isPortraitDialogOpen.value = false;
|
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 {
|
} finally {
|
||||||
isSavingPortrait.value = false;
|
isSavingPortrait.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => userProfileStore.user,
|
||||||
|
syncFormFromUser,
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -50,6 +115,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="primary-button"
|
class="primary-button"
|
||||||
|
type="button"
|
||||||
@click="isPortraitDialogOpen = true"
|
@click="isPortraitDialogOpen = true"
|
||||||
>
|
>
|
||||||
{{ t('userSettings.updatePortrait') }}
|
{{ t('userSettings.updatePortrait') }}
|
||||||
@@ -62,20 +128,77 @@
|
|||||||
<span>{{ t('userSettings.accountDetailsDescription') }}</span>
|
<span>{{ t('userSettings.accountDetailsDescription') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="settingsError"
|
||||||
|
class="page-message error"
|
||||||
|
>
|
||||||
|
{{ settingsError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="settingsStatus"
|
||||||
|
class="page-message success"
|
||||||
|
>
|
||||||
|
{{ settingsStatus }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="form-stack"
|
||||||
|
@submit.prevent="submitSettings"
|
||||||
|
>
|
||||||
<div class="details-grid">
|
<div class="details-grid">
|
||||||
<div class="detail-row">
|
<label class="field">
|
||||||
|
<span>{{ t('userSettings.firstname') }}</span>
|
||||||
|
<input
|
||||||
|
v-model="form.firstname"
|
||||||
|
type="text"
|
||||||
|
autocomplete="given-name"
|
||||||
|
:disabled="userProfileStore.isUpdating"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ t('userSettings.lastname') }}</span>
|
||||||
|
<input
|
||||||
|
v-model="form.lastname"
|
||||||
|
type="text"
|
||||||
|
autocomplete="family-name"
|
||||||
|
:disabled="userProfileStore.isUpdating"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
<span>{{ t('userSettings.alias') }}</span>
|
<span>{{ t('userSettings.alias') }}</span>
|
||||||
<strong>{{ alias }}</strong>
|
<input
|
||||||
</div>
|
v-model="form.alias"
|
||||||
<div class="detail-row">
|
type="text"
|
||||||
<span>{{ t('userSettings.fullName') }}</span>
|
autocomplete="nickname"
|
||||||
<strong>{{ fullname }}</strong>
|
:placeholder="fullname"
|
||||||
</div>
|
:disabled="userProfileStore.isUpdating"
|
||||||
<div class="detail-row">
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
<span>{{ t('userSettings.email') }}</span>
|
<span>{{ t('userSettings.email') }}</span>
|
||||||
<strong>{{ email }}</strong>
|
<input
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
:disabled="userProfileStore.isUpdating"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
class="primary-button"
|
||||||
|
type="submit"
|
||||||
|
:disabled="!canSave"
|
||||||
|
>
|
||||||
|
{{ userProfileStore.isUpdating ? t('common.saving') : t('userSettings.saveDetails') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ImageCropperDialog
|
<ImageCropperDialog
|
||||||
@@ -84,6 +207,7 @@
|
|||||||
:confirm-label="t('userSettings.savePortrait')"
|
:confirm-label="t('userSettings.savePortrait')"
|
||||||
:upload-label="t('userSettings.choosePortrait')"
|
:upload-label="t('userSettings.choosePortrait')"
|
||||||
:is-saving="isSavingPortrait"
|
:is-saving="isSavingPortrait"
|
||||||
|
:initial-url="userProfileStore.portraitUrl"
|
||||||
@save="savePortrait"
|
@save="savePortrait"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -107,8 +231,7 @@
|
|||||||
.page-header p,
|
.page-header p,
|
||||||
.panel-heading span,
|
.panel-heading span,
|
||||||
.hero-identity span,
|
.hero-identity span,
|
||||||
.hero-identity small,
|
.hero-identity small {
|
||||||
.detail-row span {
|
|
||||||
@apply text-sm leading-6;
|
@apply text-sm leading-6;
|
||||||
color: #526178;
|
color: #526178;
|
||||||
}
|
}
|
||||||
@@ -128,8 +251,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-identity strong,
|
.hero-identity strong,
|
||||||
.panel-heading strong,
|
.panel-heading strong {
|
||||||
.detail-row strong {
|
|
||||||
color: #172033;
|
color: #172033;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,10 +271,45 @@
|
|||||||
@apply grid gap-4 md:grid-cols-2;
|
@apply grid gap-4 md:grid-cols-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-row {
|
.form-stack {
|
||||||
@apply flex flex-col gap-1 rounded-[1.25rem] border p-4;
|
@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;
|
background: #fffaf2;
|
||||||
border-color: rgba(23, 32, 51, 0.08);
|
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 {
|
.primary-button {
|
||||||
@@ -160,4 +317,9 @@
|
|||||||
background: #172033;
|
background: #172033;
|
||||||
color: #fffaf2;
|
color: #fffaf2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.primary-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { getTimeZoneOptions } from '@/features/workspaces/timeZones.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const timeZoneOptions = computed(() => getTimeZoneOptions(props.modelValue));
|
||||||
|
|
||||||
|
function updateValue(event) {
|
||||||
|
emit('update:modelValue', event.target.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<select
|
||||||
|
class="time-zone-select"
|
||||||
|
:value="modelValue"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="updateValue"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="timeZone in timeZoneOptions"
|
||||||
|
:key="timeZone.value"
|
||||||
|
:value="timeZone.value"
|
||||||
|
>
|
||||||
|
{{ timeZone.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.time-zone-select {
|
||||||
|
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
||||||
|
background: #fffdf8;
|
||||||
|
border-color: rgba(23, 32, 51, 0.1);
|
||||||
|
color: #172033;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,6 +11,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
|||||||
const activeWorkspaceId = ref(null);
|
const activeWorkspaceId = ref(null);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const isCreating = ref(false);
|
const isCreating = ref(false);
|
||||||
|
const isUpdating = ref(false);
|
||||||
|
const isUploadingLogo = ref(false);
|
||||||
const invitesByWorkspace = ref({});
|
const invitesByWorkspace = ref({});
|
||||||
const membersByWorkspace = ref({});
|
const membersByWorkspace = ref({});
|
||||||
const isInvitesLoading = ref(false);
|
const isInvitesLoading = ref(false);
|
||||||
@@ -90,6 +92,74 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateWorkspace(workspaceId, payload) {
|
||||||
|
if (!authStore.isAuthenticated || !workspaceId) {
|
||||||
|
throw new Error('You must be authenticated to update a workspace.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUpdating.value) {
|
||||||
|
throw new Error('A workspace update request is already in progress.');
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdating.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.put(`/api/workspaces/${workspaceId}`, payload);
|
||||||
|
|
||||||
|
if (response.data) {
|
||||||
|
workspaces.value = workspaces.value
|
||||||
|
.map(workspace => (workspace.id === workspaceId ? response.data : workspace))
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (updateError) {
|
||||||
|
console.error('Failed to update workspace:', updateError);
|
||||||
|
error.value = 'Failed to update workspace.';
|
||||||
|
throw updateError;
|
||||||
|
} finally {
|
||||||
|
isUpdating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadWorkspaceLogo(workspaceId, file) {
|
||||||
|
if (!authStore.isAuthenticated || !workspaceId) {
|
||||||
|
throw new Error('You must be authenticated to upload a workspace logo.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUploadingLogo.value) {
|
||||||
|
throw new Error('A workspace logo upload is already in progress.');
|
||||||
|
}
|
||||||
|
|
||||||
|
isUploadingLogo.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file, file.name || 'workspace-logo.png');
|
||||||
|
|
||||||
|
const response = await client.post(`/api/workspaces/${workspaceId}/logo`, formData);
|
||||||
|
const blobUrl = response.data?.blobUrl;
|
||||||
|
|
||||||
|
if (blobUrl) {
|
||||||
|
workspaces.value = workspaces.value.map(workspace =>
|
||||||
|
workspace.id === workspaceId
|
||||||
|
? { ...workspace, logoUrl: `${blobUrl}?${Date.now()}` }
|
||||||
|
: workspace
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (uploadError) {
|
||||||
|
console.error('Failed to upload workspace logo:', uploadError);
|
||||||
|
error.value = 'Failed to upload workspace logo.';
|
||||||
|
throw uploadError;
|
||||||
|
} finally {
|
||||||
|
isUploadingLogo.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setActiveWorkspace(workspaceId) {
|
function setActiveWorkspace(workspaceId) {
|
||||||
if (workspaces.value.some(workspace => workspace.id === workspaceId)) {
|
if (workspaces.value.some(workspace => workspace.id === workspaceId)) {
|
||||||
activeWorkspaceId.value = workspaceId;
|
activeWorkspaceId.value = workspaceId;
|
||||||
@@ -192,6 +262,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
|||||||
activeWorkspace,
|
activeWorkspace,
|
||||||
isLoading,
|
isLoading,
|
||||||
isCreating,
|
isCreating,
|
||||||
|
isUpdating,
|
||||||
|
isUploadingLogo,
|
||||||
invitesByWorkspace,
|
invitesByWorkspace,
|
||||||
membersByWorkspace,
|
membersByWorkspace,
|
||||||
isInvitesLoading,
|
isInvitesLoading,
|
||||||
@@ -200,6 +272,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
|||||||
error,
|
error,
|
||||||
fetchWorkspaces,
|
fetchWorkspaces,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
|
updateWorkspace,
|
||||||
|
uploadWorkspaceLogo,
|
||||||
fetchInvites,
|
fetchInvites,
|
||||||
fetchMembers,
|
fetchMembers,
|
||||||
inviteMember,
|
inviteMember,
|
||||||
|
|||||||
84
frontend/src/features/workspaces/timeZones.js
Normal file
84
frontend/src/features/workspaces/timeZones.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
const FALLBACK_TIME_ZONES = [
|
||||||
|
'UTC',
|
||||||
|
'America/Los_Angeles',
|
||||||
|
'America/Denver',
|
||||||
|
'America/Chicago',
|
||||||
|
'America/New_York',
|
||||||
|
'America/Toronto',
|
||||||
|
'America/Montreal',
|
||||||
|
'America/Vancouver',
|
||||||
|
'America/Mexico_City',
|
||||||
|
'America/Sao_Paulo',
|
||||||
|
'Europe/London',
|
||||||
|
'Europe/Paris',
|
||||||
|
'Europe/Berlin',
|
||||||
|
'Europe/Madrid',
|
||||||
|
'Europe/Rome',
|
||||||
|
'Europe/Amsterdam',
|
||||||
|
'Europe/Zurich',
|
||||||
|
'Europe/Stockholm',
|
||||||
|
'Europe/Warsaw',
|
||||||
|
'Africa/Casablanca',
|
||||||
|
'Africa/Johannesburg',
|
||||||
|
'Asia/Dubai',
|
||||||
|
'Asia/Kolkata',
|
||||||
|
'Asia/Singapore',
|
||||||
|
'Asia/Tokyo',
|
||||||
|
'Asia/Seoul',
|
||||||
|
'Asia/Shanghai',
|
||||||
|
'Australia/Sydney',
|
||||||
|
'Pacific/Auckland',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getTimeZoneOptions(selectedTimeZone) {
|
||||||
|
const supportedTimeZones = getSupportedTimeZones();
|
||||||
|
const timeZones = new Set(['UTC', ...supportedTimeZones]);
|
||||||
|
|
||||||
|
if (selectedTimeZone) {
|
||||||
|
timeZones.add(selectedTimeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...timeZones]
|
||||||
|
.sort((left, right) => left.localeCompare(right))
|
||||||
|
.map(timeZone => ({
|
||||||
|
value: timeZone,
|
||||||
|
label: formatTimeZoneLabel(timeZone),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSupportedTimeZones() {
|
||||||
|
if (typeof Intl.supportedValuesOf === 'function') {
|
||||||
|
return Intl.supportedValuesOf('timeZone');
|
||||||
|
}
|
||||||
|
|
||||||
|
return FALLBACK_TIME_ZONES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeZoneLabel(timeZone) {
|
||||||
|
const offset = formatTimeZoneOffset(timeZone);
|
||||||
|
|
||||||
|
if (!offset) {
|
||||||
|
return timeZone.replaceAll('_', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${timeZone.replaceAll('_', ' ')} (${offset})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeZoneOffset(timeZone) {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
timeZone,
|
||||||
|
timeZoneName: 'shortOffset',
|
||||||
|
}).formatToParts(new Date());
|
||||||
|
const offset = parts.find(part => part.type === 'timeZoneName')?.value;
|
||||||
|
|
||||||
|
if (!offset) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return offset.replace('GMT', 'UTC');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, reactive, ref } from 'vue';
|
import { computed, reactive, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue';
|
||||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -126,9 +127,8 @@
|
|||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>{{ t('workspaceCreate.fields.timeZone') }}</span>
|
<span>{{ t('workspaceCreate.fields.timeZone') }}</span>
|
||||||
<input
|
<TimeZoneSelect
|
||||||
v-model="form.timeZone"
|
v-model="form.timeZone"
|
||||||
type="text"
|
|
||||||
:disabled="workspaceStore.isCreating"
|
:disabled="workspaceStore.isCreating"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, ref, watch } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import AppAvatar from '@/components/AppAvatar.vue';
|
||||||
|
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
|
||||||
|
import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue';
|
||||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||||
import {
|
import {
|
||||||
mdiAccountGroupOutline,
|
mdiAccountGroupOutline,
|
||||||
@@ -14,6 +17,15 @@
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const workspaceStore = useWorkspaceStore();
|
const workspaceStore = useWorkspaceStore();
|
||||||
const activeTab = ref('general');
|
const activeTab = ref('general');
|
||||||
|
const settingsForm = reactive({
|
||||||
|
name: '',
|
||||||
|
timeZone: '',
|
||||||
|
});
|
||||||
|
const settingsError = ref(null);
|
||||||
|
const settingsStatus = ref(null);
|
||||||
|
const logoError = ref(null);
|
||||||
|
const logoStatus = ref(null);
|
||||||
|
const isLogoDialogOpen = ref(false);
|
||||||
|
|
||||||
const inviteForm = reactive({
|
const inviteForm = reactive({
|
||||||
email: '',
|
email: '',
|
||||||
@@ -26,6 +38,15 @@
|
|||||||
const workspaceMembers = computed(() =>
|
const workspaceMembers = computed(() =>
|
||||||
workspaceStore.membersByWorkspace[workspaceStore.activeWorkspaceId] ?? []
|
workspaceStore.membersByWorkspace[workspaceStore.activeWorkspaceId] ?? []
|
||||||
);
|
);
|
||||||
|
const isSettingsDirty = computed(() => {
|
||||||
|
const workspace = workspaceStore.activeWorkspace;
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return settingsForm.name.trim() !== workspace.name || settingsForm.timeZone.trim() !== workspace.timeZone;
|
||||||
|
});
|
||||||
const settingsTabs = computed(() => [
|
const settingsTabs = computed(() => [
|
||||||
{ key: 'general', label: t('workspaceSettings.tabs.general'), icon: mdiCogOutline },
|
{ key: 'general', label: t('workspaceSettings.tabs.general'), icon: mdiCogOutline },
|
||||||
{ key: 'members', label: t('workspaceSettings.tabs.members'), icon: mdiAccountGroupOutline },
|
{ key: 'members', label: t('workspaceSettings.tabs.members'), icon: mdiAccountGroupOutline },
|
||||||
@@ -50,6 +71,17 @@
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => workspaceStore.activeWorkspace,
|
||||||
|
workspace => {
|
||||||
|
settingsForm.name = workspace?.name ?? '';
|
||||||
|
settingsForm.timeZone = workspace?.timeZone ?? '';
|
||||||
|
settingsError.value = null;
|
||||||
|
settingsStatus.value = null;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => workspaceStore.activeWorkspaceId,
|
() => workspaceStore.activeWorkspaceId,
|
||||||
async workspaceId => {
|
async workspaceId => {
|
||||||
@@ -67,6 +99,56 @@
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function submitWorkspaceSettings() {
|
||||||
|
const workspace = workspaceStore.activeWorkspace;
|
||||||
|
|
||||||
|
if (!workspace || workspaceStore.isUpdating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsError.value = null;
|
||||||
|
settingsStatus.value = null;
|
||||||
|
|
||||||
|
const name = settingsForm.name.trim();
|
||||||
|
const timeZone = settingsForm.timeZone.trim();
|
||||||
|
|
||||||
|
if (!name || !timeZone) {
|
||||||
|
settingsError.value = t('workspaceSettings.errors.required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await workspaceStore.updateWorkspace(workspace.id, {
|
||||||
|
name,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
settingsStatus.value = t('workspaceSettings.general.saved');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update workspace settings:', error);
|
||||||
|
settingsError.value = t('workspaceSettings.errors.updateFailed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWorkspaceLogo(result) {
|
||||||
|
const workspace = workspaceStore.activeWorkspace;
|
||||||
|
|
||||||
|
if (!workspace || workspaceStore.isUploadingLogo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logoError.value = null;
|
||||||
|
logoStatus.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await workspaceStore.uploadWorkspaceLogo(workspace.id, result.file);
|
||||||
|
logoStatus.value = t('workspaceSettings.logo.saved');
|
||||||
|
isLogoDialogOpen.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update workspace logo:', error);
|
||||||
|
logoError.value = t('workspaceSettings.errors.logoUploadFailed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitInvite() {
|
async function submitInvite() {
|
||||||
if (!inviteForm.email.trim() || !inviteForm.role) {
|
if (!inviteForm.email.trim() || !inviteForm.role) {
|
||||||
return;
|
return;
|
||||||
@@ -133,31 +215,93 @@
|
|||||||
>
|
>
|
||||||
<article class="settings-card">
|
<article class="settings-card">
|
||||||
<div class="section-copy">
|
<div class="section-copy">
|
||||||
<span class="section-kicker">{{ t('workspaceSettings.general.summaryTitle') }}</span>
|
<span class="section-kicker">{{ t('workspaceSettings.general.detailsTitle') }}</span>
|
||||||
<p>{{ t('workspaceSettings.general.summaryDescription') }}</p>
|
<p>{{ t('workspaceSettings.general.detailsDescription') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl
|
<div
|
||||||
v-if="workspaceStore.activeWorkspace"
|
v-if="settingsError"
|
||||||
class="summary-grid"
|
class="page-message error"
|
||||||
>
|
>
|
||||||
<div>
|
{{ settingsError }}
|
||||||
<dt>{{ t('workspaceSettings.summary.name') }}</dt>
|
|
||||||
<dd>{{ workspaceStore.activeWorkspace.name }}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<dt>{{ t('workspaceSettings.summary.slug') }}</dt>
|
<div
|
||||||
<dd>{{ workspaceStore.activeWorkspace.slug }}</dd>
|
v-if="settingsStatus"
|
||||||
|
class="page-message success"
|
||||||
|
>
|
||||||
|
{{ settingsStatus }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<dt>{{ t('workspaceSettings.summary.timeZone') }}</dt>
|
<form
|
||||||
<dd>{{ workspaceStore.activeWorkspace.timeZone }}</dd>
|
v-if="workspaceStore.activeWorkspace"
|
||||||
|
class="form-stack"
|
||||||
|
@submit.prevent="submitWorkspaceSettings"
|
||||||
|
>
|
||||||
|
<div class="logo-picker-card">
|
||||||
|
<AppAvatar
|
||||||
|
:name="settingsForm.name || workspaceStore.activeWorkspace.name"
|
||||||
|
:src="workspaceStore.activeWorkspace.logoUrl"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<div class="logo-picker-copy">
|
||||||
|
<strong>{{ t('workspaceSettings.logo.title') }}</strong>
|
||||||
|
<small>{{ t('workspaceSettings.logo.description') }}</small>
|
||||||
|
<small
|
||||||
|
v-if="logoError"
|
||||||
|
class="field-error"
|
||||||
|
>
|
||||||
|
{{ logoError }}
|
||||||
|
</small>
|
||||||
|
<small
|
||||||
|
v-if="logoStatus"
|
||||||
|
class="field-success"
|
||||||
|
>
|
||||||
|
{{ logoStatus }}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<button
|
||||||
<dt>{{ t('workspaceSettings.summary.created') }}</dt>
|
class="secondary-button"
|
||||||
<dd>{{ formatDate(workspaceStore.activeWorkspace.createdAt) }}</dd>
|
type="button"
|
||||||
|
:disabled="workspaceStore.isUploadingLogo"
|
||||||
|
@click="isLogoDialogOpen = true"
|
||||||
|
>
|
||||||
|
{{ workspaceStore.isUploadingLogo ? t('common.saving') : t('workspaceSettings.logo.changeAction') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ t('workspaceSettings.fields.name') }}</span>
|
||||||
|
<input
|
||||||
|
v-model="settingsForm.name"
|
||||||
|
type="text"
|
||||||
|
:disabled="workspaceStore.isUpdating"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ t('workspaceSettings.fields.timeZone') }}</span>
|
||||||
|
<TimeZoneSelect
|
||||||
|
v-model="settingsForm.timeZone"
|
||||||
|
:disabled="workspaceStore.isUpdating"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="primary-button"
|
||||||
|
type="submit"
|
||||||
|
:disabled="workspaceStore.isUpdating || !isSettingsDirty"
|
||||||
|
>
|
||||||
|
{{ workspaceStore.isUpdating ? t('common.saving') : t('workspaceSettings.general.saveAction') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="empty-state"
|
||||||
|
>
|
||||||
|
{{ t('workspaceSettings.noWorkspaceSelected') }}
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -366,6 +510,16 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ImageCropperDialog
|
||||||
|
v-model="isLogoDialogOpen"
|
||||||
|
:title="t('workspaceSettings.logo.cropperTitle')"
|
||||||
|
:confirm-label="t('workspaceSettings.logo.saveAction')"
|
||||||
|
:upload-label="t('workspaceSettings.logo.chooseAction')"
|
||||||
|
:initial-url="workspaceStore.activeWorkspace?.logoUrl"
|
||||||
|
:is-saving="workspaceStore.isUploadingLogo"
|
||||||
|
@save="saveWorkspaceLogo"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -426,7 +580,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section-copy h1,
|
.section-copy h1,
|
||||||
.summary-grid dd,
|
|
||||||
.invite-row strong,
|
.invite-row strong,
|
||||||
.connector-copy strong,
|
.connector-copy strong,
|
||||||
.connector-status,
|
.connector-status,
|
||||||
@@ -440,7 +593,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section-copy p,
|
.section-copy p,
|
||||||
.summary-grid dt,
|
|
||||||
.invite-row span,
|
.invite-row span,
|
||||||
.invite-row small,
|
.invite-row small,
|
||||||
.empty-state,
|
.empty-state,
|
||||||
@@ -452,22 +604,32 @@
|
|||||||
color: #526178;
|
color: #526178;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-grid {
|
.logo-picker-card {
|
||||||
@apply grid gap-4 sm:grid-cols-2;
|
@apply flex flex-col gap-4 rounded-[1rem] border p-4 sm:flex-row sm:items-center;
|
||||||
}
|
background: #fffaf2;
|
||||||
|
|
||||||
.summary-grid div {
|
|
||||||
@apply rounded-[1rem] border p-4;
|
|
||||||
background: #f8fafc;
|
|
||||||
border-color: rgba(23, 32, 51, 0.08);
|
border-color: rgba(23, 32, 51, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-grid dt {
|
.logo-picker-copy {
|
||||||
@apply text-xs font-bold uppercase tracking-[0.16em];
|
@apply flex min-w-0 flex-1 flex-col gap-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-grid dd {
|
.logo-picker-copy strong {
|
||||||
@apply mt-2 text-base font-semibold;
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-picker-copy small,
|
||||||
|
.field-error,
|
||||||
|
.field-success {
|
||||||
|
@apply text-sm leading-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-success {
|
||||||
|
color: #0f766e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-stack {
|
.form-stack {
|
||||||
@@ -498,6 +660,18 @@
|
|||||||
color: #fffaf2;
|
color: #fffaf2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
@apply inline-flex items-center justify-center rounded-full px-4 py-2 text-sm font-semibold;
|
||||||
|
background: rgba(23, 32, 51, 0.08);
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:disabled,
|
||||||
|
.secondary-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
|
||||||
.invite-list,
|
.invite-list,
|
||||||
.connector-list,
|
.connector-list,
|
||||||
.workflow-rule-list,
|
.workflow-rule-list,
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
import WorkspaceSelector from './WorkspaceSelector.vue';
|
||||||
import {
|
import {
|
||||||
mdiChevronDown,
|
|
||||||
mdiCogOutline,
|
mdiCogOutline,
|
||||||
mdiLogin,
|
mdiLogin,
|
||||||
mdiPlus,
|
mdiPlus,
|
||||||
@@ -23,20 +22,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const workspaceStore = useWorkspaceStore();
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const isWorkspaceMenuOpen = ref(false);
|
|
||||||
const workspaceMenuRef = ref(null);
|
|
||||||
|
|
||||||
const canSwitchWorkspaces = computed(() => workspaceStore.workspaces.length > 1);
|
|
||||||
const canManageWorkspaces = computed(() => authStore.isManager);
|
|
||||||
const canOpenWorkspaceMenu = computed(() => canSwitchWorkspaces.value || canManageWorkspaces.value);
|
|
||||||
const activeWorkspaceName = computed(() =>
|
|
||||||
workspaceStore.activeWorkspace?.name || t('nav.noWorkspace')
|
|
||||||
);
|
|
||||||
const appBarActions = computed(() => {
|
const appBarActions = computed(() => {
|
||||||
if (!authStore.isAuthenticated) {
|
if (!authStore.isAuthenticated) {
|
||||||
return [];
|
return [];
|
||||||
@@ -81,38 +69,6 @@
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggleWorkspaceMenu() {
|
|
||||||
if (!canOpenWorkspaceMenu.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isWorkspaceMenuOpen.value = !isWorkspaceMenuOpen.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooseWorkspace(workspaceId) {
|
|
||||||
workspaceStore.setActiveWorkspace(workspaceId);
|
|
||||||
isWorkspaceMenuOpen.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openCreateWorkspace() {
|
|
||||||
isWorkspaceMenuOpen.value = false;
|
|
||||||
await router.push({ name: 'workspace-create' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDocumentClick(event) {
|
|
||||||
if (workspaceMenuRef.value && !workspaceMenuRef.value.contains(event.target)) {
|
|
||||||
isWorkspaceMenuOpen.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('click', handleDocumentClick);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener('click', handleDocumentClick);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -134,52 +90,9 @@
|
|||||||
|
|
||||||
<div class="side-menu">
|
<div class="side-menu">
|
||||||
<div class="side-menu-items side-menu-left">
|
<div class="side-menu-items side-menu-left">
|
||||||
<div
|
<WorkspaceSelector
|
||||||
v-if="authStore.isAuthenticated"
|
v-if="authStore.isAuthenticated"
|
||||||
ref="workspaceMenuRef"
|
|
||||||
class="user-menu-wrap"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="menu-item-action workspace-trigger"
|
|
||||||
:class="{ 'workspace-trigger-static': !canOpenWorkspaceMenu }"
|
|
||||||
@click.stop="toggleWorkspaceMenu"
|
|
||||||
>
|
|
||||||
<span class="workspace-trigger-mark">W</span>
|
|
||||||
<span class="label workspace-trigger-label">{{ activeWorkspaceName }}</span>
|
|
||||||
<v-icon
|
|
||||||
v-if="canOpenWorkspaceMenu"
|
|
||||||
:icon="mdiChevronDown"
|
|
||||||
class="user-trigger-icon"
|
|
||||||
:class="{ 'user-trigger-icon-open': isWorkspaceMenuOpen }"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="isWorkspaceMenuOpen"
|
|
||||||
class="user-menu"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="workspace in workspaceStore.workspaces"
|
|
||||||
:key="workspace.id"
|
|
||||||
class="user-menu-item"
|
|
||||||
:class="{ 'user-menu-item-active': workspace.id === workspaceStore.activeWorkspaceId }"
|
|
||||||
@click="chooseWorkspace(workspace.id)"
|
|
||||||
>
|
|
||||||
<span>{{ workspace.name }}</span>
|
|
||||||
<small>{{ workspace.timeZone }}</small>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="canManageWorkspaces"
|
|
||||||
class="user-menu-item user-menu-item-create"
|
|
||||||
type="button"
|
|
||||||
@click="openCreateWorkspace"
|
|
||||||
>
|
|
||||||
<span>{{ t('workspaceSelector.createAction') }}</span>
|
|
||||||
<v-icon :icon="mdiPlus" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="side-menu-items side-menu-right">
|
<div class="side-menu-items side-menu-right">
|
||||||
@@ -283,73 +196,6 @@
|
|||||||
@apply text-xl;
|
@apply text-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-menu-wrap {
|
|
||||||
@apply relative;
|
|
||||||
z-index: 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-trigger {
|
|
||||||
@apply max-w-[18rem] pl-2 pr-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-trigger-icon {
|
|
||||||
@apply text-base;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-trigger-icon-open {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-trigger-static {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-trigger-mark {
|
|
||||||
@apply flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl text-xs font-black uppercase;
|
|
||||||
background: linear-gradient(135deg, rgba(255, 138, 61, 0.16), rgba(239, 68, 68, 0.14));
|
|
||||||
color: #c2410c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-trigger-label {
|
|
||||||
@apply max-w-[11rem] truncate;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-menu {
|
|
||||||
@apply absolute right-0 top-[calc(100%+0.75rem)] flex min-w-[14rem] flex-col gap-1 rounded-[1.25rem] border p-2;
|
|
||||||
background: rgba(255, 255, 255, 0.96);
|
|
||||||
border-color: rgba(23, 32, 51, 0.08);
|
|
||||||
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
|
|
||||||
z-index: 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-menu-item {
|
|
||||||
@apply flex items-center gap-3 rounded-[0.9rem] px-3 py-3 text-left text-sm font-semibold transition-colors;
|
|
||||||
color: #172033;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-menu-item:hover {
|
|
||||||
background: rgba(23, 32, 51, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-menu-item-danger {
|
|
||||||
color: #b91c1c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-menu-item-active {
|
|
||||||
background: rgba(255, 138, 61, 0.12);
|
|
||||||
color: #c2410c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-menu-item small {
|
|
||||||
@apply ml-auto text-xs font-medium;
|
|
||||||
color: #526178;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-menu-item-create {
|
|
||||||
@apply justify-between border border-dashed;
|
|
||||||
border-color: rgba(23, 32, 51, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-action-link {
|
.menu-action-link {
|
||||||
@apply no-underline;
|
@apply no-underline;
|
||||||
}
|
}
|
||||||
|
|||||||
202
frontend/src/layouts/main/WorkspaceSelector.vue
Normal file
202
frontend/src/layouts/main/WorkspaceSelector.vue
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import AppAvatar from '@/components/AppAvatar.vue';
|
||||||
|
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||||
|
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||||
|
import {
|
||||||
|
mdiChevronDown,
|
||||||
|
mdiPlus,
|
||||||
|
} from '@mdi/js';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const workspaceStore = useWorkspaceStore();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const isWorkspaceMenuOpen = ref(false);
|
||||||
|
const workspaceMenuRef = ref(null);
|
||||||
|
|
||||||
|
const canSwitchWorkspaces = computed(() => workspaceStore.workspaces.length > 1);
|
||||||
|
const canManageWorkspaces = computed(() => authStore.isManager);
|
||||||
|
const canOpenWorkspaceMenu = computed(() => canSwitchWorkspaces.value || canManageWorkspaces.value);
|
||||||
|
const activeWorkspaceName = computed(() =>
|
||||||
|
workspaceStore.activeWorkspace?.name || t('nav.noWorkspace')
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleWorkspaceMenu() {
|
||||||
|
if (!canOpenWorkspaceMenu.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isWorkspaceMenuOpen.value = !isWorkspaceMenuOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseWorkspace(workspaceId) {
|
||||||
|
workspaceStore.setActiveWorkspace(workspaceId);
|
||||||
|
isWorkspaceMenuOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCreateWorkspace() {
|
||||||
|
isWorkspaceMenuOpen.value = false;
|
||||||
|
await router.push({ name: 'workspace-create' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentClick(event) {
|
||||||
|
if (workspaceMenuRef.value && !workspaceMenuRef.value.contains(event.target)) {
|
||||||
|
isWorkspaceMenuOpen.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleDocumentClick);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', handleDocumentClick);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="workspaceMenuRef"
|
||||||
|
class="user-menu-wrap"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="menu-item-action workspace-trigger"
|
||||||
|
:class="{ 'workspace-trigger-static': !canOpenWorkspaceMenu }"
|
||||||
|
@click.stop="toggleWorkspaceMenu"
|
||||||
|
>
|
||||||
|
<AppAvatar
|
||||||
|
:name="activeWorkspaceName"
|
||||||
|
:src="workspaceStore.activeWorkspace?.logoUrl"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<span class="label workspace-trigger-label">{{ activeWorkspaceName }}</span>
|
||||||
|
<v-icon
|
||||||
|
v-if="canOpenWorkspaceMenu"
|
||||||
|
:icon="mdiChevronDown"
|
||||||
|
class="user-trigger-icon"
|
||||||
|
:class="{ 'user-trigger-icon-open': isWorkspaceMenuOpen }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isWorkspaceMenuOpen"
|
||||||
|
class="user-menu"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="workspace in workspaceStore.workspaces"
|
||||||
|
:key="workspace.id"
|
||||||
|
class="user-menu-item"
|
||||||
|
:class="{ 'user-menu-item-active': workspace.id === workspaceStore.activeWorkspaceId }"
|
||||||
|
@click="chooseWorkspace(workspace.id)"
|
||||||
|
>
|
||||||
|
<AppAvatar
|
||||||
|
:name="workspace.name"
|
||||||
|
:src="workspace.logoUrl"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<span class="user-menu-item-copy">
|
||||||
|
<span>{{ workspace.name }}</span>
|
||||||
|
<small>{{ workspace.timeZone }}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="canManageWorkspaces"
|
||||||
|
class="user-menu-item user-menu-item-create"
|
||||||
|
type="button"
|
||||||
|
@click="openCreateWorkspace"
|
||||||
|
>
|
||||||
|
<span>{{ t('workspaceSelector.createAction') }}</span>
|
||||||
|
<v-icon :icon="mdiPlus" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.label {
|
||||||
|
@apply hidden text-nowrap md:inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-action {
|
||||||
|
@apply flex h-11 items-center gap-3 rounded-full px-4 transition-colors;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
color: #172033;
|
||||||
|
border: 1px solid rgba(23, 32, 51, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-action:hover {
|
||||||
|
background: #172033;
|
||||||
|
color: #fffaf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-wrap {
|
||||||
|
@apply relative;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-trigger {
|
||||||
|
@apply max-w-[18rem] pl-2 pr-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-trigger-icon {
|
||||||
|
@apply text-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-trigger-icon-open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-trigger-static {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-trigger-label {
|
||||||
|
@apply max-w-[11rem] truncate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu {
|
||||||
|
@apply absolute right-0 top-[calc(100%+0.75rem)] flex min-w-[14rem] flex-col gap-1 rounded-[1.25rem] border p-2;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
border-color: rgba(23, 32, 51, 0.08);
|
||||||
|
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item {
|
||||||
|
@apply flex items-center gap-3 rounded-[0.9rem] px-3 py-3 text-left text-sm font-semibold transition-colors;
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item:hover {
|
||||||
|
background: rgba(23, 32, 51, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item-active {
|
||||||
|
background: rgba(255, 138, 61, 0.12);
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item-copy {
|
||||||
|
@apply flex min-w-0 flex-1 flex-col gap-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item-copy span,
|
||||||
|
.user-menu-item-copy small {
|
||||||
|
@apply truncate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item-copy small {
|
||||||
|
@apply text-xs font-medium;
|
||||||
|
color: #526178;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item-create {
|
||||||
|
@apply justify-between border border-dashed;
|
||||||
|
border-color: rgba(23, 32, 51, 0.12);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -35,7 +35,8 @@
|
|||||||
"website": "Website",
|
"website": "Website",
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"creating": "Creating..."
|
"creating": "Creating...",
|
||||||
|
"saving": "Saving..."
|
||||||
},
|
},
|
||||||
"workspaceSelector": {
|
"workspaceSelector": {
|
||||||
"createAction": "Add workspace"
|
"createAction": "Add workspace"
|
||||||
@@ -309,14 +310,24 @@
|
|||||||
"description": "Manage the portrait and account details shown inside the workspace.",
|
"description": "Manage the portrait and account details shown inside the workspace.",
|
||||||
"updatePortrait": "Update portrait",
|
"updatePortrait": "Update portrait",
|
||||||
"accountDetails": "Account details",
|
"accountDetails": "Account details",
|
||||||
"accountDetailsDescription": "Additional account editing fields can be added here next.",
|
"accountDetailsDescription": "Edit the profile details other workspace members see.",
|
||||||
|
"saveDetails": "Save details",
|
||||||
|
"saved": "Profile details saved",
|
||||||
|
"portraitSaved": "Portrait saved",
|
||||||
"alias": "Alias",
|
"alias": "Alias",
|
||||||
|
"firstname": "First name",
|
||||||
|
"lastname": "Last name",
|
||||||
"fullName": "Full name",
|
"fullName": "Full name",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"noEmail": "No email set",
|
"noEmail": "No email set",
|
||||||
"cropperTitle": "Update user portrait",
|
"cropperTitle": "Update user portrait",
|
||||||
"savePortrait": "Save portrait",
|
"savePortrait": "Save portrait",
|
||||||
"choosePortrait": "Choose portrait"
|
"choosePortrait": "Choose portrait",
|
||||||
|
"errors": {
|
||||||
|
"emailRequired": "Email is required.",
|
||||||
|
"saveFailed": "Profile details could not be saved.",
|
||||||
|
"portraitFailed": "Portrait could not be saved."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"workspaceSettings": {
|
"workspaceSettings": {
|
||||||
"eyebrow": "Settings",
|
"eyebrow": "Settings",
|
||||||
@@ -334,12 +345,13 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"required": "All workspace fields are required.",
|
"required": "All workspace fields are required.",
|
||||||
"createFailed": "The workspace could not be created.",
|
"createFailed": "The workspace could not be created.",
|
||||||
|
"updateFailed": "The workspace settings could not be saved.",
|
||||||
|
"logoUploadFailed": "The workspace logo could not be saved.",
|
||||||
"inviteRequired": "Email and role are required to invite a member.",
|
"inviteRequired": "Email and role are required to invite a member.",
|
||||||
"inviteFailed": "The workspace invite could not be created."
|
"inviteFailed": "The workspace invite could not be created."
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "Workspace name",
|
"name": "Workspace name",
|
||||||
"slug": "Workspace slug",
|
|
||||||
"timeZone": "Time zone",
|
"timeZone": "Time zone",
|
||||||
"memberEmail": "Member email",
|
"memberEmail": "Member email",
|
||||||
"memberRole": "Role"
|
"memberRole": "Role"
|
||||||
@@ -353,9 +365,7 @@
|
|||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"slug": "Slug",
|
"timeZone": "Time zone"
|
||||||
"timeZone": "Time zone",
|
|
||||||
"created": "Created"
|
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
@@ -382,8 +392,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"summaryTitle": "Workspace summary",
|
"detailsTitle": "Workspace details",
|
||||||
"summaryDescription": "Reference details for the workspace currently in context."
|
"detailsDescription": "Update the workspace name and default time zone used across schedules and workspace views.",
|
||||||
|
"saveAction": "Save workspace",
|
||||||
|
"saved": "Workspace settings saved."
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"title": "Workspace logo",
|
||||||
|
"description": "Use a local file or remote image, then crop it for the workspace.",
|
||||||
|
"changeAction": "Change image",
|
||||||
|
"cropperTitle": "Update workspace logo",
|
||||||
|
"saveAction": "Save logo",
|
||||||
|
"chooseAction": "Choose logo",
|
||||||
|
"saved": "Workspace logo saved."
|
||||||
},
|
},
|
||||||
"approvals": {
|
"approvals": {
|
||||||
"flowTitle": "Approval flow",
|
"flowTitle": "Approval flow",
|
||||||
|
|||||||
@@ -35,7 +35,8 @@
|
|||||||
"website": "Site web",
|
"website": "Site web",
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"creating": "Création..."
|
"creating": "Création...",
|
||||||
|
"saving": "Enregistrement..."
|
||||||
},
|
},
|
||||||
"workspaceSelector": {
|
"workspaceSelector": {
|
||||||
"createAction": "Ajouter un espace"
|
"createAction": "Ajouter un espace"
|
||||||
@@ -309,14 +310,24 @@
|
|||||||
"description": "Gérez le portrait et les informations du compte affichés dans l'espace.",
|
"description": "Gérez le portrait et les informations du compte affichés dans l'espace.",
|
||||||
"updatePortrait": "Mettre à jour le portrait",
|
"updatePortrait": "Mettre à jour le portrait",
|
||||||
"accountDetails": "Détails du compte",
|
"accountDetails": "Détails du compte",
|
||||||
"accountDetailsDescription": "Des champs supplémentaires d'édition du compte peuvent être ajoutés ici ensuite.",
|
"accountDetailsDescription": "Modifiez les informations de profil visibles par les autres membres.",
|
||||||
|
"saveDetails": "Enregistrer les détails",
|
||||||
|
"saved": "Informations de profil enregistrées",
|
||||||
|
"portraitSaved": "Portrait enregistré",
|
||||||
"alias": "Alias",
|
"alias": "Alias",
|
||||||
|
"firstname": "Prénom",
|
||||||
|
"lastname": "Nom",
|
||||||
"fullName": "Nom complet",
|
"fullName": "Nom complet",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"noEmail": "Aucun email défini",
|
"noEmail": "Aucun email défini",
|
||||||
"cropperTitle": "Mettre à jour le portrait utilisateur",
|
"cropperTitle": "Mettre à jour le portrait utilisateur",
|
||||||
"savePortrait": "Enregistrer le portrait",
|
"savePortrait": "Enregistrer le portrait",
|
||||||
"choosePortrait": "Choisir un portrait"
|
"choosePortrait": "Choisir un portrait",
|
||||||
|
"errors": {
|
||||||
|
"emailRequired": "L'email est requis.",
|
||||||
|
"saveFailed": "Les informations de profil n'ont pas pu être enregistrées.",
|
||||||
|
"portraitFailed": "Le portrait n'a pas pu être enregistré."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"workspaceSettings": {
|
"workspaceSettings": {
|
||||||
"eyebrow": "Paramètres",
|
"eyebrow": "Paramètres",
|
||||||
@@ -334,12 +345,13 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"required": "Tous les champs de l'espace sont requis.",
|
"required": "Tous les champs de l'espace sont requis.",
|
||||||
"createFailed": "L'espace n'a pas pu être créé.",
|
"createFailed": "L'espace n'a pas pu être créé.",
|
||||||
|
"updateFailed": "Les paramètres de l'espace n'ont pas pu être enregistrés.",
|
||||||
|
"logoUploadFailed": "Le logo de l'espace n'a pas pu être enregistré.",
|
||||||
"inviteRequired": "L'email et le rôle sont requis pour inviter un membre.",
|
"inviteRequired": "L'email et le rôle sont requis pour inviter un membre.",
|
||||||
"inviteFailed": "L'invitation de l'espace n'a pas pu être créée."
|
"inviteFailed": "L'invitation de l'espace n'a pas pu être créée."
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "Nom de l'espace",
|
"name": "Nom de l'espace",
|
||||||
"slug": "Slug de l'espace",
|
|
||||||
"timeZone": "Fuseau horaire",
|
"timeZone": "Fuseau horaire",
|
||||||
"memberEmail": "Email du membre",
|
"memberEmail": "Email du membre",
|
||||||
"memberRole": "Rôle"
|
"memberRole": "Rôle"
|
||||||
@@ -353,9 +365,7 @@
|
|||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
"slug": "Slug",
|
"timeZone": "Fuseau horaire"
|
||||||
"timeZone": "Fuseau horaire",
|
|
||||||
"created": "Créé"
|
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"general": "Général",
|
"general": "Général",
|
||||||
@@ -382,8 +392,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"summaryTitle": "Résumé de l'espace",
|
"detailsTitle": "Détails de l'espace",
|
||||||
"summaryDescription": "Détails de référence pour l'espace actuellement en contexte."
|
"detailsDescription": "Mettez à jour le nom de l'espace et le fuseau horaire par défaut utilisés dans les calendriers et les vues de l'espace.",
|
||||||
|
"saveAction": "Enregistrer l'espace",
|
||||||
|
"saved": "Paramètres de l'espace enregistrés."
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"title": "Logo de l'espace",
|
||||||
|
"description": "Utilisez un fichier local ou une image distante, puis recadrez-la pour l'espace.",
|
||||||
|
"changeAction": "Changer l'image",
|
||||||
|
"cropperTitle": "Mettre à jour le logo de l'espace",
|
||||||
|
"saveAction": "Enregistrer le logo",
|
||||||
|
"chooseAction": "Choisir un logo",
|
||||||
|
"saved": "Logo de l'espace enregistré."
|
||||||
},
|
},
|
||||||
"approvals": {
|
"approvals": {
|
||||||
"flowTitle": "Flux d'approbation",
|
"flowTitle": "Flux d'approbation",
|
||||||
|
|||||||
@@ -54,6 +54,25 @@ const vuetify = createVuetify({
|
|||||||
aliases,
|
aliases,
|
||||||
sets: { mdi },
|
sets: { mdi },
|
||||||
},
|
},
|
||||||
|
theme: {
|
||||||
|
defaultTheme: 'socializeLight',
|
||||||
|
themes: {
|
||||||
|
socializeLight: {
|
||||||
|
dark: false,
|
||||||
|
colors: {
|
||||||
|
background: '#f4f6f3',
|
||||||
|
surface: '#fbfaf6',
|
||||||
|
primary: '#172033',
|
||||||
|
secondary: '#fff3e2',
|
||||||
|
accent: '#ff8a3d',
|
||||||
|
error: '#bc2f2f',
|
||||||
|
info: '#2563eb',
|
||||||
|
success: '#2fa58d',
|
||||||
|
warning: '#b45309',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
|
|||||||
4451
shared/openapi/openapi.json
Normal file
4451
shared/openapi/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user