Compare commits

...

10 Commits

73 changed files with 12264 additions and 446 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,4 +1,7 @@
# PROMPT TEMPLATES
I need you to help me write a feature. First, we need to define it, so you will ask me questions one-by-one to make sure we have a shared understanding of the scope
and expectating. - The feature we want is a way for your clients to report bugs/suggestions/requests from within our app. It should not be intrusive. It should allow
them to take a screen capture, put annotation, describe their request and/or issue. Then, as a dev, i will want to collect and review them.
## Purpose
This document standardizes how we interact with AI coding agents (Codex, Claude, etc).

View File

@@ -5,6 +5,7 @@ using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.Clients.Data;
using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Notifications.Data;
using Socialize.Api.Modules.Projects.Data;
@@ -28,18 +29,21 @@ public class AppDbContext(
public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>();
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
public DbSet<FeedbackReport> FeedbackReports => Set<FeedbackReport>();
public DbSet<FeedbackTag> FeedbackTags => Set<FeedbackTag>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(modelBuilder);
base.OnModelCreating(builder);
modelBuilder.ConfigureWorkspacesModule();
modelBuilder.ConfigureClientsModule();
modelBuilder.ConfigureProjectsModule();
modelBuilder.ConfigureContentItemsModule();
modelBuilder.ConfigureAssetsModule();
modelBuilder.ConfigureCommentsModule();
modelBuilder.ConfigureApprovalsModule();
modelBuilder.ConfigureNotificationsModule();
builder.ConfigureWorkspacesModule();
builder.ConfigureClientsModule();
builder.ConfigureProjectsModule();
builder.ConfigureContentItemsModule();
builder.ConfigureAssetsModule();
builder.ConfigureCommentsModule();
builder.ConfigureApprovalsModule();
builder.ConfigureNotificationsModule();
builder.ConfigureFeedbackModule();
}
}

View File

@@ -50,7 +50,7 @@ public static class DependencyInjection
{
using IServiceScope scope = app.ApplicationServices.CreateScope();
await using AppDbContext context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await context.Database.EnsureCreatedAsync(cancellationToken);
await context.Database.MigrateAsync(cancellationToken);
return app;
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Infrastructure.BlobStorage.Configuration;
public sealed class LocalBlobStorageOptions
{
public const string SectionName = "LocalBlobStorage";
public string RootPath { get; set; } = "App_Data/blob-storage";
public string RequestPath { get; set; } = "/api/storage";
}

View File

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

View File

@@ -1,154 +0,0 @@
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
namespace Socialize.Api.Infrastructure.BlobStorage.Services;
public class AzureBlobStorage : IBlobStorage
{
private const long MaxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes
private readonly BlobServiceClient _blobServiceClient;
private readonly ILogger<AzureBlobStorage> _logger;
public AzureBlobStorage(IConfiguration configuration, ILogger<AzureBlobStorage> logger)
{
_logger = logger;
string? connectionString = configuration.GetConnectionString("AzureBlob");
_blobServiceClient = new BlobServiceClient(connectionString);
}
/// <summary>
/// Upload a file to microsoft azure blob storage.
/// </summary>
/// <param name="containerName">The name of the container where the file is stored.</param>
/// <param name="blobName">The blob name (path within the container, include the file name).</param>
/// <param name="stream"></param>
/// <param name="contentType">The content type.</param>
/// <param name="ct">The cancellation token</param>
/// <returns></returns>
public async Task<string> UploadFileAsync(
string containerName,
string blobName,
Stream stream,
string contentType,
CancellationToken ct = default)
{
// Read the file stream into a memory stream to determine the length
// WATCH FOR MEMORY USAGE USING THE MEMORY STREAM.
stream.Position = 0;
// Check if the file size exceeds the maximum upload size
if (stream.Length > MaxUploadSize)
{
_logger.LogError(
$"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
throw new InvalidOperationException(
$"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
}
// Validate content type
if (!ContentTypes.IsAllowed(contentType, stream))
{
_logger.LogError(
$"Blob storage: Unsupported file type {contentType}.");
throw new InvalidOperationException("Unsupported file type.");
}
try
{
// Get a reference to a container
BlobContainerClient? containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
// Create the container if it does not exist
await containerClient.CreateIfNotExistsAsync(
PublicAccessType.Blob,
cancellationToken: ct);
// Get a reference to a blob
BlobClient? blobClient = containerClient.GetBlobClient(blobName);
// Define the BlobHttpHeaders to include the content type
BlobHttpHeaders blobHttpHeaders = new() { ContentType = contentType };
// Upload the file
Response<BlobContentInfo>? response = await blobClient.UploadAsync(
stream,
new BlobUploadOptions { HttpHeaders = blobHttpHeaders },
ct);
string fileUri = blobClient.Uri.ToString();
_logger.LogInformation(
"""
Blob storage: Status [ {ResponseStatus} ]
Uploaded [ {BlobName} ] to the container [ {ContainerName} ]
with contentType [ {ContentType} ]
with a length of [ {StreamLength} bytes ]
with the uri [ {FileUri} ]
""",
response.GetRawResponse().Status.ToString(),
blobName,
containerName,
contentType,
stream.Length,
fileUri
);
// Return the URI of the uploaded blob
return fileUri;
}
catch (RequestFailedException ex)
{
_logger.LogError($"Blob storage: Azure Storage request failed: {ex.Message}");
throw;
}
catch (Exception ex)
{
_logger.LogError($"Blob storage: An error occurred: {ex.Message}");
throw;
}
}
/// <summary>
/// Download a file to microsoft's azure blob storage.
/// </summary>
/// <param name="blobName">The blob name (path within the container).</param>
/// <param name="containerName">The name of the container where the file is stored. (users)</param>
/// <param name="ct">The cancellation token for the request</param>
/// <returns></returns>
public async Task<MemoryStream> DownloadFileAsync(
string containerName,
string blobName,
CancellationToken ct = default)
{
try
{
// Get a reference to a container
BlobContainerClient? containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
// Get a reference to a blob
BlobClient? blobClient = containerClient.GetBlobClient(blobName);
// Download the blob to a stream
BlobDownloadInfo download = await blobClient.DownloadAsync(ct);
MemoryStream memoryStream = new();
await download.Content.CopyToAsync(memoryStream, ct);
memoryStream.Position = 0; // Ensure the stream is at the beginning
return memoryStream;
}
catch (RequestFailedException ex)
{
_logger.LogError($"Azure Storage request failed: {ex.Message}");
throw;
}
catch (Exception ex)
{
_logger.LogError($"An error occurred: {ex.Message}");
throw;
}
}
}

View File

@@ -0,0 +1,142 @@
using Microsoft.Extensions.Options;
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
namespace Socialize.Api.Infrastructure.BlobStorage.Services;
public sealed class LocalBlobStorage(
IWebHostEnvironment environment,
IHttpContextAccessor httpContextAccessor,
IOptions<LocalBlobStorageOptions> options,
ILogger<LocalBlobStorage> logger)
: IBlobStorage
{
private const long MaxUploadSize = 10 * 1024 * 1024;
private const string ContentTypeMetadataSuffix = ".content-type";
private readonly LocalBlobStorageOptions _options = options.Value;
public async Task<string> UploadFileAsync(
string containerName,
string blobName,
Stream stream,
string contentType,
CancellationToken ct = default)
{
stream.Position = 0;
if (stream.Length > MaxUploadSize)
{
logger.LogError("Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.", MaxUploadSize);
throw new InvalidOperationException($"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
}
if (!ContentTypes.IsAllowed(contentType, stream))
{
logger.LogError("Blob storage: Unsupported file type {ContentType}.", contentType);
throw new InvalidOperationException("Unsupported file type.");
}
string relativePath = GetSafeRelativePath(containerName, blobName);
string filePath = Path.Combine(GetRootPath(), relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? GetRootPath());
await using FileStream fileStream = File.Create(filePath);
await stream.CopyToAsync(fileStream, ct);
await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct);
string fileUri = BuildPublicUrl(relativePath);
logger.LogInformation(
"Blob storage: Uploaded [{BlobName}] to local container [{ContainerName}] with contentType [{ContentType}] and uri [{FileUri}]",
blobName,
containerName,
contentType,
fileUri);
return fileUri;
}
public async Task<MemoryStream> DownloadFileAsync(
string containerName,
string blobName,
CancellationToken ct = default)
{
string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName));
if (!File.Exists(filePath))
{
throw new FileNotFoundException("Blob storage: Local file was not found.", blobName);
}
MemoryStream memoryStream = new();
await using FileStream fileStream = File.OpenRead(filePath);
await fileStream.CopyToAsync(memoryStream, ct);
memoryStream.Position = 0;
return memoryStream;
}
internal string GetRootPath()
{
if (Path.IsPathRooted(_options.RootPath))
{
return Path.GetFullPath(_options.RootPath);
}
return Path.GetFullPath(Path.Combine(environment.ContentRootPath, _options.RootPath));
}
internal static string? ReadContentType(string filePath)
{
string metadataPath = GetContentTypeMetadataPath(filePath);
return File.Exists(metadataPath)
? File.ReadAllText(metadataPath)
: null;
}
private static string GetContentTypeMetadataPath(string filePath)
{
return $"{filePath}{ContentTypeMetadataSuffix}";
}
private static string GetSafeRelativePath(string containerName, string blobName)
{
if (Path.IsPathRooted(containerName) || Path.IsPathRooted(blobName))
{
throw new InvalidOperationException("Blob storage: Blob paths must be relative.");
}
string[] pathParts = [containerName, .. blobName.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar])];
if (pathParts.Any(part => part is "" or "." or ".."))
{
throw new InvalidOperationException("Blob storage: Blob paths must not contain relative path segments.");
}
return Path.Combine(pathParts);
}
private string BuildPublicUrl(string relativePath)
{
HttpRequest? request = httpContextAccessor.HttpContext?.Request;
string requestPath = NormalizeRequestPath(_options.RequestPath);
string urlPath = $"{requestPath}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
if (request is null)
{
return urlPath;
}
return $"{request.Scheme}://{request.Host}{request.PathBase}{urlPath}";
}
internal static string NormalizeRequestPath(string requestPath)
{
string normalized = string.IsNullOrWhiteSpace(requestPath)
? "/api/storage"
: requestPath.Trim();
return normalized.StartsWith("/", StringComparison.Ordinal)
? normalized.TrimEnd('/')
: $"/{normalized.TrimEnd('/')}";
}
}

View File

@@ -1,5 +1,6 @@
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
using Socialize.Api.Infrastructure.BlobStorage.Services;
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
using Socialize.Api.Infrastructure.Configuration;
using Socialize.Api.Infrastructure.Emailer.Configuration;
using Socialize.Api.Infrastructure.Emailer.Contracts;
@@ -16,7 +17,10 @@ public static class DependencyInjection
builder.Services.Configure<WebsiteOptions>(
builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName));
builder.Services.AddTransient<IBlobStorage, AzureBlobStorage>();
builder.Services.Configure<LocalBlobStorageOptions>(
builder.Configuration.GetSection(LocalBlobStorageOptions.SectionName));
builder.Services.AddTransient<LocalBlobStorage>();
builder.Services.AddTransient<IBlobStorage>(services => services.GetRequiredService<LocalBlobStorage>());
builder.Services.Configure<StripeOptions>(
builder.Configuration.GetSection(StripeOptions.ConfigurationSection));

View File

@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Socialize.Api.Data;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AppDbContext))]
[Migration("20260430054500_AddWorkspaceLogo")]
public partial class AddWorkspaceLogo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "LogoUrl",
table: "Workspaces",
type: "character varying(2048)",
maxLength: 2048,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LogoUrl",
table: "Workspaces");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddFeedbackFoundation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FeedbackReports",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Description = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: false),
ReporterUserId = table.Column<Guid>(type: "uuid", nullable: false),
ReporterDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ReporterEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
SubmittedPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
BrowserUserAgent = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
ViewportWidth = table.Column<int>(type: "integer", nullable: true),
ViewportHeight = table.Column<int>(type: "integer", nullable: true),
AppVersion = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: true),
WorkspaceName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ClientId = table.Column<Guid>(type: "uuid", nullable: true),
ClientName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ProjectId = table.Column<Guid>(type: "uuid", nullable: true),
ProjectName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: true),
ContentItemTitle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
LastActivityAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CancelledAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CancelledByUserId = table.Column<Guid>(type: "uuid", nullable: true),
CancellationReason = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackReports", x => x.Id);
});
migrationBuilder.CreateTable(
name: "FeedbackTags",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FeedbackReportId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
NormalizedName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FeedbackTags", x => x.Id);
table.ForeignKey(
name: "FK_FeedbackTags_FeedbackReports_FeedbackReportId",
column: x => x.FeedbackReportId,
principalTable: "FeedbackReports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_LastActivityAt",
table: "FeedbackReports",
column: "LastActivityAt");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_ReporterUserId",
table: "FeedbackReports",
column: "ReporterUserId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_Status",
table: "FeedbackReports",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_Type",
table: "FeedbackReports",
column: "Type");
migrationBuilder.CreateIndex(
name: "IX_FeedbackReports_WorkspaceId",
table: "FeedbackReports",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_FeedbackTags_FeedbackReportId_NormalizedName",
table: "FeedbackTags",
columns: new[] { "FeedbackReportId", "NormalizedName" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_FeedbackTags_NormalizedName",
table: "FeedbackTags",
column: "NormalizedName");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FeedbackTags");
migrationBuilder.DropTable(
name: "FeedbackReports");
}
}
}

View File

@@ -125,7 +125,7 @@ namespace Socialize.Api.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalDecision", b =>
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalDecision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -168,7 +168,7 @@ namespace Socialize.Api.Migrations
b.ToTable("ApprovalDecisions", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalRequest", b =>
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -230,7 +230,7 @@ namespace Socialize.Api.Migrations
b.ToTable("ApprovalRequests", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Assets.Data.Asset", b =>
modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -286,7 +286,7 @@ namespace Socialize.Api.Migrations
b.ToTable("Assets", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Assets.Data.AssetRevision", b =>
modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.AssetRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -329,7 +329,7 @@ namespace Socialize.Api.Migrations
b.ToTable("AssetRevisions", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Clients.Data.Client", b =>
modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -379,7 +379,7 @@ namespace Socialize.Api.Migrations
b.ToTable("Clients", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Comments.Data.Comment", b =>
modelBuilder.Entity("Socialize.Api.Modules.Comments.Data.Comment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -434,7 +434,7 @@ namespace Socialize.Api.Migrations
b.ToTable("Comments", (string)null);
});
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItem", b =>
modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -500,7 +500,7 @@ namespace Socialize.Api.Migrations
b.ToTable("ContentItems", (string)null);
});
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItemRevision", b =>
modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -558,7 +558,150 @@ namespace Socialize.Api.Migrations
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")
.ValueGeneratedOnAdd()
@@ -585,7 +728,7 @@ namespace Socialize.Api.Migrations
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Identity.Data.User", b =>
modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -688,7 +831,7 @@ namespace Socialize.Api.Migrations
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Notifications.Data.NotificationEvent", b =>
modelBuilder.Entity("Socialize.Api.Modules.Notifications.Data.NotificationEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -750,7 +893,7 @@ namespace Socialize.Api.Migrations
b.ToTable("NotificationEvents", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Projects.Data.Project", b =>
modelBuilder.Entity("Socialize.Api.Modules.Projects.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -803,7 +946,7 @@ namespace Socialize.Api.Migrations
b.ToTable("Projects", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.Workspace", b =>
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -814,6 +957,10 @@ namespace Socialize.Api.Migrations
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("LogoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
@@ -842,7 +989,7 @@ namespace Socialize.Api.Migrations
b.ToTable("Workspaces", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.WorkspaceInvite", b =>
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.WorkspaceInvite", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -885,7 +1032,7 @@ namespace Socialize.Api.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
@@ -894,7 +1041,7 @@ namespace Socialize.Api.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
b.HasOne("Socialize.Api.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -903,7 +1050,7 @@ namespace Socialize.Api.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
b.HasOne("Socialize.Api.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -912,13 +1059,13 @@ namespace Socialize.Api.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Socialize.Modules.Identity.Data.User", null)
b.HasOne("Socialize.Api.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -927,12 +1074,28 @@ namespace Socialize.Api.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
b.HasOne("Socialize.Api.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.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
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.Feedback.Data;
public class FeedbackTag
{
public Guid Id { get; set; }
public Guid FeedbackReportId { get; set; }
public string Name { get; set; } = string.Empty;
public string NormalizedName { get; set; } = string.Empty;
public FeedbackReport? FeedbackReport { get; set; }
}

View File

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

View File

@@ -0,0 +1,9 @@
namespace Socialize.Api.Modules.Feedback;
public static class DependencyInjection
{
public static WebApplicationBuilder AddFeedbackModule(this WebApplicationBuilder builder)
{
return builder;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Feedback.Handlers;
public class ListFeedbackTagsHandler(AppDbContext dbContext)
: EndpointWithoutRequest<IReadOnlyCollection<string>>
{
public override void Configure()
{
Get("/api/feedback/tags");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(CancellationToken ct)
{
List<string> tags = await dbContext.FeedbackTags
.GroupBy(tag => new { tag.NormalizedName, tag.Name })
.OrderBy(group => group.Key.Name)
.Select(group => group.Key.Name)
.ToListAsync(ct);
await SendOkAsync(tags, ct);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
using Socialize.Api.Modules.Feedback.Data;
namespace Socialize.Api.Modules.Feedback.Services;
public static class FeedbackRules
{
public static bool TryParseType(string? value, out FeedbackType type)
{
return Enum.TryParse(value?.Trim(), ignoreCase: true, out type)
&& Enum.IsDefined(type);
}
public static bool TryParseStatus(string? value, out FeedbackStatus status)
{
string? normalized = value?.Trim().Replace("'", string.Empty, StringComparison.Ordinal);
if (string.Equals(normalized, "Wont Do", StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalized, "WontDo", StringComparison.OrdinalIgnoreCase))
{
status = FeedbackStatus.WontDo;
return true;
}
return Enum.TryParse(normalized, ignoreCase: true, out status)
&& Enum.IsDefined(status);
}
public static bool IsFinal(FeedbackStatus status)
{
return status is FeedbackStatus.Cancelled;
}
public static bool CanDeveloperSetStatus(FeedbackStatus currentStatus, FeedbackStatus nextStatus)
{
return !IsFinal(currentStatus) &&
nextStatus is FeedbackStatus.New or FeedbackStatus.Planned or FeedbackStatus.Resolved or FeedbackStatus.WontDo;
}
public static bool CanReporterCancel(FeedbackStatus currentStatus)
{
return !IsFinal(currentStatus);
}
public static IReadOnlyCollection<string> NormalizeTags(IEnumerable<string>? tags)
{
if (tags is null)
{
return Array.Empty<string>();
}
return tags
.Select(tag => tag.Trim())
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag.Length > 64 ? tag[..64] : tag)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public static string NormalizeTagKey(string tag)
{
return tag.Trim().ToUpperInvariant();
}
}

View File

@@ -7,4 +7,5 @@ public static class KnownRoles
public const string Client = nameof(Client);
public const string Provider = nameof(Provider);
public const string WorkspaceMember = nameof(WorkspaceMember);
public const string Developer = nameof(Developer);
}

View File

@@ -97,5 +97,11 @@ public static class DependencyInjection
{
await roleManager.CreateAsync(workspaceMemberRole);
}
Role developerRole = new(KnownRoles.Developer);
if (roleManager.Roles.All(r => r.Name != developerRole.Name))
{
await roleManager.CreateAsync(developerRole);
}
}
}

View File

@@ -5,6 +5,7 @@ public class Workspace
public Guid Id { get; init; }
public required string Name { get; set; }
public required string Slug { get; set; }
public string? LogoUrl { get; set; }
public Guid OwnerUserId { get; set; }
public required string TimeZone { get; set; }
public DateTimeOffset CreatedAt { get; init; }

View File

@@ -12,6 +12,7 @@ public static class WorkspaceModelConfiguration
workspace.HasKey(x => x.Id);
workspace.Property(x => x.Name).HasMaxLength(256).IsRequired();
workspace.Property(x => x.Slug).HasMaxLength(128).IsRequired();
workspace.Property(x => x.LogoUrl).HasMaxLength(2048);
workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired();
workspace.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()

View File

@@ -0,0 +1,68 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
public record ChangeWorkspaceLogoRequest(
IFormFile File);
public record ChangeWorkspaceLogoResponse(
string BlobUrl);
public sealed class ChangeWorkspaceLogoRequestValidator : Validator<ChangeWorkspaceLogoRequest>
{
public ChangeWorkspaceLogoRequestValidator()
{
RuleFor(x => x.File)
.NotNull()
.NotEmpty();
}
}
public class ChangeWorkspaceLogoHandler(
AppDbContext dbContext,
IBlobStorage blobStorage,
AccessScopeService accessScopeService)
: Endpoint<ChangeWorkspaceLogoRequest, ChangeWorkspaceLogoResponse>
{
public override void Configure()
{
Post("/api/workspaces/{id}/logo");
Options(o => o.WithTags("Workspaces"));
AllowFileUploads();
}
public override async Task HandleAsync(ChangeWorkspaceLogoRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
{
await SendForbiddenAsync(ct);
return;
}
string blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Workspaces,
$"{workspace.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.LogoPicture}",
request.File.OpenReadStream(),
request.File.ContentType,
ct);
workspace.LogoUrl = blobUrl;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(new ChangeWorkspaceLogoResponse(blobUrl), ct);
}
}

View File

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

View File

@@ -10,10 +10,11 @@ public record WorkspaceDto(
Guid Id,
string Name,
string Slug,
string? LogoUrl,
string TimeZone,
DateTimeOffset CreatedAt);
public class GetWorkspacesHandler(
internal class GetWorkspacesHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest<IReadOnlyCollection<WorkspaceDto>>
@@ -26,20 +27,21 @@ public class GetWorkspacesHandler(
public override async Task HandleAsync(CancellationToken ct)
{
IQueryable<Workspace> query = dbContext.Workspaces.AsQueryable();
var query = dbContext.Workspaces.AsQueryable();
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
var workspaceScopeIds = User.GetWorkspaceScopeIds();
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
}
List<WorkspaceDto> workspaces = await query
var workspaces = await query
.OrderBy(workspace => workspace.Name)
.Select(workspace => new WorkspaceDto(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.CreatedAt))
.ToListAsync(ct);

View File

@@ -0,0 +1,66 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
public record UpdateWorkspaceRequest(
string Name,
string TimeZone);
public class UpdateWorkspaceRequestValidator
: Validator<UpdateWorkspaceRequest>
{
public UpdateWorkspaceRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
}
}
public class UpdateWorkspaceHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<UpdateWorkspaceRequest, WorkspaceDto>
{
public override void Configure()
{
Put("/api/workspaces/{id}");
Options(o => o.WithTags("Workspaces"));
}
public override async Task HandleAsync(UpdateWorkspaceRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
{
await SendForbiddenAsync(ct);
return;
}
workspace.Name = request.Name.Trim();
workspace.TimeZone = request.TimeZone.Trim();
await dbContext.SaveChangesAsync(ct);
WorkspaceDto dto = new(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.CreatedAt);
await SendOkAsync(dto, ct);
}
}

View File

@@ -2,7 +2,10 @@ using Azure.Identity;
using FastEndpoints;
using FastEndpoints.Swagger;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Options;
using Socialize;
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
using Socialize.Api.Infrastructure.BlobStorage.Services;
using Socialize.Api.Infrastructure;
using Socialize.Api.Infrastructure.Development;
using Socialize.Api.Modules.Approvals;
@@ -10,6 +13,7 @@ using Socialize.Api.Modules.Assets;
using Socialize.Api.Modules.Clients;
using Socialize.Api.Modules.Comments;
using Socialize.Api.Modules.ContentItems;
using Socialize.Api.Modules.Feedback;
using Socialize.Api.Modules.Identity;
using Socialize.Api.Modules.Notifications;
using Socialize.Api.Modules.Projects;
@@ -66,6 +70,7 @@ builder.AddAssetsModule();
builder.AddCommentsModule();
builder.AddApprovalsModule();
builder.AddNotificationsModule();
builder.AddFeedbackModule();
var app = builder.Build();
@@ -92,6 +97,38 @@ if (!app.Environment.IsDevelopment())
app.UseHealthChecks("/health");
LocalBlobStorageOptions localBlobStorageOptions = app.Services
.GetRequiredService<IOptions<LocalBlobStorageOptions>>()
.Value;
string localBlobStorageRoot = app.Services
.GetRequiredService<LocalBlobStorage>()
.GetRootPath();
string localBlobStorageRootWithSeparator = Path.EndsInDirectorySeparator(localBlobStorageRoot)
? localBlobStorageRoot
: $"{localBlobStorageRoot}{Path.DirectorySeparatorChar}";
Directory.CreateDirectory(localBlobStorageRoot);
app.MapGet(
$"{LocalBlobStorage.NormalizeRequestPath(localBlobStorageOptions.RequestPath)}/{{**blobPath}}",
async (
string blobPath,
CancellationToken ct) =>
{
string filePath = Path.GetFullPath(Path.Combine(localBlobStorageRoot, blobPath));
if (!filePath.StartsWith(localBlobStorageRootWithSeparator, StringComparison.Ordinal) ||
filePath.EndsWith(".content-type", StringComparison.OrdinalIgnoreCase) ||
!File.Exists(filePath))
{
return Results.NotFound();
}
string contentType = LocalBlobStorage.ReadContentType(filePath) ?? "application/octet-stream";
byte[] bytes = await File.ReadAllBytesAsync(filePath, ct);
return Results.File(bytes, contentType);
});
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();

View File

@@ -15,7 +15,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" Version="12.26.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.0" />
<PackageReference Include="Azure.Identity" Version="1.18.0" />
<PackageReference Include="FastEndpoints" Version="5.35.0" />

View File

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

View File

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

View File

@@ -26,6 +26,8 @@ services:
condition: service_healthy
expose:
- "8080"
volumes:
- api-blob-storage:/app/App_Data/blob-storage
web:
build:
@@ -37,3 +39,6 @@ services:
- "8080:80"
volumes:
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
volumes:
api-blob-storage:

View File

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

View File

@@ -0,0 +1,34 @@
# Feature: User Profile Settings
## Status
Draft
## Goal
Allow authenticated users to manage the profile information shown inside the application shell and workspace activity.
## User Stories
- As an authenticated user, I want to update my name, alias, email, and portrait so that other workspace members see accurate profile information.
## Frontend Areas
- `/app/settings/user-information`
- `frontend/src/features/user-profile/`
## Backend Modules
- Identity
## Domain Rules
- Profile updates apply only to the authenticated user.
- Portrait uploads flow through the existing blob storage abstraction.
- Email changes use the identity module endpoint and should remain auditable through backend identity behavior.
## Done When
- [ ] User information settings show editable name, alias, and email fields.
- [ ] Portrait upload remains available from the settings page.
- [ ] Successful updates refresh the user profile state used by the app shell.

View File

@@ -0,0 +1,40 @@
# Task: Use local blob storage
## Feature
`docs/FEATURES/platform-scaffold.md`
## Goal
Store uploaded portraits and logos on the API server filesystem instead of Azure Blob Storage.
## Context
User, client, and workspace portrait uploads already flow through `IBlobStorage`. The implementation can change without altering endpoint contracts or frontend behavior.
## Files Likely To Change
- `backend/src/Socialize.Api/Infrastructure/DependencyInjection.cs`
- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/*`
- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Configuration/*`
- `backend/src/Socialize.Api/Program.cs`
- `backend/src/Socialize.Api/appsettings.Development.json`
## Constraints
- Do not change API request or response contracts.
- Keep upload validation behavior consistent with the existing blob storage implementation.
- Serve returned blob URLs from the API host so the existing frontend can keep using `portraitUrl` and `logoUrl`.
## Done When
- [x] `IBlobStorage` resolves to local filesystem storage by default.
- [x] Uploaded files are served back from the API host.
- [x] Backend build passes.
## Validation Commands
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
```

View File

@@ -0,0 +1,28 @@
# Task: Improve UI Surface Contrast
## Goal
Increase contrast between the app background, panels, and form controls so inputs are easier to identify against white or near-white surfaces.
## Feature Spec
`docs/FEATURES/platform-scaffold.md`
## Scope
- Update the shared frontend color tokens.
- Configure Vuetify to use the Socialize light theme colors.
- Add shared form control and surface defaults for native and Vuetify controls.
- Avoid feature-specific behavior changes.
## Likely Files
- `frontend/src/assets/main.css`
- `frontend/src/main.js`
## Validation
```bash
cd frontend
npm run build
```

View File

@@ -0,0 +1,69 @@
# Task: Backend feedback foundation
## Goal
Add the backend foundation for product feedback reports.
## Feature Spec
- `docs/FEATURES/product-feedback.md`
## Scope
- Add a new `Developer` identity role and seed it with the existing role setup.
- Add a new FastEndpoints module under `backend/src/Socialize.Api/Modules/Feedback`.
- Add feedback report data entities and EF Core model configuration.
- Add feedback enum/value support for:
- type: `Bug`, `Suggestion`, `Request`
- status: `New`, `Planned`, `Resolved`, `Won't Do`, `Cancelled`
- Add `DbSet` entries and module configuration to `AppDbContext`.
- Capture reporter id, reporter display fields, submitted route, browser metadata, viewport size, app version if available, and optional workspace/client/project/content context.
- Add API endpoints for:
- submit feedback
- list current user's feedback
- get current user's feedback detail
- list all feedback for `Developer`
- get feedback detail for `Developer`
- update feedback type/status/tags for `Developer`
- cancel own feedback with optional reason
- list previously used tags for `Developer`
- Enforce access rules:
- authenticated users can submit feedback
- reporters can view only their own feedback
- developers can view all feedback
- only developers can update type/status/tags
- reporters can only move their own non-final report to `Cancelled`
- Keep assignment, priority, severity, duplicate linking, and deletion out of scope.
## Likely Files
- `backend/src/Socialize.Api/Modules/Identity/Contracts/KnownRoles.cs`
- `backend/src/Socialize.Api/Modules/Identity/DependencyInjection.cs`
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
- `backend/src/Socialize.Api/Modules/Feedback/**`
- `backend/tests/Socialize.Tests/**`
## Notes
- Use FastEndpoints handlers and keep request/response records near their handlers unless local module patterns suggest otherwise.
- Use FluentValidation for non-trivial inputs.
- Treat feedback as global SaaS operator data, not workspace-owned workflow data.
- Tags are free-form but should be normalized enough to support search/filter suggestions later.
## Validation
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
```
## Done When
- [ ] `Developer` role exists and is seeded.
- [ ] Feedback reports can be submitted by authenticated users.
- [ ] Reporters can list and view only their own feedback.
- [ ] Developers can list and view all feedback.
- [ ] Developers can update type, status, and tags.
- [ ] Reporters can cancel their own feedback with an optional reason.
- [ ] Backend validation rejects invalid type/status transitions and missing descriptions.
- [ ] Backend tests cover access rules and core transitions.

View File

@@ -0,0 +1,54 @@
# Task: Protected feedback screenshots
## Goal
Store feedback screenshots through blob storage and expose them only through authenticated, feedback-scoped access.
## Feature Spec
- `docs/FEATURES/product-feedback.md`
## Scope
- Add screenshot metadata to feedback reports or a related feedback screenshot entity.
- Store uploaded annotated screenshots with the existing `IBlobStorage` abstraction.
- Use a dedicated feedback storage container/prefix.
- Validate content type and maximum upload size on the backend.
- Add API support for attaching a screenshot when creating feedback or immediately after creation.
- Add a protected screenshot download/preview endpoint.
- Enforce screenshot access:
- reporter can access screenshots for their own reports
- developers can access all feedback screenshots
- no public/static blob URL access for feedback screenshots
- Return enough screenshot metadata for frontend preview/download flows without exposing unauthenticated blob URLs.
- Document that future feedback deletion must remove associated screenshot blobs.
## Likely Files
- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/*`
- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/*`
- `backend/src/Socialize.Api/Modules/Feedback/**`
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
- `backend/tests/Socialize.Tests/**`
## Notes
- Existing portrait/logo blob behavior may expose static URLs; feedback screenshots must not rely on that public URL pattern.
- Prefer an endpoint that streams the blob after checking feedback access.
- Annotated screenshots are expected to be compressed PNG or JPEG files.
## Validation
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
```
## Done When
- [ ] Feedback screenshots are stored via `IBlobStorage`.
- [ ] Feedback screenshots use a dedicated storage area/prefix.
- [ ] Invalid or oversized screenshots are rejected with clear API errors.
- [ ] Screenshot access requires authentication.
- [ ] Reporter/developer access rules are enforced for downloads/previews.
- [ ] Backend tests cover authorized and unauthorized screenshot access.

View File

@@ -0,0 +1,58 @@
# Task: Feedback comments, activity, and notifications
## Goal
Add the conversation, activity timeline, and in-app notification behavior for feedback.
## Feature Spec
- `docs/FEATURES/product-feedback.md`
## Scope
- Add feedback comments visible to both reporters and developers.
- Add feedback activity entries for status/type/tag changes and cancellation.
- Return a mixed timeline of comments and activity from feedback detail endpoints.
- Add API endpoints for:
- reporter adds comment to own feedback
- developer adds comment to any feedback
- detail timeline retrieval if not included in existing detail endpoints
- Use the existing Notifications module for:
- new feedback report: notify all `Developer` users
- developer comment: notify reporter
- developer status change: notify reporter
- reporter comment: notify developers who have previously commented on that report
- Do not notify for developer type/tag changes.
- Extend notification payloads so feedback notifications can open feedback detail pages.
- Add read/unread support needed for My Feedback unread indicators, or expose enough data for the frontend to derive unread state from notifications.
## Likely Files
- `backend/src/Socialize.Api/Modules/Feedback/**`
- `backend/src/Socialize.Api/Modules/Notifications/**`
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
- `backend/tests/Socialize.Tests/**`
## Notes
- Internal/private comments are out of scope.
- Email notifications are out of scope.
- Avoid adding assignment/owner workflow.
## Validation
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
```
## Done When
- [ ] Reporters and developers can comment according to access rules.
- [ ] Status/type/tag/cancel actions create activity entries.
- [ ] Feedback detail includes a mixed comment/activity timeline.
- [ ] New reports notify all developers.
- [ ] Developer comments and status changes notify the reporter.
- [ ] Reporter comments notify participating developers.
- [ ] Feedback notifications include route-target data for frontend navigation.
- [ ] Backend tests cover comment access and notification side effects.

View File

@@ -0,0 +1,67 @@
# Task: Frontend feedback submission flow
## Goal
Add the global authenticated Feedback button, submission dialog, viewport capture, and annotation flow.
## Feature Spec
- `docs/FEATURES/product-feedback.md`
## Scope
- Add feature-owned frontend code under `frontend/src/features/feedback/`.
- Add a small floating Feedback button to the authenticated app shell on every `/app/*` page.
- Keep the button visible on feedback-related pages too.
- Add a feedback submission dialog with:
- required type: `Bug`, `Suggestion`, `Request`
- required plain-text description
- optional capture flow
- dirty-close warning that discards unsent feedback if confirmed
- Capture only the current app viewport when the user explicitly clicks `Capture screen`.
- Add screenshot annotation support:
- crop
- arrows
- circles or ellipses
- lines
- freehand marks
- text labels
- undo
- clear/reset
- Export annotated screenshots as compressed PNG or JPEG.
- Submit feedback metadata, route context, browser metadata, viewport size, and optional screenshot to the backend.
- If capture fails, show a friendly error and allow text-only submission.
- Use established libraries for capture/annotation rather than custom screenshot infrastructure.
- Add English and French locale strings for the submission flow.
## Likely Files
- `frontend/package.json`
- `frontend/src/layouts/**`
- `frontend/src/features/feedback/**`
- `frontend/src/plugins/api.js`
- `frontend/src/locales/en.json`
- `frontend/src/locales/fr.json`
## Notes
- Runtime configuration must continue to flow through `frontend/src/config.js` if new configuration is needed.
- Keep the flow non-intrusive and app-shell scoped.
- Avoid landing-page or marketing-style UI.
## Validation
```bash
cd frontend
npm run build
```
## Done When
- [ ] Authenticated users see a floating Feedback button on every app page.
- [ ] Users can submit required type and description.
- [ ] Users can optionally capture and annotate the app viewport.
- [ ] Capture failures do not block text-only feedback.
- [ ] Dirty dialog close warns before discarding unsent feedback.
- [ ] UI strings exist in English and French.
- [ ] Frontend build passes.

View File

@@ -0,0 +1,71 @@
# Task: Frontend My Feedback pages
## Goal
Add reporter-facing pages for tracking submitted feedback.
## Feature Spec
- `docs/FEATURES/product-feedback.md`
## Scope
- Add routes:
- `/app/my-feedback`
- `/app/my-feedback/:id`
- Add feature-owned views/stores/components under `frontend/src/features/feedback/`.
- The list page is global across workspaces and shows only the authenticated user's own reports.
- Default the list to active reports:
- `New`
- `Planned`
- Support list filtering by:
- status
- type
- Support sorting by:
- newest
- last activity
- Show unread indicators for reports with unread developer comments or status changes.
- Show visible tags.
- Detail page should show:
- report details
- current URL/path link
- screenshot preview
- tags
- comments
- activity timeline
- cancellation action with optional reason when the report is not final
- Allow reporters to add follow-up comments.
- Extend navigation/sidebar/user menu as appropriate so users can find My Feedback.
- Add English and French locale strings.
## Likely Files
- `frontend/src/router/router.js`
- `frontend/src/layouts/main/**`
- `frontend/src/features/feedback/**`
- `frontend/src/locales/en.json`
- `frontend/src/locales/fr.json`
## Notes
- Reporters cannot edit or delete submitted feedback in v1.
- Reporters cannot change status except cancelling their own non-final report.
- `Cancelled` is final.
## Validation
```bash
cd frontend
npm run build
```
## Done When
- [ ] Authenticated users can open My Feedback.
- [ ] My Feedback defaults to active reports.
- [ ] Users can filter and sort their feedback.
- [ ] Unread indicators are visible where applicable.
- [ ] Users can open details, preview screenshots, read timeline, and comment.
- [ ] Users can cancel their own non-final report with an optional reason.
- [ ] UI strings exist in English and French.
- [ ] Frontend build passes.

View File

@@ -0,0 +1,75 @@
# Task: Frontend developer feedback review
## Goal
Add the developer-facing global feedback review area.
## Feature Spec
- `docs/FEATURES/product-feedback.md`
## Scope
- Add routes restricted to the `Developer` role:
- `/app/feedback`
- `/app/feedback/:id`
- Add feature-owned views/stores/components under `frontend/src/features/feedback/`.
- Add a discoverable navigation entry for users with the `Developer` role.
- The list page is global and shows all reports by default, including final statuses.
- Support list filters:
- type
- status
- tag
- reporter
- workspace context
- date range
- text search
- Support sorting by:
- newest
- oldest
- last activity
- Detail page should show:
- report details and captured metadata
- reporter name/email
- current URL/path link
- screenshot preview
- developer download/open-original screenshot action
- comments
- activity timeline
- status updates
- type updates
- tag management with suggestions from previously used tags
- Allow developers to comment on any feedback report.
- Add English and French locale strings.
## Likely Files
- `frontend/src/router/router.js`
- `frontend/src/layouts/main/**`
- `frontend/src/features/feedback/**`
- `frontend/src/locales/en.json`
- `frontend/src/locales/fr.json`
## Notes
- Do not add assignment, priority, severity, duplicate linking, or private comments.
- Keep the review page operational and dense, not a Jira-style board.
## Validation
```bash
cd frontend
npm run build
```
## Done When
- [ ] Only users with the `Developer` role can access `/app/feedback`.
- [ ] Developers can list all feedback with required filters and sorting.
- [ ] Developers can open details and inspect metadata.
- [ ] Developers can preview and download/open screenshots.
- [ ] Developers can update type, status, and tags.
- [ ] Tag suggestions use previously used tags.
- [ ] Developers can comment.
- [ ] UI strings exist in English and French.
- [ ] Frontend build passes.

View File

@@ -0,0 +1,50 @@
# Task: Feedback notification UI integration
## Goal
Integrate feedback notifications into the existing notification bell and route navigation.
## Feature Spec
- `docs/FEATURES/product-feedback.md`
## Scope
- Extend frontend notification display to support feedback event types.
- Clicking a feedback notification should open:
- `/app/my-feedback/:id` for reporters
- `/app/feedback/:id` for developers when appropriate
- Mark feedback notifications as read using existing notification behavior.
- Ensure feedback notification labels are localized in English and French.
- Ensure My Feedback unread indicators stay consistent with notification read state or the backend unread model.
- Preserve existing content/comment/approval notification behavior.
## Likely Files
- `frontend/src/layouts/main/AppSidebar.vue`
- `frontend/src/features/notifications/**`
- `frontend/src/features/feedback/**`
- `frontend/src/locales/en.json`
- `frontend/src/locales/fr.json`
## Notes
- This task depends on backend feedback notification payloads from `003-feedback-comments-activity-notifications.md`.
- Do not introduce email notification behavior.
## Validation
```bash
cd frontend
npm run build
```
## Done When
- [ ] Feedback notifications appear in the existing notification bell.
- [ ] Feedback notification clicks navigate to the correct detail page.
- [ ] Feedback notifications can be marked read.
- [ ] My Feedback unread indicators reflect unread feedback activity.
- [ ] Existing notification flows still work.
- [ ] UI strings exist in English and French.
- [ ] Frontend build passes.

View File

@@ -0,0 +1,50 @@
# Task: OpenAPI sync and end-to-end feedback polish
## Goal
Finalize contract sync, validation, and end-to-end behavior after the feedback backend and frontend tasks are implemented.
## Feature Spec
- `docs/FEATURES/product-feedback.md`
## Scope
- Run the backend and regenerate OpenAPI after feedback API contracts are complete.
- Update generated frontend API types.
- Resolve frontend build issues caused by contract changes.
- Verify reporter and developer access flows manually.
- Verify protected screenshot preview/download behavior.
- Verify feedback notifications open the expected pages.
- Verify English/French feedback UI coverage.
- Review `docs/FEATURES/product-feedback.md` and update it if implementation intentionally changed behavior.
- Add or update follow-up task files for deferred work discovered during implementation.
## Likely Files
- `shared/openapi/openapi.json`
- `frontend/src/api/schema.d.ts`
- `docs/FEATURES/product-feedback.md`
- `docs/TASKS/product-feedback/**`
## Validation
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
./scripts/update-openapi.sh
cd frontend
npm run build
```
## Done When
- [ ] OpenAPI snapshot is updated.
- [ ] Generated frontend schema is updated.
- [ ] Backend build passes.
- [ ] Backend tests pass.
- [ ] Frontend build passes.
- [ ] Reporter can submit, view, comment, and cancel feedback.
- [ ] Developer can review, filter, comment, update status/type/tags, and access screenshots.
- [ ] Feedback notifications work from the notification bell.
- [ ] Feature spec still matches implemented behavior.

View File

@@ -0,0 +1,23 @@
# Task: Edit user information settings
## Goal
Allow users to edit their profile details from the user information settings page.
## Feature Spec
- `docs/FEATURES/user-profile-settings.md`
## Scope
- Replace read-only user information details with editable first name, last name, alias, and email fields.
- Keep portrait upload available on the page.
- Use the existing Identity endpoints for full name, alias, email, and portrait updates.
- Keep the profile store as the source of truth for app-shell user identity.
## Validation
```bash
cd frontend
npm run build
```

View File

@@ -0,0 +1,24 @@
# Task: Edit workspace settings
## Goal
Allow managers to update the active workspace name and time zone from the workspace settings page.
## Feature Spec
- `docs/FEATURES/workspace-review-workflow.md`
## Scope
- Add a backend workspace update endpoint for `name` and `timeZone`.
- Add a backend workspace logo upload endpoint.
- Add a frontend workspace store update action.
- Replace the workspace settings general summary with editable details and logo controls.
- Do not display workspace slug or workspace creation date on the workspace settings page.
## Validation
```bash
dotnet build backend/Socialize.slnx
cd frontend && npm run build
```

View File

@@ -2313,9 +2313,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001722",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001722.tgz",
"integrity": "sha512-DCQHBBZtiK6JVkAGw7drvAMK0Q0POD/xZvEmDp6baiMMP6QXXk9HpD6mNYBZWhOPG6LvIDb82ITqtWjhDckHCA==",
"version": "1.0.30001791",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
"dev": true,
"funding": [
{
@@ -2330,7 +2330,8 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
]
],
"license": "CC-BY-4.0"
},
"node_modules/chalk": {
"version": "4.1.2",

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +6,18 @@
--socialize-primary: #172033;
--socialize-accent: #ff8a3d;
--socialize-highlight: #2fa58d;
--h-background: #fffaf2;
--h-background: #f4f6f3;
--h-on-background: #172033;
--h-surface: #ffffff;
--h-surface: #fbfaf6;
--h-surface-muted: #f1f5f2;
--h-on-surface: #172033;
--h-control: #eef3ef;
--h-control-hover: #e7eee9;
--h-control-focus: #ffffff;
--h-border: #c7d2cc;
--h-border-strong: #94a39d;
--h-primary: #172033;
--h-on-primary: #fffaf2;
--h-on-primary: #fbfaf6;
--h-secondary: #fff3e2;
--h-on-secondary: #172033;
--h-tertiary: #d9f6ee;
@@ -20,6 +26,93 @@
--h-on-error: #ffffff;
}
html,
body,
#app {
min-height: 100%;
background: var(--h-background);
}
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']),
select,
textarea {
background-color: var(--h-control) !important;
border-color: var(--h-border) !important;
color: var(--h-on-surface);
}
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']):hover,
select:hover,
textarea:hover {
background-color: var(--h-control-hover) !important;
border-color: var(--h-border-strong) !important;
}
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']):focus,
select:focus,
textarea:focus,
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']):focus-visible,
select:focus-visible,
textarea:focus-visible {
background-color: var(--h-control-focus) !important;
border-color: var(--socialize-highlight) !important;
box-shadow: 0 0 0 3px rgba(47, 165, 141, 0.16);
outline: none;
}
input::placeholder,
textarea::placeholder {
color: #68778a;
opacity: 1;
}
.v-application {
background: var(--h-background) !important;
color: var(--h-on-background);
}
.v-card,
.v-sheet,
.v-list,
.v-menu > .v-overlay__content,
.v-dialog > .v-overlay__content {
background-color: var(--h-surface) !important;
border: 1px solid var(--h-border);
}
.v-field {
background-color: var(--h-control) !important;
color: var(--h-on-surface);
}
.v-field:hover {
background-color: var(--h-control-hover) !important;
}
.v-field--focused {
background-color: var(--h-control-focus) !important;
}
.v-field__outline {
color: var(--h-border-strong);
}
.v-field--focused .v-field__outline {
color: var(--socialize-highlight);
}
.v-field__input,
.v-field-label {
color: var(--h-on-surface);
}
.panel,
[class$='-panel'],
[class$='-card'],
div.card {
border-color: var(--h-border) !important;
}
@layer components {
.btn {
@apply min-w-24 w-full;

View File

@@ -1,4 +1,4 @@
import {computed, watch} from 'vue'
import {computed, ref, watch} from 'vue'
import {defineStore} from 'pinia'
import {useAuthStore} from "@/features/auth/stores/authStore.js";
import {useClient} from "@/plugins/api.js";
@@ -9,6 +9,9 @@ export const useUserProfileStore = defineStore(
() => {
const authStore = useAuthStore()
const isUpdating = ref(false)
const isUploadingPortrait = ref(false)
const error = ref(null)
const authWatcher = watch(
() => authStore.isAuthenticated,
@@ -64,12 +67,15 @@ export const useUserProfileStore = defineStore(
const client = useClient()
const userResponse = await client.get("/api/users/profile");
value.value = userResponse.data
} catch (error) {
console.error(error)
} catch (fetchError) {
console.error(fetchError)
}
}
async function changeFullname(firstname, lastname) {
isUpdating.value = true
error.value = null
try {
const client = useClient()
await client.post(
@@ -80,12 +86,19 @@ export const useUserProfileStore = defineStore(
})
value.value.firstname = firstname;
value.value.lastname = lastname;
} catch (error) {
console.error(error)
} catch (updateError) {
console.error(updateError)
error.value = 'Failed to update profile.'
throw updateError
} finally {
isUpdating.value = false
}
}
async function changeAlias(alias) {
isUpdating.value = true
error.value = null
try {
const client = useClient()
await client.post(
@@ -94,8 +107,12 @@ export const useUserProfileStore = defineStore(
alias: alias
})
value.value.alias = alias;
} catch (error) {
console.error(error)
} catch (updateError) {
console.error(updateError)
error.value = 'Failed to update profile.'
throw updateError
} finally {
isUpdating.value = false
}
}
@@ -128,6 +145,9 @@ export const useUserProfileStore = defineStore(
}
async function changeEmail(email) {
isUpdating.value = true
error.value = null
try {
const client = useClient()
await client.post(
@@ -136,8 +156,12 @@ export const useUserProfileStore = defineStore(
email: email
})
value.value.email = email;
} catch (error) {
console.error(error)
} catch (updateError) {
console.error(updateError)
error.value = 'Failed to update profile.'
throw updateError
} finally {
isUpdating.value = false
}
}
@@ -156,6 +180,9 @@ export const useUserProfileStore = defineStore(
}
async function changePortrait(selectedFile) {
isUploadingPortrait.value = true
error.value = null
try {
const client = useClient()
const formData = new FormData();
@@ -166,8 +193,12 @@ export const useUserProfileStore = defineStore(
formData)
value.value.portraitUrl = `${response.data.blobUrl}?${Date.now()}` // the Date.now() is for cache-busting
} catch (error) {
console.error(error)
} catch (uploadError) {
console.error(uploadError)
error.value = 'Failed to update portrait.'
throw uploadError
} finally {
isUploadingPortrait.value = false
}
}
@@ -176,6 +207,9 @@ export const useUserProfileStore = defineStore(
alias,
fullname,
portraitUrl,
isUpdating,
isUploadingPortrait,
error,
roles,
persona,
authorizedWorkspaceIds,

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import AppAvatar from '@/components/AppAvatar.vue';
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
@@ -9,21 +9,86 @@
const { t } = useI18n();
const isPortraitDialogOpen = ref(false);
const isSavingPortrait = ref(false);
const settingsError = ref(null);
const settingsStatus = ref(null);
const form = reactive({
firstname: '',
lastname: '',
alias: '',
email: '',
});
const email = computed(() => userProfileStore.user?.email || t('userSettings.noEmail'));
const alias = computed(() => userProfileStore.alias);
const fullname = computed(() => userProfileStore.fullname);
const canSave = computed(() => Boolean(form.email.trim()) && !userProfileStore.isUpdating);
function syncFormFromUser(user) {
form.firstname = user?.firstname ?? '';
form.lastname = user?.lastname ?? '';
form.alias = user?.alias ?? '';
form.email = user?.email ?? '';
}
async function submitSettings() {
if (!form.email.trim()) {
settingsError.value = t('userSettings.errors.emailRequired');
settingsStatus.value = null;
return;
}
const user = userProfileStore.user ?? {};
const nextFirstname = form.firstname.trim();
const nextLastname = form.lastname.trim();
const nextAlias = form.alias.trim();
const nextEmail = form.email.trim();
settingsError.value = null;
settingsStatus.value = null;
try {
if (nextFirstname !== (user.firstname ?? '') || nextLastname !== (user.lastname ?? '')) {
await userProfileStore.changeFullname(nextFirstname, nextLastname);
}
if (nextAlias !== (user.alias ?? '')) {
await userProfileStore.changeAlias(nextAlias || null);
}
if (nextEmail !== (user.email ?? '')) {
await userProfileStore.changeEmail(nextEmail);
}
settingsStatus.value = t('userSettings.saved');
syncFormFromUser(userProfileStore.user);
} catch (error) {
console.error('Failed to update user settings:', error);
settingsError.value = t('userSettings.errors.saveFailed');
}
}
async function savePortrait(result) {
isSavingPortrait.value = true;
settingsError.value = null;
settingsStatus.value = null;
try {
await userProfileStore.changePortrait(result.file);
isPortraitDialogOpen.value = false;
settingsStatus.value = t('userSettings.portraitSaved');
} catch (error) {
console.error('Failed to update user portrait:', error);
settingsError.value = t('userSettings.errors.portraitFailed');
} finally {
isSavingPortrait.value = false;
}
}
watch(
() => userProfileStore.user,
syncFormFromUser,
{ immediate: true, deep: true }
);
</script>
<template>
@@ -50,6 +115,7 @@
<button
class="primary-button"
type="button"
@click="isPortraitDialogOpen = true"
>
{{ t('userSettings.updatePortrait') }}
@@ -62,20 +128,77 @@
<span>{{ t('userSettings.accountDetailsDescription') }}</span>
</div>
<div class="details-grid">
<div class="detail-row">
<span>{{ t('userSettings.alias') }}</span>
<strong>{{ alias }}</strong>
</div>
<div class="detail-row">
<span>{{ t('userSettings.fullName') }}</span>
<strong>{{ fullname }}</strong>
</div>
<div class="detail-row">
<span>{{ t('userSettings.email') }}</span>
<strong>{{ email }}</strong>
</div>
<div
v-if="settingsError"
class="page-message error"
>
{{ settingsError }}
</div>
<div
v-if="settingsStatus"
class="page-message success"
>
{{ settingsStatus }}
</div>
<form
class="form-stack"
@submit.prevent="submitSettings"
>
<div class="details-grid">
<label class="field">
<span>{{ t('userSettings.firstname') }}</span>
<input
v-model="form.firstname"
type="text"
autocomplete="given-name"
:disabled="userProfileStore.isUpdating"
/>
</label>
<label class="field">
<span>{{ t('userSettings.lastname') }}</span>
<input
v-model="form.lastname"
type="text"
autocomplete="family-name"
:disabled="userProfileStore.isUpdating"
/>
</label>
<label class="field">
<span>{{ t('userSettings.alias') }}</span>
<input
v-model="form.alias"
type="text"
autocomplete="nickname"
:placeholder="fullname"
:disabled="userProfileStore.isUpdating"
/>
</label>
<label class="field">
<span>{{ t('userSettings.email') }}</span>
<input
v-model="form.email"
type="email"
autocomplete="email"
:disabled="userProfileStore.isUpdating"
/>
</label>
</div>
<div class="form-actions">
<button
class="primary-button"
type="submit"
:disabled="!canSave"
>
{{ userProfileStore.isUpdating ? t('common.saving') : t('userSettings.saveDetails') }}
</button>
</div>
</form>
</div>
<ImageCropperDialog
@@ -84,6 +207,7 @@
:confirm-label="t('userSettings.savePortrait')"
:upload-label="t('userSettings.choosePortrait')"
:is-saving="isSavingPortrait"
:initial-url="userProfileStore.portraitUrl"
@save="savePortrait"
/>
</section>
@@ -107,8 +231,7 @@
.page-header p,
.panel-heading span,
.hero-identity span,
.hero-identity small,
.detail-row span {
.hero-identity small {
@apply text-sm leading-6;
color: #526178;
}
@@ -128,8 +251,7 @@
}
.hero-identity strong,
.panel-heading strong,
.detail-row strong {
.panel-heading strong {
color: #172033;
}
@@ -149,10 +271,45 @@
@apply grid gap-4 md:grid-cols-2;
}
.detail-row {
@apply flex flex-col gap-1 rounded-[1.25rem] border p-4;
.form-stack {
@apply flex flex-col gap-4;
}
.field {
@apply flex flex-col gap-2;
}
.field span {
@apply text-sm font-semibold;
color: #172033;
}
.field input {
@apply rounded-[1rem] border px-4 py-3 text-sm;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
color: #172033;
}
.field input:disabled {
opacity: 0.7;
}
.form-actions {
@apply flex justify-end;
}
.page-message {
@apply rounded-[1rem] border px-4 py-3 text-sm font-semibold;
background: rgba(15, 118, 110, 0.08);
border-color: rgba(15, 118, 110, 0.18);
color: #0f766e;
}
.page-message.error {
background: rgba(185, 28, 28, 0.08);
border-color: rgba(185, 28, 28, 0.16);
color: #b91c1c;
}
.primary-button {
@@ -160,4 +317,9 @@
background: #172033;
color: #fffaf2;
}
.primary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
</style>

View File

@@ -0,0 +1,49 @@
<script setup>
import { computed } from 'vue';
import { getTimeZoneOptions } from '@/features/workspaces/timeZones.js';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
const timeZoneOptions = computed(() => getTimeZoneOptions(props.modelValue));
function updateValue(event) {
emit('update:modelValue', event.target.value);
}
</script>
<template>
<select
class="time-zone-select"
:value="modelValue"
:disabled="disabled"
@change="updateValue"
>
<option
v-for="timeZone in timeZoneOptions"
:key="timeZone.value"
:value="timeZone.value"
>
{{ timeZone.label }}
</option>
</select>
</template>
<style scoped>
.time-zone-select {
@apply rounded-[1rem] border px-4 py-3 text-sm;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
outline: none;
}
</style>

View File

@@ -11,6 +11,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
const activeWorkspaceId = ref(null);
const isLoading = ref(false);
const isCreating = ref(false);
const isUpdating = ref(false);
const isUploadingLogo = ref(false);
const invitesByWorkspace = ref({});
const membersByWorkspace = ref({});
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) {
if (workspaces.value.some(workspace => workspace.id === workspaceId)) {
activeWorkspaceId.value = workspaceId;
@@ -192,6 +262,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
activeWorkspace,
isLoading,
isCreating,
isUpdating,
isUploadingLogo,
invitesByWorkspace,
membersByWorkspace,
isInvitesLoading,
@@ -200,6 +272,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
error,
fetchWorkspaces,
createWorkspace,
updateWorkspace,
uploadWorkspaceLogo,
fetchInvites,
fetchMembers,
inviteMember,

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

View File

@@ -2,6 +2,7 @@
import { computed, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
const router = useRouter();
@@ -126,9 +127,8 @@
<label class="field">
<span>{{ t('workspaceCreate.fields.timeZone') }}</span>
<input
<TimeZoneSelect
v-model="form.timeZone"
type="text"
:disabled="workspaceStore.isCreating"
/>
</label>

View File

@@ -1,6 +1,9 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
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 {
mdiAccountGroupOutline,
@@ -14,6 +17,15 @@
const { t } = useI18n();
const workspaceStore = useWorkspaceStore();
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({
email: '',
@@ -26,6 +38,15 @@
const workspaceMembers = computed(() =>
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(() => [
{ key: 'general', label: t('workspaceSettings.tabs.general'), icon: mdiCogOutline },
{ 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(
() => workspaceStore.activeWorkspaceId,
async workspaceId => {
@@ -67,6 +99,56 @@
{ 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() {
if (!inviteForm.email.trim() || !inviteForm.role) {
return;
@@ -133,31 +215,93 @@
>
<article class="settings-card">
<div class="section-copy">
<span class="section-kicker">{{ t('workspaceSettings.general.summaryTitle') }}</span>
<p>{{ t('workspaceSettings.general.summaryDescription') }}</p>
<span class="section-kicker">{{ t('workspaceSettings.general.detailsTitle') }}</span>
<p>{{ t('workspaceSettings.general.detailsDescription') }}</p>
</div>
<dl
v-if="workspaceStore.activeWorkspace"
class="summary-grid"
<div
v-if="settingsError"
class="page-message error"
>
<div>
<dt>{{ t('workspaceSettings.summary.name') }}</dt>
<dd>{{ workspaceStore.activeWorkspace.name }}</dd>
{{ settingsError }}
</div>
<div
v-if="settingsStatus"
class="page-message success"
>
{{ settingsStatus }}
</div>
<form
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>
<button
class="secondary-button"
type="button"
:disabled="workspaceStore.isUploadingLogo"
@click="isLogoDialogOpen = true"
>
{{ workspaceStore.isUploadingLogo ? t('common.saving') : t('workspaceSettings.logo.changeAction') }}
</button>
</div>
<div>
<dt>{{ t('workspaceSettings.summary.slug') }}</dt>
<dd>{{ workspaceStore.activeWorkspace.slug }}</dd>
</div>
<div>
<dt>{{ t('workspaceSettings.summary.timeZone') }}</dt>
<dd>{{ workspaceStore.activeWorkspace.timeZone }}</dd>
</div>
<div>
<dt>{{ t('workspaceSettings.summary.created') }}</dt>
<dd>{{ formatDate(workspaceStore.activeWorkspace.createdAt) }}</dd>
</div>
</dl>
<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>
</article>
</div>
@@ -366,6 +510,16 @@
</router-link>
</article>
</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>
</template>
@@ -426,7 +580,6 @@
}
.section-copy h1,
.summary-grid dd,
.invite-row strong,
.connector-copy strong,
.connector-status,
@@ -440,7 +593,6 @@
}
.section-copy p,
.summary-grid dt,
.invite-row span,
.invite-row small,
.empty-state,
@@ -452,22 +604,32 @@
color: #526178;
}
.summary-grid {
@apply grid gap-4 sm:grid-cols-2;
}
.summary-grid div {
@apply rounded-[1rem] border p-4;
background: #f8fafc;
.logo-picker-card {
@apply flex flex-col gap-4 rounded-[1rem] border p-4 sm:flex-row sm:items-center;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
}
.summary-grid dt {
@apply text-xs font-bold uppercase tracking-[0.16em];
.logo-picker-copy {
@apply flex min-w-0 flex-1 flex-col gap-1;
}
.summary-grid dd {
@apply mt-2 text-base font-semibold;
.logo-picker-copy strong {
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 {
@@ -498,6 +660,18 @@
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,
.connector-list,
.workflow-rule-list,

View File

@@ -1,11 +1,10 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import WorkspaceSelector from './WorkspaceSelector.vue';
import {
mdiChevronDown,
mdiCogOutline,
mdiLogin,
mdiPlus,
@@ -23,20 +22,9 @@
});
const route = useRoute();
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')
);
const appBarActions = computed(() => {
if (!authStore.isAuthenticated) {
return [];
@@ -81,38 +69,6 @@
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>
<template>
@@ -134,52 +90,9 @@
<div class="side-menu">
<div class="side-menu-items side-menu-left">
<div
<WorkspaceSelector
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 class="side-menu-items side-menu-right">
@@ -283,73 +196,6 @@
@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 {
@apply no-underline;
}

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

View File

@@ -35,7 +35,8 @@
"website": "Website",
"common": {
"cancel": "Cancel",
"creating": "Creating..."
"creating": "Creating...",
"saving": "Saving..."
},
"workspaceSelector": {
"createAction": "Add workspace"
@@ -309,14 +310,24 @@
"description": "Manage the portrait and account details shown inside the workspace.",
"updatePortrait": "Update portrait",
"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",
"firstname": "First name",
"lastname": "Last name",
"fullName": "Full name",
"email": "Email",
"noEmail": "No email set",
"cropperTitle": "Update user 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": {
"eyebrow": "Settings",
@@ -334,12 +345,13 @@
"errors": {
"required": "All workspace fields are required.",
"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.",
"inviteFailed": "The workspace invite could not be created."
},
"fields": {
"name": "Workspace name",
"slug": "Workspace slug",
"timeZone": "Time zone",
"memberEmail": "Member email",
"memberRole": "Role"
@@ -353,9 +365,7 @@
},
"summary": {
"name": "Name",
"slug": "Slug",
"timeZone": "Time zone",
"created": "Created"
"timeZone": "Time zone"
},
"tabs": {
"general": "General",
@@ -382,8 +392,19 @@
}
},
"general": {
"summaryTitle": "Workspace summary",
"summaryDescription": "Reference details for the workspace currently in context."
"detailsTitle": "Workspace details",
"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": {
"flowTitle": "Approval flow",

View File

@@ -35,7 +35,8 @@
"website": "Site web",
"common": {
"cancel": "Annuler",
"creating": "Création..."
"creating": "Création...",
"saving": "Enregistrement..."
},
"workspaceSelector": {
"createAction": "Ajouter un espace"
@@ -309,14 +310,24 @@
"description": "Gérez le portrait et les informations du compte affichés dans l'espace.",
"updatePortrait": "Mettre à jour le portrait",
"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",
"firstname": "Prénom",
"lastname": "Nom",
"fullName": "Nom complet",
"email": "Email",
"noEmail": "Aucun email défini",
"cropperTitle": "Mettre à jour le portrait utilisateur",
"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": {
"eyebrow": "Paramètres",
@@ -334,12 +345,13 @@
"errors": {
"required": "Tous les champs de l'espace sont requis.",
"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.",
"inviteFailed": "L'invitation de l'espace n'a pas pu être créée."
},
"fields": {
"name": "Nom de l'espace",
"slug": "Slug de l'espace",
"timeZone": "Fuseau horaire",
"memberEmail": "Email du membre",
"memberRole": "Rôle"
@@ -353,9 +365,7 @@
},
"summary": {
"name": "Nom",
"slug": "Slug",
"timeZone": "Fuseau horaire",
"created": "Créé"
"timeZone": "Fuseau horaire"
},
"tabs": {
"general": "Général",
@@ -382,8 +392,19 @@
}
},
"general": {
"summaryTitle": "Résumé de l'espace",
"summaryDescription": "Détails de référence pour l'espace actuellement en contexte."
"detailsTitle": "Détails de l'espace",
"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": {
"flowTitle": "Flux d'approbation",

View File

@@ -54,6 +54,25 @@ const vuetify = createVuetify({
aliases,
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();

4451
shared/openapi/openapi.json Normal file

File diff suppressed because it is too large Load Diff