Compare commits

23 Commits

Author SHA1 Message Date
df0409d7f6 wip 2026-05-01 14:23:37 -04:00
5077f557f4 docs: redefine approval workflow 2026-05-01 00:58:47 -04:00
1722d65d22 chore(doc): remove unused edit-workspace-settings task
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-04-30 16:08:55 -04:00
14023e65d5 docs: remove platform-scaffold feature and tasks.
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-04-30 15:56:07 -04:00
237b1a4242 docs: adds workspace-invites feature and tasks
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-04-30 15:46:06 -04:00
ace0279bd0 fix(workspace-invite): inconsistence in roles names 2026-04-30 15:45:32 -04:00
07458c1541 chore: remove unused bootstop-vdp-agentic.sh script
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-04-30 14:25:17 -04:00
a9bfdc460d chore: update docs to include feedback module 2026-04-30 14:22:09 -04:00
258554f9d4 chore: add a dev user 2026-04-30 14:09:52 -04:00
6731fb5d3a feat: add feedback review notification UI 2026-04-30 13:53:00 -04:00
5aaddbca40 feat: add feedback submission flow 2026-04-30 13:33:10 -04:00
1263e28c00 feat: add feedback comments activity notifications 2026-04-30 13:24:23 -04:00
4873f39192 feat: protect feedback screenshots 2026-04-30 13:15:19 -04:00
cb6948aa14 feat: add feedback backend foundation 2026-04-30 03:31:42 -04:00
f9960b4fc9 docs: add product feedback feature plan 2026-04-30 03:30:48 -04:00
2e4c16621d feat: allow editing user profile settings 2026-04-30 02:24:10 -04:00
60ce08ee86 fix: improve frontend surface contrast 2026-04-30 02:15:43 -04:00
0f3652c1a1 chore: fix some warnings 2026-04-30 02:04:27 -04:00
63738ad027 feat: update workspace settings 2026-04-30 02:03:42 -04:00
6177eec2bf fix: show workspace logo in selector 2026-04-30 02:02:31 -04:00
b51b8b4185 feat: use local blob storage 2026-04-30 01:57:37 -04:00
d222e33667 refactor: extract workspace selector 2026-04-30 01:44:03 -04:00
fcd80cd30f chore: update the browserlist db 2026-04-30 01:27:26 -04:00
160 changed files with 28448 additions and 2700 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

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;
@@ -25,21 +26,29 @@ public class AppDbContext(
public DbSet<Asset> Assets => Set<Asset>();
public DbSet<AssetRevision> AssetRevisions => Set<AssetRevision>();
public DbSet<Comment> Comments => Set<Comment>();
public DbSet<ApprovalWorkflowInstance> ApprovalWorkflowInstances => Set<ApprovalWorkflowInstance>();
public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>();
public DbSet<WorkspaceApprovalStepConfiguration> WorkspaceApprovalStepConfigurations => Set<WorkspaceApprovalStepConfiguration>();
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
public DbSet<FeedbackReport> FeedbackReports => Set<FeedbackReport>();
public DbSet<FeedbackTag> FeedbackTags => Set<FeedbackTag>();
public DbSet<FeedbackScreenshot> FeedbackScreenshots => Set<FeedbackScreenshot>();
public DbSet<FeedbackComment> FeedbackComments => Set<FeedbackComment>();
public DbSet<FeedbackActivityEntry> FeedbackActivityEntries => Set<FeedbackActivityEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(modelBuilder);
base.OnModelCreating(builder);
modelBuilder.ConfigureWorkspacesModule();
modelBuilder.ConfigureClientsModule();
modelBuilder.ConfigureProjectsModule();
modelBuilder.ConfigureContentItemsModule();
modelBuilder.ConfigureAssetsModule();
modelBuilder.ConfigureCommentsModule();
modelBuilder.ConfigureApprovalsModule();
modelBuilder.ConfigureNotificationsModule();
builder.ConfigureWorkspacesModule();
builder.ConfigureClientsModule();
builder.ConfigureProjectsModule();
builder.ConfigureContentItemsModule();
builder.ConfigureAssetsModule();
builder.ConfigureCommentsModule();
builder.ConfigureApprovalsModule();
builder.ConfigureNotificationsModule();
builder.ConfigureFeedbackModule();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddWorkspaceApprovalConfiguration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ApprovalMode",
table: "Workspaces",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "Required");
migrationBuilder.AddColumn<bool>(
name: "LockContentAfterApproval",
table: "Workspaces",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "SchedulePostsAutomaticallyOnApproval",
table: "Workspaces",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "SendAutomaticApprovalReminders",
table: "Workspaces",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ApprovalMode",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "LockContentAfterApproval",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "SchedulePostsAutomaticallyOnApproval",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "SendAutomaticApprovalReminders",
table: "Workspaces");
}
}
}

View File

@@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddWorkspaceApprovalStepConfiguration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "WorkspaceApprovalStepConfigurations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
TargetType = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
TargetValue = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
RequiredApproverCount = table.Column<int>(type: "integer", nullable: false, defaultValue: 1),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_WorkspaceApprovalStepConfigurations", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_WorkspaceApprovalStepConfigurations_WorkspaceId",
table: "WorkspaceApprovalStepConfigurations",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_WorkspaceApprovalStepConfigurations_WorkspaceId_SortOrder",
table: "WorkspaceApprovalStepConfigurations",
columns: new[] { "WorkspaceId", "SortOrder" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WorkspaceApprovalStepConfigurations");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddApprovalWorkflowRuntime : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "WorkflowInstanceId",
table: "ApprovalRequests",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WorkflowStepRequiredApproverCount",
table: "ApprovalRequests",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WorkflowStepSortOrder",
table: "ApprovalRequests",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "WorkflowStepTargetType",
table: "ApprovalRequests",
type: "character varying(32)",
maxLength: 32,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "WorkflowStepTargetValue",
table: "ApprovalRequests",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.CreateTable(
name: "ApprovalWorkflowInstances",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
State = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ApprovalMode = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
StartedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ApprovalWorkflowInstances", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ApprovalRequests_WorkflowInstanceId",
table: "ApprovalRequests",
column: "WorkflowInstanceId");
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowInstances_ContentItemId",
table: "ApprovalWorkflowInstances",
column: "ContentItemId");
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowInstances_ContentItemId_State",
table: "ApprovalWorkflowInstances",
columns: new[] { "ContentItemId", "State" },
unique: true,
filter: "\"State\" = 'Pending'");
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowInstances_WorkspaceId",
table: "ApprovalWorkflowInstances",
column: "WorkspaceId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ApprovalWorkflowInstances");
migrationBuilder.DropIndex(
name: "IX_ApprovalRequests_WorkflowInstanceId",
table: "ApprovalRequests");
migrationBuilder.DropColumn(
name: "WorkflowInstanceId",
table: "ApprovalRequests");
migrationBuilder.DropColumn(
name: "WorkflowStepRequiredApproverCount",
table: "ApprovalRequests");
migrationBuilder.DropColumn(
name: "WorkflowStepSortOrder",
table: "ApprovalRequests");
migrationBuilder.DropColumn(
name: "WorkflowStepTargetType",
table: "ApprovalRequests");
migrationBuilder.DropColumn(
name: "WorkflowStepTargetValue",
table: "ApprovalRequests");
}
}
}

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()
@@ -216,6 +216,23 @@ namespace Socialize.Api.Migrations
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid?>("WorkflowInstanceId")
.HasColumnType("uuid");
b.Property<int?>("WorkflowStepRequiredApproverCount")
.HasColumnType("integer");
b.Property<int?>("WorkflowStepSortOrder")
.HasColumnType("integer");
b.Property<string>("WorkflowStepTargetType")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("WorkflowStepTargetValue")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
@@ -225,12 +242,104 @@ namespace Socialize.Api.Migrations
b.HasIndex("ReviewerEmail");
b.HasIndex("WorkflowInstanceId");
b.HasIndex("WorkspaceId");
b.ToTable("ApprovalRequests", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Assets.Data.Asset", b =>
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ApprovalMode")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("StartedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("WorkspaceId");
b.HasIndex("ContentItemId", "State")
.IsUnique()
.HasFilter("\"State\" = 'Pending'");
b.ToTable("ApprovalWorkflowInstances", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.WorkspaceApprovalStepConfiguration", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("RequiredApproverCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("TargetValue")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "SortOrder")
.IsUnique();
b.ToTable("WorkspaceApprovalStepConfigurations", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -286,7 +395,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 +438,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 +488,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 +543,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 +609,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 +667,298 @@ namespace Socialize.Api.Migrations
b.ToTable("ContentItemRevisions", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Identity.Data.Role", b =>
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ActivityType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ActorDisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ActorEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("ActorUserId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("FeedbackReportId")
.HasColumnType("uuid");
b.Property<string>("FromValue")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Note")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("ToValue")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("ActorUserId");
b.HasIndex("CreatedAt");
b.HasIndex("FeedbackReportId");
b.ToTable("FeedbackActivityEntries", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackComment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AuthorDisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("AuthorEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("AuthorRole")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("AuthorUserId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(8000)
.HasColumnType("character varying(8000)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("FeedbackReportId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AuthorUserId");
b.HasIndex("CreatedAt");
b.HasIndex("FeedbackReportId");
b.ToTable("FeedbackComments", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AppVersion")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("BrowserUserAgent")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("CancellationReason")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("CancelledAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("CancelledByUserId")
.HasColumnType("uuid");
b.Property<Guid?>("ClientId")
.HasColumnType("uuid");
b.Property<string>("ClientName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("ContentItemId")
.HasColumnType("uuid");
b.Property<string>("ContentItemTitle")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(8000)
.HasColumnType("character varying(8000)");
b.Property<DateTimeOffset>("LastActivityAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("ProjectName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ReporterDisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ReporterEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("ReporterUserId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("SubmittedPath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int?>("ViewportHeight")
.HasColumnType("integer");
b.Property<int?>("ViewportWidth")
.HasColumnType("integer");
b.Property<Guid?>("WorkspaceId")
.HasColumnType("uuid");
b.Property<string>("WorkspaceName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("LastActivityAt");
b.HasIndex("ReporterUserId");
b.HasIndex("Status");
b.HasIndex("Type");
b.HasIndex("WorkspaceId");
b.ToTable("FeedbackReports", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BlobContainerName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("FeedbackReportId")
.HasColumnType("uuid");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("FeedbackReportId")
.IsUnique();
b.ToTable("FeedbackScreenshots", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("FeedbackReportId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("NormalizedName");
b.HasIndex("FeedbackReportId", "NormalizedName")
.IsUnique();
b.ToTable("FeedbackTags", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.Role", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -585,7 +985,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 +1088,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 +1150,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,17 +1203,33 @@ 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()
.HasColumnType("uuid");
b.Property<string>("ApprovalMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasDefaultValue("Required");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<bool>("LockContentAfterApproval")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("LogoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
@@ -822,6 +1238,16 @@ namespace Socialize.Api.Migrations
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<bool>("SchedulePostsAutomaticallyOnApproval")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("SendAutomaticApprovalReminders")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
@@ -842,7 +1268,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 +1311,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 +1320,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 +1329,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 +1338,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 +1353,67 @@ namespace Socialize.Api.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
b.HasOne("Socialize.Api.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b =>
{
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
.WithMany("ActivityEntries")
.HasForeignKey("FeedbackReportId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("FeedbackReport");
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackComment", b =>
{
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
.WithMany("Comments")
.HasForeignKey("FeedbackReportId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("FeedbackReport");
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b =>
{
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
.WithOne("Screenshot")
.HasForeignKey("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", "FeedbackReportId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("FeedbackReport");
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b =>
{
b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport")
.WithMany("Tags")
.HasForeignKey("FeedbackReportId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("FeedbackReport");
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
{
b.Navigation("ActivityEntries");
b.Navigation("Comments");
b.Navigation("Screenshot");
b.Navigation("Tags");
});
#pragma warning restore 612, 618
}
}

View File

@@ -6,10 +6,28 @@ public static class ApprovalModelConfiguration
{
public static ModelBuilder ConfigureApprovalsModule(this ModelBuilder modelBuilder)
{
modelBuilder.Entity<ApprovalWorkflowInstance>(workflowInstance =>
{
workflowInstance.ToTable("ApprovalWorkflowInstances");
workflowInstance.HasKey(x => x.Id);
workflowInstance.Property(x => x.State).HasMaxLength(64).IsRequired();
workflowInstance.Property(x => x.ApprovalMode).HasMaxLength(64).IsRequired();
workflowInstance.Property(x => x.StartedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
workflowInstance.HasIndex(x => x.WorkspaceId);
workflowInstance.HasIndex(x => x.ContentItemId);
workflowInstance.HasIndex(x => new { x.ContentItemId, x.State })
.IsUnique()
.HasFilter("\"State\" = 'Pending'");
});
modelBuilder.Entity<ApprovalRequest>(approvalRequest =>
{
approvalRequest.ToTable("ApprovalRequests");
approvalRequest.HasKey(x => x.Id);
approvalRequest.Property(x => x.WorkflowStepTargetType).HasMaxLength(32);
approvalRequest.Property(x => x.WorkflowStepTargetValue).HasMaxLength(128);
approvalRequest.Property(x => x.Stage).HasMaxLength(64).IsRequired();
approvalRequest.Property(x => x.ReviewerName).HasMaxLength(256).IsRequired();
approvalRequest.Property(x => x.ReviewerEmail).HasMaxLength(256).IsRequired();
@@ -20,6 +38,7 @@ public static class ApprovalModelConfiguration
.HasDefaultValueSql("CURRENT_TIMESTAMP");
approvalRequest.HasIndex(x => x.WorkspaceId);
approvalRequest.HasIndex(x => x.ContentItemId);
approvalRequest.HasIndex(x => x.WorkflowInstanceId);
approvalRequest.HasIndex(x => x.ReviewerEmail);
});
@@ -37,6 +56,21 @@ public static class ApprovalModelConfiguration
approvalDecision.HasIndex(x => x.ApprovalRequestId);
});
modelBuilder.Entity<WorkspaceApprovalStepConfiguration>(approvalStep =>
{
approvalStep.ToTable("WorkspaceApprovalStepConfigurations");
approvalStep.HasKey(x => x.Id);
approvalStep.Property(x => x.Name).HasMaxLength(128).IsRequired();
approvalStep.Property(x => x.TargetType).HasMaxLength(32).IsRequired();
approvalStep.Property(x => x.TargetValue).HasMaxLength(128).IsRequired();
approvalStep.Property(x => x.RequiredApproverCount).HasDefaultValue(1);
approvalStep.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
approvalStep.HasIndex(x => x.WorkspaceId);
approvalStep.HasIndex(x => new { x.WorkspaceId, x.SortOrder }).IsUnique();
});
return modelBuilder;
}
}

View File

@@ -5,6 +5,11 @@ public class ApprovalRequest
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ContentItemId { get; set; }
public Guid? WorkflowInstanceId { get; set; }
public int? WorkflowStepSortOrder { get; set; }
public string? WorkflowStepTargetType { get; set; }
public string? WorkflowStepTargetValue { get; set; }
public int? WorkflowStepRequiredApproverCount { get; set; }
public required string Stage { get; set; }
public required string ReviewerName { get; set; }
public required string ReviewerEmail { get; set; }

View File

@@ -0,0 +1,12 @@
namespace Socialize.Api.Modules.Approvals.Data;
public class ApprovalWorkflowInstance
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ContentItemId { get; set; }
public required string State { get; set; }
public required string ApprovalMode { get; set; }
public DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace Socialize.Api.Modules.Approvals.Data;
public class WorkspaceApprovalStepConfiguration
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public required string Name { get; set; }
public int SortOrder { get; set; }
public required string TargetType { get; set; }
public required string TargetValue { get; set; }
public int RequiredApproverCount { get; set; } = 1;
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -1,4 +1,4 @@
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
namespace Socialize.Api.Modules.Approvals;
@@ -7,6 +7,8 @@ public static class DependencyInjection
public static WebApplicationBuilder AddApprovalsModule(
this WebApplicationBuilder builder)
{
builder.Services.AddScoped<ApprovalWorkflowRuntimeService>();
return builder;
}
}

View File

@@ -4,7 +4,9 @@ using System.Security.Cryptography;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Approvals.Handlers;
@@ -62,6 +64,22 @@ public class CreateApprovalRequestHandler(
return;
}
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!ApprovalWorkflowRules.CanCreateSingleStepApprovalRequest(workspace.ApprovalMode))
{
AddError(request => request.WorkspaceId, workspace.ApprovalMode == ApprovalModes.None
? "Approval workflow is disabled for this workspace."
: "Move content to In approval to start the configured multi-level approval workflow.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
var approval = new ApprovalRequest()
{
Id = Guid.NewGuid(),
@@ -79,14 +97,7 @@ public class CreateApprovalRequestHandler(
dbContext.ApprovalRequests.Add(approval);
if (approval.Stage == "Internal")
{
contentItem.Status = "In internal review";
}
else if (approval.Stage == "Client")
{
contentItem.Status = "In client review";
}
contentItem.Status = "In approval";
await dbContext.SaveChangesAsync(ct);
@@ -107,6 +118,11 @@ public class CreateApprovalRequestHandler(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.WorkflowInstanceId,
approval.WorkflowStepSortOrder,
approval.WorkflowStepTargetType,
approval.WorkflowStepTargetValue,
approval.WorkflowStepRequiredApproverCount,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,

View File

@@ -24,6 +24,11 @@ public record ApprovalRequestDto(
Guid Id,
Guid WorkspaceId,
Guid ContentItemId,
Guid? WorkflowInstanceId,
int? WorkflowStepSortOrder,
string? WorkflowStepTargetType,
string? WorkflowStepTargetValue,
int? WorkflowStepRequiredApproverCount,
string Stage,
string ReviewerName,
string ReviewerEmail,
@@ -65,6 +70,7 @@ public class GetApprovalsHandler(
List<ApprovalRequest> approvals = await dbContext.ApprovalRequests
.Where(approval => approval.ContentItemId == request.ContentItemId)
.OrderByDescending(approval => approval.SentAt)
.ThenBy(approval => approval.WorkflowStepSortOrder)
.ToListAsync(ct);
List<Guid> approvalIds = approvals
@@ -91,6 +97,11 @@ public class GetApprovalsHandler(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.WorkflowInstanceId,
approval.WorkflowStepSortOrder,
approval.WorkflowStepTargetType,
approval.WorkflowStepTargetValue,
approval.WorkflowStepRequiredApproverCount,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,

View File

@@ -4,7 +4,9 @@ using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Approvals.Handlers;
@@ -19,7 +21,10 @@ public class SubmitApprovalDecisionRequestValidator
{
public SubmitApprovalDecisionRequestValidator()
{
RuleFor(x => x.Decision).NotEmpty().MaximumLength(64);
RuleFor(x => x.Decision)
.NotEmpty()
.Equal("Approved")
.WithMessage("Only approved decisions are supported.");
RuleFor(x => x.Comment).MaximumLength(2048);
RuleFor(x => x.ReviewerName).MaximumLength(256);
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
@@ -29,6 +34,7 @@ public class SubmitApprovalDecisionRequestValidator
public class SubmitApprovalDecisionHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
{
@@ -64,6 +70,13 @@ public class SubmitApprovalDecisionHandler(
return;
}
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
string normalizedDecision = request.Decision.Trim();
string decidedByName = User?.Identity?.IsAuthenticated == true
? User.GetAlias() ?? User.GetName()
@@ -84,28 +97,26 @@ public class SubmitApprovalDecisionHandler(
CreatedAt = DateTimeOffset.UtcNow,
};
ApprovalWorkflowDecisionResult workflowDecisionResult = await approvalWorkflowRuntimeService
.ApplyWorkflowStepDecisionAsync(approval, contentItem, workspace, User!, decision, ct);
if (!workflowDecisionResult.Succeeded)
{
AddError(request => request.Decision, workflowDecisionResult.ErrorMessage ?? "The approval decision could not be recorded.");
await SendErrorsAsync(workflowDecisionResult.StatusCode, ct);
return;
}
if (!workflowDecisionResult.IsWorkflowStep)
{
approval.State = normalizedDecision;
approval.CompletedAt = DateTimeOffset.UtcNow;
if (approval.Stage == "Internal")
if (normalizedDecision == "Approved")
{
contentItem.Status = normalizedDecision switch
{
"Approved" => "Ready for client review",
"Changes requested" => "Changes requested internally",
"Rejected" => "Rejected",
_ => contentItem.Status,
};
}
else if (approval.Stage == "Client")
{
contentItem.Status = normalizedDecision switch
{
"Approved" => "Approved",
"Changes requested" => "Changes requested by client",
"Rejected" => "Rejected",
_ => contentItem.Status,
};
contentItem.Status = ApprovalWorkflowRules.GetFinalApprovalStatus(
workspace.SchedulePostsAutomaticallyOnApproval,
contentItem.DueDate);
}
dbContext.ApprovalDecisions.Add(decision);
@@ -123,6 +134,7 @@ public class SubmitApprovalDecisionHandler(
decidedByEmail,
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
ct);
}
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
@@ -158,6 +170,11 @@ public class SubmitApprovalDecisionHandler(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.WorkflowInstanceId,
approval.WorkflowStepSortOrder,
approval.WorkflowStepTargetType,
approval.WorkflowStepTargetValue,
approval.WorkflowStepRequiredApproverCount,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,

View File

@@ -0,0 +1,56 @@
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Approvals.Services;
public static class ApprovalStepTargetTypes
{
public const string Role = "Role";
public const string Membership = "Membership";
public const string Member = "Member";
}
public static class ApprovalMembershipTargets
{
public const string Team = "Team";
public const string Client = "Client";
}
public static class ApprovalStepConfigurationRules
{
public static readonly IReadOnlySet<string> AllowedTargetTypes = new HashSet<string>(StringComparer.Ordinal)
{
ApprovalStepTargetTypes.Role,
ApprovalStepTargetTypes.Membership,
ApprovalStepTargetTypes.Member,
};
public static readonly IReadOnlySet<string> AllowedRoleTargets = new HashSet<string>(StringComparer.Ordinal)
{
KnownRoles.Administrator,
KnownRoles.Manager,
KnownRoles.WorkspaceMember,
KnownRoles.Client,
KnownRoles.Provider,
};
public static readonly IReadOnlySet<string> AllowedMembershipTargets = new HashSet<string>(StringComparer.Ordinal)
{
ApprovalMembershipTargets.Team,
ApprovalMembershipTargets.Client,
};
public static bool IsValidTargetType(string? targetType)
{
return !string.IsNullOrWhiteSpace(targetType) && AllowedTargetTypes.Contains(targetType.Trim());
}
public static bool IsValidRoleTarget(string? targetValue)
{
return !string.IsNullOrWhiteSpace(targetValue) && AllowedRoleTargets.Contains(targetValue.Trim());
}
public static bool IsValidMembershipTarget(string? targetValue)
{
return !string.IsNullOrWhiteSpace(targetValue) && AllowedMembershipTargets.Contains(targetValue.Trim());
}
}

View File

@@ -0,0 +1,102 @@
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Approvals.Services;
public static class ApprovalModes
{
public const string None = "None";
public const string Optional = "Optional";
public const string Required = "Required";
public const string MultiLevel = "Multi-level";
}
public static class ApprovalWorkflowRules
{
public static bool CanCreateSingleStepApprovalRequest(string approvalMode)
{
return approvalMode is ApprovalModes.Optional or ApprovalModes.Required;
}
public static bool BlocksManualApprovedOrScheduledStatus(string approvalMode)
{
return approvalMode is ApprovalModes.Required or ApprovalModes.MultiLevel;
}
public static bool IsApprovalCompletionStatus(string status)
{
return status is "Approved" or "Scheduled";
}
public static string GetFinalApprovalStatus(bool schedulePostsAutomaticallyOnApproval, DateTimeOffset? plannedPublishDate)
{
return schedulePostsAutomaticallyOnApproval && plannedPublishDate.HasValue
? "Scheduled"
: "Approved";
}
public static bool HasRequiredStepApprovals(int approvedDecisionCount, int requiredApproverCount)
{
return approvedDecisionCount >= Math.Max(1, requiredApproverCount);
}
public static bool CanApproveWorkflowStep(
bool isAdministrator,
bool hasWorkspaceAccess,
IReadOnlyCollection<string> userRoles,
Guid userId,
string? targetType,
string? targetValue)
{
if (isAdministrator)
{
return true;
}
if (!hasWorkspaceAccess ||
string.IsNullOrWhiteSpace(targetType) ||
string.IsNullOrWhiteSpace(targetValue))
{
return false;
}
return targetType switch
{
ApprovalStepTargetTypes.Role => userRoles.Contains(targetValue),
ApprovalStepTargetTypes.Membership => MatchesMembershipTarget(userRoles, targetValue),
ApprovalStepTargetTypes.Member => ParseMemberTargetIds(targetValue).Contains(userId),
_ => false,
};
}
public static IReadOnlyCollection<Guid> ParseMemberTargetIds(string? targetValue)
{
if (string.IsNullOrWhiteSpace(targetValue))
{
return [];
}
return targetValue
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(value => Guid.TryParse(value, out Guid memberUserId) ? memberUserId : Guid.Empty)
.Where(memberUserId => memberUserId != Guid.Empty)
.Distinct()
.ToArray();
}
public static string FormatMemberTargetValue(IEnumerable<Guid> memberUserIds)
{
return string.Join(",", memberUserIds.Distinct().OrderBy(memberUserId => memberUserId));
}
private static bool MatchesMembershipTarget(
IReadOnlyCollection<string> userRoles,
string targetValue)
{
return targetValue switch
{
ApprovalMembershipTargets.Client => userRoles.Contains(KnownRoles.Client),
ApprovalMembershipTargets.Team => !userRoles.Contains(KnownRoles.Client),
_ => false,
};
}
}

View File

@@ -0,0 +1,401 @@
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Approvals.Services;
public record ApprovalWorkflowStartResult(bool Succeeded, string? ErrorMessage);
public record ApprovalWorkflowDecisionResult(
bool Succeeded,
string? ErrorMessage,
int StatusCode,
bool IsWorkflowStep);
public class ApprovalWorkflowRuntimeService(
AppDbContext dbContext,
INotificationEventWriter notificationEventWriter)
{
private const string PendingState = "Pending";
private const string ApprovedState = "Approved";
public async Task<ApprovalWorkflowStartResult> StartMultiLevelWorkflowAsync(
ContentItem contentItem,
Workspace workspace,
Guid requestedByUserId,
CancellationToken ct)
{
if (workspace.ApprovalMode != ApprovalModes.MultiLevel)
{
return new ApprovalWorkflowStartResult(false, "The workspace is not configured for multi-level approval.");
}
ApprovalWorkflowInstance? activeWorkflow = await dbContext.ApprovalWorkflowInstances
.SingleOrDefaultAsync(
workflow => workflow.ContentItemId == contentItem.Id && workflow.State == PendingState,
ct);
if (activeWorkflow is not null)
{
contentItem.Status = "In approval";
return new ApprovalWorkflowStartResult(true, null);
}
List<WorkspaceApprovalStepConfiguration> configuredSteps = await dbContext.WorkspaceApprovalStepConfigurations
.Where(step => step.WorkspaceId == workspace.Id)
.OrderBy(step => step.SortOrder)
.ThenBy(step => step.Name)
.ToListAsync(ct);
if (configuredSteps.Count == 0)
{
return new ApprovalWorkflowStartResult(false, "Multi-level approval requires at least one configured approval step.");
}
DateTimeOffset now = DateTimeOffset.UtcNow;
var workflowInstance = new ApprovalWorkflowInstance
{
Id = Guid.NewGuid(),
WorkspaceId = workspace.Id,
ContentItemId = contentItem.Id,
State = PendingState,
ApprovalMode = workspace.ApprovalMode,
StartedAt = now,
};
List<ApprovalRequest> workflowSteps = configuredSteps
.Select((step, index) => new ApprovalRequest
{
Id = Guid.NewGuid(),
WorkspaceId = workspace.Id,
ContentItemId = contentItem.Id,
WorkflowInstanceId = workflowInstance.Id,
WorkflowStepSortOrder = index,
WorkflowStepTargetType = step.TargetType,
WorkflowStepTargetValue = step.TargetValue,
WorkflowStepRequiredApproverCount = step.RequiredApproverCount,
Stage = step.Name,
ReviewerName = FormatStepTarget(step),
ReviewerEmail = string.Empty,
RequestedByUserId = requestedByUserId,
DueAt = contentItem.DueDate,
State = PendingState,
AccessToken = CreateAccessToken(),
SentAt = now,
})
.ToList();
dbContext.ApprovalWorkflowInstances.Add(workflowInstance);
dbContext.ApprovalRequests.AddRange(workflowSteps);
contentItem.Status = "In approval";
await dbContext.SaveChangesAsync(ct);
await NotifyCurrentStepApproversAsync(workflowSteps[0], contentItem, ct);
return new ApprovalWorkflowStartResult(true, null);
}
public async Task<ApprovalWorkflowDecisionResult> ApplyWorkflowStepDecisionAsync(
ApprovalRequest approval,
ContentItem contentItem,
Workspace workspace,
ClaimsPrincipal user,
ApprovalDecision decision,
CancellationToken ct)
{
if (!approval.WorkflowInstanceId.HasValue)
{
return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, false);
}
if (user.Identity?.IsAuthenticated != true)
{
return new ApprovalWorkflowDecisionResult(false, "Multi-level approval steps require an authenticated approver.", StatusCodes.Status401Unauthorized, true);
}
if (!await CanApproveStepAsync(user, approval, workspace.Id, ct))
{
return new ApprovalWorkflowDecisionResult(false, "You cannot approve the current workflow step.", StatusCodes.Status403Forbidden, true);
}
ApprovalRequest? currentStep = await GetCurrentPendingStepAsync(approval.WorkflowInstanceId.Value, ct);
if (currentStep?.Id != approval.Id)
{
return new ApprovalWorkflowDecisionResult(false, "Only the current pending approval step can be approved.", StatusCodes.Status409Conflict, true);
}
Guid currentUserId = user.GetUserId();
bool alreadyApproved = await dbContext.ApprovalDecisions.AnyAsync(
candidate => candidate.ApprovalRequestId == approval.Id &&
candidate.DecidedByUserId == currentUserId &&
candidate.Decision == ApprovedState,
ct);
if (alreadyApproved)
{
return new ApprovalWorkflowDecisionResult(false, "You have already approved this workflow step.", StatusCodes.Status409Conflict, true);
}
dbContext.ApprovalDecisions.Add(decision);
await dbContext.SaveChangesAsync(ct);
int approvedCount = await dbContext.ApprovalDecisions
.Where(candidate => candidate.ApprovalRequestId == approval.Id && candidate.Decision == ApprovedState)
.Select(candidate => candidate.DecidedByUserId.HasValue
? candidate.DecidedByUserId.Value.ToString()
: candidate.DecidedByEmail.ToLower())
.Distinct()
.CountAsync(ct);
int requiredApproverCount = approval.WorkflowStepRequiredApproverCount ?? 1;
if (!ApprovalWorkflowRules.HasRequiredStepApprovals(approvedCount, requiredApproverCount))
{
return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, true);
}
approval.State = ApprovedState;
approval.CompletedAt = DateTimeOffset.UtcNow;
ApprovalRequest? nextStep = await dbContext.ApprovalRequests
.Where(candidate => candidate.WorkflowInstanceId == approval.WorkflowInstanceId &&
candidate.State == PendingState &&
candidate.Id != approval.Id)
.OrderBy(candidate => candidate.WorkflowStepSortOrder)
.ThenBy(candidate => candidate.SentAt)
.FirstOrDefaultAsync(ct);
if (nextStep is null)
{
ApprovalWorkflowInstance? workflowInstance = await dbContext.ApprovalWorkflowInstances
.SingleOrDefaultAsync(candidate => candidate.Id == approval.WorkflowInstanceId.Value, ct);
if (workflowInstance is null)
{
return new ApprovalWorkflowDecisionResult(false, "The approval workflow instance could not be found.", StatusCodes.Status404NotFound, true);
}
workflowInstance.State = ApprovedState;
workflowInstance.CompletedAt = DateTimeOffset.UtcNow;
contentItem.Status = ApprovalWorkflowRules.GetFinalApprovalStatus(
workspace.SchedulePostsAutomaticallyOnApproval,
contentItem.DueDate);
}
await dbContext.SaveChangesAsync(ct);
if (nextStep is null)
{
await NotifyPublishUsersAsync(approval, contentItem, ct);
}
else
{
await NotifyCurrentStepApproversAsync(nextStep, contentItem, ct);
}
return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, true);
}
public async Task<bool> HasCompletedMultiLevelWorkflowAsync(Guid contentItemId, CancellationToken ct)
{
return await dbContext.ApprovalWorkflowInstances.AnyAsync(
workflow => workflow.ContentItemId == contentItemId && workflow.State == ApprovedState,
ct);
}
private async Task<ApprovalRequest?> GetCurrentPendingStepAsync(Guid workflowInstanceId, CancellationToken ct)
{
return await dbContext.ApprovalRequests
.Where(candidate => candidate.WorkflowInstanceId == workflowInstanceId && candidate.State == PendingState)
.OrderBy(candidate => candidate.WorkflowStepSortOrder)
.ThenBy(candidate => candidate.SentAt)
.FirstOrDefaultAsync(ct);
}
private async Task<bool> CanApproveStepAsync(
ClaimsPrincipal user,
ApprovalRequest approval,
Guid workspaceId,
CancellationToken ct)
{
Guid userId = user.GetUserId();
bool hasWorkspaceAccess = await UserHasWorkspaceAccessAsync(userId, workspaceId, ct);
string[] userRoles = ApprovalStepConfigurationRules.AllowedRoleTargets
.Where(user.IsInRole)
.ToArray();
return ApprovalWorkflowRules.CanApproveWorkflowStep(
user.IsInRole(KnownRoles.Administrator),
hasWorkspaceAccess,
userRoles,
userId,
approval.WorkflowStepTargetType,
approval.WorkflowStepTargetValue);
}
private async Task<bool> UserHasWorkspaceAccessAsync(Guid userId, Guid workspaceId, CancellationToken ct)
{
string workspaceClaimValue = workspaceId.ToString();
return await dbContext.UserClaims.AnyAsync(
claim => claim.UserId == userId &&
claim.ClaimType == KnownClaims.WorkspaceScope &&
claim.ClaimValue == workspaceClaimValue,
ct);
}
private async Task NotifyCurrentStepApproversAsync(
ApprovalRequest approval,
ContentItem contentItem,
CancellationToken ct)
{
List<ApprovalNotificationRecipient> recipients = await GetStepApproverRecipientsAsync(approval, ct);
foreach (ApprovalNotificationRecipient recipient in recipients)
{
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.step.current",
"ApprovalRequest",
approval.Id,
$"{approval.Stage} approval is ready for {contentItem.Title}.",
recipient.UserId,
recipient.Email,
$$"""{"stage":"{{approval.Stage}}","requiredApproverCount":{{approval.WorkflowStepRequiredApproverCount ?? 1}}}"""),
ct);
}
}
private async Task NotifyPublishUsersAsync(
ApprovalRequest approval,
ContentItem contentItem,
CancellationToken ct)
{
List<ApprovalNotificationRecipient> recipients = await GetPublishRecipientUsersAsync(approval.WorkspaceId, ct);
foreach (ApprovalNotificationRecipient recipient in recipients)
{
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.workflow.completed",
"ApprovalWorkflowInstance",
approval.WorkflowInstanceId!.Value,
$"Final approval completed for {contentItem.Title}.",
recipient.UserId,
recipient.Email,
$$"""{"status":"{{contentItem.Status}}"}"""),
ct);
}
}
private async Task<List<ApprovalNotificationRecipient>> GetStepApproverRecipientsAsync(
ApprovalRequest approval,
CancellationToken ct)
{
string? targetType = approval.WorkflowStepTargetType;
string? targetValue = approval.WorkflowStepTargetValue;
if (string.IsNullOrWhiteSpace(targetType) || string.IsNullOrWhiteSpace(targetValue))
{
return [];
}
return targetType switch
{
ApprovalStepTargetTypes.Member => await GetMemberRecipientsAsync(targetValue, ct),
ApprovalStepTargetTypes.Role => await GetRoleRecipientsAsync(approval.WorkspaceId, [targetValue], ct),
ApprovalStepTargetTypes.Membership => await GetMembershipRecipientsAsync(approval.WorkspaceId, targetValue, ct),
_ => [],
};
}
private async Task<List<ApprovalNotificationRecipient>> GetMemberRecipientsAsync(string targetValue, CancellationToken ct)
{
IReadOnlyCollection<Guid> userIds = ApprovalWorkflowRules.ParseMemberTargetIds(targetValue);
if (userIds.Count == 0)
{
return [];
}
return await dbContext.Users
.Where(user => userIds.Contains(user.Id))
.Select(user => new ApprovalNotificationRecipient(user.Id, user.Email))
.ToListAsync(ct);
}
private async Task<List<ApprovalNotificationRecipient>> GetMembershipRecipientsAsync(
Guid workspaceId,
string targetValue,
CancellationToken ct)
{
string[] roles = targetValue switch
{
ApprovalMembershipTargets.Client => [KnownRoles.Client],
ApprovalMembershipTargets.Team => [KnownRoles.Administrator, KnownRoles.Manager, KnownRoles.WorkspaceMember, KnownRoles.Provider],
_ => [],
};
return roles.Length == 0
? []
: await GetRoleRecipientsAsync(workspaceId, roles, ct);
}
private async Task<List<ApprovalNotificationRecipient>> GetPublishRecipientUsersAsync(Guid workspaceId, CancellationToken ct)
{
return await GetRoleRecipientsAsync(workspaceId, [KnownRoles.Administrator, KnownRoles.Manager], ct);
}
private async Task<List<ApprovalNotificationRecipient>> GetRoleRecipientsAsync(
Guid workspaceId,
IReadOnlyCollection<string> roles,
CancellationToken ct)
{
string workspaceClaimValue = workspaceId.ToString();
return await dbContext.UserRoles
.Join(
dbContext.Roles,
userRole => userRole.RoleId,
role => role.Id,
(userRole, role) => new { userRole.UserId, RoleName = role.Name })
.Where(candidate => candidate.RoleName != null && roles.Contains(candidate.RoleName))
.Join(
dbContext.UserClaims.Where(claim =>
claim.ClaimType == KnownClaims.WorkspaceScope &&
claim.ClaimValue == workspaceClaimValue),
candidate => candidate.UserId,
claim => claim.UserId,
(candidate, _) => candidate.UserId)
.Distinct()
.Join(
dbContext.Users,
userId => userId,
user => user.Id,
(_, user) => new ApprovalNotificationRecipient(user.Id, user.Email))
.ToListAsync(ct);
}
private static string FormatStepTarget(WorkspaceApprovalStepConfiguration step)
{
return step.TargetType switch
{
ApprovalStepTargetTypes.Role => $"Role: {step.TargetValue}",
ApprovalStepTargetTypes.Membership => $"Membership: {step.TargetValue}",
ApprovalStepTargetTypes.Member => "Assigned members",
_ => step.TargetValue,
};
}
private static string CreateAccessToken()
{
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
}
private sealed record ApprovalNotificationRecipient(Guid UserId, string? Email);
}

View File

@@ -66,15 +66,6 @@ public class CreateContentItemRevisionHandler(
item.CurrentRevisionNumber = revisionNumber;
item.CurrentRevisionLabel = revisionLabel;
if (item.Status == "Changes requested internally")
{
item.Status = "Internal changes in progress";
}
else if (item.Status == "Changes requested by client")
{
item.Status = "Client changes in progress";
}
ContentItemRevision revision = new()
{
Id = Guid.NewGuid(),

View File

@@ -2,8 +2,10 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.ContentItems.Handlers;
@@ -21,24 +23,18 @@ public class UpdateContentItemStatusRequestValidator
public class UpdateContentItemStatusHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<UpdateContentItemStatusRequest, ContentItemDetailDto>
{
private static readonly HashSet<string> AllowedStatuses =
[
"Draft",
"In internal review",
"Changes requested internally",
"Internal changes in progress",
"Ready for client review",
"In client review",
"Changes requested by client",
"Client changes in progress",
"In production",
"In approval",
"Approved",
"Rejected",
"Ready to publish",
"Scheduled",
"Published",
"Archived",
];
public override void Configure()
@@ -72,7 +68,64 @@ public class UpdateContentItemStatusHandler(
return;
}
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == item.WorkspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
if (normalizedStatus == "In approval" && workspace.ApprovalMode == ApprovalModes.MultiLevel)
{
ApprovalWorkflowStartResult startResult = await approvalWorkflowRuntimeService.StartMultiLevelWorkflowAsync(
item,
workspace,
User.GetUserId(),
ct);
if (!startResult.Succeeded)
{
AddError(request => request.Status, startResult.ErrorMessage ?? "The approval workflow could not be started.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
}
else if (ApprovalWorkflowRules.IsApprovalCompletionStatus(normalizedStatus) &&
ApprovalWorkflowRules.BlocksManualApprovedOrScheduledStatus(workspace.ApprovalMode))
{
if (workspace.ApprovalMode == ApprovalModes.MultiLevel)
{
bool hasCompletedWorkflow = await approvalWorkflowRuntimeService.HasCompletedMultiLevelWorkflowAsync(item.Id, ct);
if (!hasCompletedWorkflow)
{
AddError(request => request.Status, "This workspace requires the multi-level approval workflow to complete before content can be approved or scheduled.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
}
else
{
bool hasApprovedDecision = await dbContext.ApprovalRequests.AnyAsync(
approval => approval.ContentItemId == item.Id &&
approval.WorkspaceId == item.WorkspaceId &&
approval.State == "Approved" &&
approval.CompletedAt.HasValue,
ct);
if (!hasApprovedDecision)
{
AddError(request => request.Status, "This workspace requires approval before content can be approved or scheduled.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
}
}
if (item.Status != "In approval" || normalizedStatus != "In approval")
{
item.Status = normalizedStatus;
}
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(

View File

@@ -0,0 +1,164 @@
using Socialize.Api.Modules.Feedback.Data;
namespace Socialize.Api.Modules.Feedback.Contracts;
public record FeedbackContextDto(
Guid? WorkspaceId,
string? WorkspaceName,
Guid? ClientId,
string? ClientName,
Guid? ProjectId,
string? ProjectName,
Guid? ContentItemId,
string? ContentItemTitle);
public record FeedbackMetadataDto(
string SubmittedPath,
string? BrowserUserAgent,
int? ViewportWidth,
int? ViewportHeight,
string? AppVersion);
public record FeedbackScreenshotDto(
Guid Id,
string FileName,
string ContentType,
long SizeBytes,
string DownloadPath,
DateTimeOffset CreatedAt);
public record FeedbackReportDto(
Guid Id,
string Type,
string Status,
string Description,
Guid ReporterUserId,
string ReporterDisplayName,
string ReporterEmail,
FeedbackMetadataDto Metadata,
FeedbackContextDto Context,
FeedbackScreenshotDto? Screenshot,
IReadOnlyCollection<string> Tags,
IReadOnlyCollection<FeedbackTimelineItemDto> Timeline,
DateTimeOffset CreatedAt,
DateTimeOffset LastActivityAt,
DateTimeOffset? CancelledAt,
string? CancellationReason);
public record FeedbackTimelineItemDto(
Guid Id,
string Kind,
Guid ActorUserId,
string ActorDisplayName,
string ActorEmail,
string? ActorRole,
string? Body,
string? ActivityType,
string? FromValue,
string? ToValue,
string? Note,
DateTimeOffset CreatedAt);
public static class FeedbackDtoMapper
{
public static FeedbackReportDto ToDto(this FeedbackReport report)
{
return new FeedbackReportDto(
report.Id,
ToDisplayString(report.Type),
ToDisplayString(report.Status),
report.Description,
report.ReporterUserId,
report.ReporterDisplayName,
report.ReporterEmail,
new FeedbackMetadataDto(
report.SubmittedPath,
report.BrowserUserAgent,
report.ViewportWidth,
report.ViewportHeight,
report.AppVersion),
new FeedbackContextDto(
report.WorkspaceId,
report.WorkspaceName,
report.ClientId,
report.ClientName,
report.ProjectId,
report.ProjectName,
report.ContentItemId,
report.ContentItemTitle),
report.Screenshot is null
? null
: new FeedbackScreenshotDto(
report.Screenshot.Id,
report.Screenshot.FileName,
report.Screenshot.ContentType,
report.Screenshot.SizeBytes,
$"/api/feedback/{report.Id}/screenshot",
report.Screenshot.CreatedAt),
report.Tags.OrderBy(tag => tag.Name).Select(tag => tag.Name).ToArray(),
report.Comments
.Select(comment => comment.ToTimelineDto())
.Concat(report.ActivityEntries.Select(activity => activity.ToTimelineDto()))
.OrderBy(item => item.CreatedAt)
.ThenBy(item => item.Kind)
.ToArray(),
report.CreatedAt,
report.LastActivityAt,
report.CancelledAt,
report.CancellationReason);
}
private static string ToDisplayString(FeedbackType type)
{
return type.ToString();
}
private static string ToDisplayString(FeedbackStatus status)
{
return status == FeedbackStatus.WontDo ? "Won't Do" : status.ToString();
}
public static FeedbackTimelineItemDto ToTimelineDto(this FeedbackComment comment)
{
return new FeedbackTimelineItemDto(
comment.Id,
"Comment",
comment.AuthorUserId,
comment.AuthorDisplayName,
comment.AuthorEmail,
comment.AuthorRole,
comment.Body,
null,
null,
null,
null,
comment.CreatedAt);
}
public static FeedbackTimelineItemDto ToTimelineDto(this FeedbackActivityEntry activity)
{
return new FeedbackTimelineItemDto(
activity.Id,
"Activity",
activity.ActorUserId,
activity.ActorDisplayName,
activity.ActorEmail,
null,
null,
activity.ActivityType,
activity.FromValue,
activity.ToValue,
activity.Note,
activity.CreatedAt);
}
public static string ToFeedbackDisplayString(this FeedbackType type)
{
return ToDisplayString(type);
}
public static string ToFeedbackDisplayString(this FeedbackStatus status)
{
return ToDisplayString(status);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,104 @@
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.Feedback.Data;
public static class FeedbackModelConfiguration
{
public static ModelBuilder ConfigureFeedbackModule(this ModelBuilder modelBuilder)
{
modelBuilder.Entity<FeedbackReport>(feedback =>
{
feedback.ToTable("FeedbackReports");
feedback.HasKey(x => x.Id);
feedback.Property(x => x.Type).HasConversion<string>().HasMaxLength(32).IsRequired();
feedback.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
feedback.Property(x => x.Description).HasMaxLength(8000).IsRequired();
feedback.Property(x => x.ReporterDisplayName).HasMaxLength(256).IsRequired();
feedback.Property(x => x.ReporterEmail).HasMaxLength(256).IsRequired();
feedback.Property(x => x.SubmittedPath).HasMaxLength(2048).IsRequired();
feedback.Property(x => x.BrowserUserAgent).HasMaxLength(1024);
feedback.Property(x => x.AppVersion).HasMaxLength(128);
feedback.Property(x => x.WorkspaceName).HasMaxLength(256);
feedback.Property(x => x.ClientName).HasMaxLength(256);
feedback.Property(x => x.ProjectName).HasMaxLength(256);
feedback.Property(x => x.ContentItemTitle).HasMaxLength(256);
feedback.Property(x => x.CancellationReason).HasMaxLength(2000);
feedback.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
feedback.HasIndex(x => x.ReporterUserId);
feedback.HasIndex(x => x.Status);
feedback.HasIndex(x => x.Type);
feedback.HasIndex(x => x.WorkspaceId);
feedback.HasIndex(x => x.LastActivityAt);
});
modelBuilder.Entity<FeedbackTag>(tag =>
{
tag.ToTable("FeedbackTags");
tag.HasKey(x => x.Id);
tag.Property(x => x.Name).HasMaxLength(64).IsRequired();
tag.Property(x => x.NormalizedName).HasMaxLength(64).IsRequired();
tag.HasIndex(x => x.NormalizedName);
tag.HasIndex(x => new { x.FeedbackReportId, x.NormalizedName }).IsUnique();
tag.HasOne(x => x.FeedbackReport)
.WithMany(x => x.Tags)
.HasForeignKey(x => x.FeedbackReportId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<FeedbackScreenshot>(screenshot =>
{
screenshot.ToTable("FeedbackScreenshots");
screenshot.HasKey(x => x.Id);
screenshot.Property(x => x.FileName).HasMaxLength(256).IsRequired();
screenshot.Property(x => x.ContentType).HasMaxLength(128).IsRequired();
screenshot.Property(x => x.BlobContainerName).HasMaxLength(128).IsRequired();
screenshot.Property(x => x.BlobName).HasMaxLength(512).IsRequired();
screenshot.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
screenshot.HasIndex(x => x.FeedbackReportId).IsUnique();
screenshot.HasOne(x => x.FeedbackReport)
.WithOne(x => x.Screenshot)
.HasForeignKey<FeedbackScreenshot>(x => x.FeedbackReportId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<FeedbackComment>(comment =>
{
comment.ToTable("FeedbackComments");
comment.HasKey(x => x.Id);
comment.Property(x => x.AuthorDisplayName).HasMaxLength(256).IsRequired();
comment.Property(x => x.AuthorEmail).HasMaxLength(256).IsRequired();
comment.Property(x => x.AuthorRole).HasMaxLength(32).IsRequired();
comment.Property(x => x.Body).HasMaxLength(8000).IsRequired();
comment.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
comment.HasIndex(x => x.FeedbackReportId);
comment.HasIndex(x => x.AuthorUserId);
comment.HasIndex(x => x.CreatedAt);
comment.HasOne(x => x.FeedbackReport)
.WithMany(x => x.Comments)
.HasForeignKey(x => x.FeedbackReportId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<FeedbackActivityEntry>(activity =>
{
activity.ToTable("FeedbackActivityEntries");
activity.HasKey(x => x.Id);
activity.Property(x => x.ActorDisplayName).HasMaxLength(256).IsRequired();
activity.Property(x => x.ActorEmail).HasMaxLength(256).IsRequired();
activity.Property(x => x.ActivityType).HasMaxLength(64).IsRequired();
activity.Property(x => x.FromValue).HasMaxLength(512);
activity.Property(x => x.ToValue).HasMaxLength(512);
activity.Property(x => x.Note).HasMaxLength(2000);
activity.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
activity.HasIndex(x => x.FeedbackReportId);
activity.HasIndex(x => x.ActorUserId);
activity.HasIndex(x => x.CreatedAt);
activity.HasOne(x => x.FeedbackReport)
.WithMany(x => x.ActivityEntries)
.HasForeignKey(x => x.FeedbackReportId)
.OnDelete(DeleteBehavior.Cascade);
});
return modelBuilder;
}
}

View File

@@ -0,0 +1,34 @@
namespace Socialize.Api.Modules.Feedback.Data;
public class FeedbackReport
{
public Guid Id { get; set; }
public FeedbackType Type { get; set; }
public FeedbackStatus Status { get; set; }
public string Description { get; set; } = string.Empty;
public Guid ReporterUserId { get; set; }
public string ReporterDisplayName { get; set; } = string.Empty;
public string ReporterEmail { get; set; } = string.Empty;
public string SubmittedPath { get; set; } = string.Empty;
public string? BrowserUserAgent { get; set; }
public int? ViewportWidth { get; set; }
public int? ViewportHeight { get; set; }
public string? AppVersion { get; set; }
public Guid? WorkspaceId { get; set; }
public string? WorkspaceName { get; set; }
public Guid? ClientId { get; set; }
public string? ClientName { get; set; }
public Guid? ProjectId { get; set; }
public string? ProjectName { get; set; }
public Guid? ContentItemId { get; set; }
public string? ContentItemTitle { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset LastActivityAt { get; set; }
public DateTimeOffset? CancelledAt { get; set; }
public Guid? CancelledByUserId { get; set; }
public string? CancellationReason { get; set; }
public ICollection<FeedbackTag> Tags { get; } = new List<FeedbackTag>();
public ICollection<FeedbackComment> Comments { get; } = new List<FeedbackComment>();
public ICollection<FeedbackActivityEntry> ActivityEntries { get; } = new List<FeedbackActivityEntry>();
public FeedbackScreenshot? Screenshot { get; set; }
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
namespace Socialize.Api.Modules.Feedback;
public static class DependencyInjection
{
public static WebApplicationBuilder AddFeedbackModule(this WebApplicationBuilder builder)
{
builder.Services.AddScoped<Services.FeedbackNotificationService>();
return builder;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,80 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Feedback.Contracts;
using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Feedback.Services;
namespace Socialize.Api.Modules.Feedback.Handlers;
public record CancelMyFeedbackRequest(string? Reason);
public class CancelMyFeedbackRequestValidator
: Validator<CancelMyFeedbackRequest>
{
public CancelMyFeedbackRequestValidator()
{
RuleFor(x => x.Reason).MaximumLength(2000);
}
}
public class CancelMyFeedbackHandler(AppDbContext dbContext)
: Endpoint<CancelMyFeedbackRequest, FeedbackReportDto>
{
public override void Configure()
{
Post("/api/my-feedback/{id}/cancel");
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(CancelMyFeedbackRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
Guid reporterUserId = User.GetUserId();
FeedbackReport? report = await dbContext.FeedbackReports
.Include(candidate => candidate.Tags)
.Include(candidate => candidate.Screenshot)
.SingleOrDefaultAsync(
candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId,
ct);
if (report is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!FeedbackAccessRules.CanReporterCancel(report, reporterUserId))
{
AddError("The feedback report cannot be cancelled.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
DateTimeOffset now = DateTimeOffset.UtcNow;
FeedbackStatus previousStatus = report.Status;
report.Status = FeedbackStatus.Cancelled;
report.CancelledAt = now;
report.CancelledByUserId = reporterUserId;
report.CancellationReason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
report.LastActivityAt = now;
report.ActivityEntries.Add(new FeedbackActivityEntry
{
Id = Guid.NewGuid(),
FeedbackReportId = report.Id,
ActorUserId = reporterUserId,
ActorDisplayName = User.GetAlias() ?? User.GetName(),
ActorEmail = User.GetEmail(),
ActivityType = FeedbackActivityTypes.Cancelled,
FromValue = previousStatus.ToFeedbackDisplayString(),
ToValue = FeedbackStatus.Cancelled.ToFeedbackDisplayString(),
Note = report.CancellationReason,
CreatedAt = now,
});
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(report.ToDto(), ct);
}
}

View File

@@ -0,0 +1,38 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Feedback.Contracts;
using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Feedback.Handlers;
public class GetDeveloperFeedbackHandler(AppDbContext dbContext)
: EndpointWithoutRequest<FeedbackReportDto>
{
public override void Configure()
{
Get("/api/feedback/{id}");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
FeedbackReport? report = await dbContext.FeedbackReports
.Include(candidate => candidate.Tags)
.Include(candidate => candidate.Screenshot)
.Include(candidate => candidate.Comments)
.Include(candidate => candidate.ActivityEntries)
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (report is null)
{
await SendNotFoundAsync(ct);
return;
}
await SendOkAsync(report.ToDto(), ct);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Feedback.Contracts;
using Socialize.Api.Modules.Feedback.Data;
namespace Socialize.Api.Modules.Feedback.Handlers;
public class GetMyFeedbackHandler(AppDbContext dbContext)
: EndpointWithoutRequest<FeedbackReportDto>
{
public override void Configure()
{
Get("/api/my-feedback/{id}");
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
Guid reporterUserId = User.GetUserId();
FeedbackReport? report = await dbContext.FeedbackReports
.Include(candidate => candidate.Tags)
.Include(candidate => candidate.Screenshot)
.Include(candidate => candidate.Comments)
.Include(candidate => candidate.ActivityEntries)
.SingleOrDefaultAsync(candidate => candidate.Id == id && candidate.ReporterUserId == reporterUserId, ct);
if (report is null)
{
await SendNotFoundAsync(ct);
return;
}
await SendOkAsync(report.ToDto(), ct);
}
}

View File

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

View File

@@ -0,0 +1,30 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Feedback.Contracts;
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Feedback.Handlers;
public class ListDeveloperFeedbackHandler(AppDbContext dbContext)
: EndpointWithoutRequest<IReadOnlyCollection<FeedbackReportDto>>
{
public override void Configure()
{
Get("/api/feedback");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(CancellationToken ct)
{
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
.Include(report => report.Tags)
.Include(report => report.Screenshot)
.OrderByDescending(report => report.LastActivityAt)
.Select(report => report.ToDto())
.ToListAsync(ct);
await SendOkAsync(reports, ct);
}
}

View File

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

View File

@@ -0,0 +1,31 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Feedback.Contracts;
namespace Socialize.Api.Modules.Feedback.Handlers;
public class ListMyFeedbackHandler(AppDbContext dbContext)
: EndpointWithoutRequest<IReadOnlyCollection<FeedbackReportDto>>
{
public override void Configure()
{
Get("/api/my-feedback");
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid reporterUserId = User.GetUserId();
List<FeedbackReportDto> reports = await dbContext.FeedbackReports
.Include(report => report.Tags)
.Include(report => report.Screenshot)
.Where(report => report.ReporterUserId == reporterUserId)
.OrderByDescending(report => report.LastActivityAt)
.Select(report => report.ToDto())
.ToListAsync(ct);
await SendOkAsync(reports, ct);
}
}

View File

@@ -0,0 +1,105 @@
using FastEndpoints;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Feedback.Contracts;
using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Feedback.Services;
namespace Socialize.Api.Modules.Feedback.Handlers;
public record SubmitFeedbackRequest(
string Type,
string Description,
string SubmittedPath,
string? BrowserUserAgent,
int? ViewportWidth,
int? ViewportHeight,
string? AppVersion,
Guid? WorkspaceId,
string? WorkspaceName,
Guid? ClientId,
string? ClientName,
Guid? ProjectId,
string? ProjectName,
Guid? ContentItemId,
string? ContentItemTitle);
public class SubmitFeedbackRequestValidator
: Validator<SubmitFeedbackRequest>
{
public SubmitFeedbackRequestValidator()
{
RuleFor(x => x.Type).NotEmpty().MaximumLength(32);
RuleFor(x => x.Description).NotEmpty().MaximumLength(8000);
RuleFor(x => x.SubmittedPath).NotEmpty().MaximumLength(2048);
RuleFor(x => x.BrowserUserAgent).MaximumLength(1024);
RuleFor(x => x.AppVersion).MaximumLength(128);
RuleFor(x => x.WorkspaceName).MaximumLength(256);
RuleFor(x => x.ClientName).MaximumLength(256);
RuleFor(x => x.ProjectName).MaximumLength(256);
RuleFor(x => x.ContentItemTitle).MaximumLength(256);
RuleFor(x => x.ViewportWidth).GreaterThan(0).When(x => x.ViewportWidth.HasValue);
RuleFor(x => x.ViewportHeight).GreaterThan(0).When(x => x.ViewportHeight.HasValue);
}
}
public class SubmitFeedbackHandler(
AppDbContext dbContext,
FeedbackNotificationService notificationService)
: Endpoint<SubmitFeedbackRequest, FeedbackReportDto>
{
public override void Configure()
{
Post("/api/feedback");
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(SubmitFeedbackRequest request, CancellationToken ct)
{
if (!FeedbackRules.TryParseType(request.Type, out FeedbackType type))
{
AddError(request => request.Type, "The selected feedback type is not valid.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
DateTimeOffset now = DateTimeOffset.UtcNow;
FeedbackReport report = new()
{
Id = Guid.NewGuid(),
Type = type,
Status = FeedbackStatus.New,
Description = request.Description.Trim(),
ReporterUserId = User.GetUserId(),
ReporterDisplayName = User.GetAlias() ?? User.GetName(),
ReporterEmail = User.GetEmail(),
SubmittedPath = request.SubmittedPath.Trim(),
BrowserUserAgent = NormalizeOptional(request.BrowserUserAgent),
ViewportWidth = request.ViewportWidth,
ViewportHeight = request.ViewportHeight,
AppVersion = NormalizeOptional(request.AppVersion),
WorkspaceId = request.WorkspaceId,
WorkspaceName = NormalizeOptional(request.WorkspaceName),
ClientId = request.ClientId,
ClientName = NormalizeOptional(request.ClientName),
ProjectId = request.ProjectId,
ProjectName = NormalizeOptional(request.ProjectName),
ContentItemId = request.ContentItemId,
ContentItemTitle = NormalizeOptional(request.ContentItemTitle),
CreatedAt = now,
LastActivityAt = now,
};
dbContext.FeedbackReports.Add(report);
await notificationService.AddNewReportNotificationsAsync(report, ct);
await dbContext.SaveChangesAsync(ct);
await SendAsync(report.ToDto(), StatusCodes.Status201Created, ct);
}
private static string? NormalizeOptional(string? value)
{
string? normalized = value?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
}

View File

@@ -0,0 +1,210 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Feedback.Contracts;
using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Feedback.Services;
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Feedback.Handlers;
public record UpdateDeveloperFeedbackRequest(
string? Type,
string? Status,
IReadOnlyCollection<string>? Tags);
public class UpdateDeveloperFeedbackRequestValidator
: Validator<UpdateDeveloperFeedbackRequest>
{
public UpdateDeveloperFeedbackRequestValidator()
{
RuleFor(x => x.Type).MaximumLength(32);
RuleFor(x => x.Status).MaximumLength(32);
RuleForEach(x => x.Tags).MaximumLength(64);
}
}
public class UpdateDeveloperFeedbackHandler(
AppDbContext dbContext,
FeedbackNotificationService notificationService)
: Endpoint<UpdateDeveloperFeedbackRequest, FeedbackReportDto>
{
public override void Configure()
{
Patch("/api/feedback/{id}");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Feedback"));
}
public override async Task HandleAsync(UpdateDeveloperFeedbackRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
FeedbackReport? report = await dbContext.FeedbackReports
.Include(candidate => candidate.Tags)
.Include(candidate => candidate.Screenshot)
.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (report is null)
{
await SendNotFoundAsync(ct);
return;
}
bool changed = false;
Guid developerUserId = User.GetUserId();
DateTimeOffset now = DateTimeOffset.UtcNow;
if (!string.IsNullOrWhiteSpace(request.Type))
{
if (!FeedbackRules.TryParseType(request.Type, out FeedbackType nextType))
{
AddError(request => request.Type, "The selected feedback type is not valid.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (report.Type != nextType)
{
AddActivity(
report,
developerUserId,
FeedbackActivityTypes.TypeChanged,
report.Type.ToFeedbackDisplayString(),
nextType.ToFeedbackDisplayString(),
null,
now);
report.Type = nextType;
changed = true;
}
}
if (!string.IsNullOrWhiteSpace(request.Status))
{
if (!FeedbackRules.TryParseStatus(request.Status, out FeedbackStatus nextStatus))
{
AddError(request => request.Status, "The selected feedback status is not valid.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!FeedbackRules.CanDeveloperSetStatus(report.Status, nextStatus))
{
AddError(request => request.Status, "The requested status transition is not allowed.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (report.Status != nextStatus)
{
AddActivity(
report,
developerUserId,
FeedbackActivityTypes.StatusChanged,
report.Status.ToFeedbackDisplayString(),
nextStatus.ToFeedbackDisplayString(),
null,
now);
report.Status = nextStatus;
notificationService.AddDeveloperStatusNotification(report, developerUserId, nextStatus);
changed = true;
}
}
if (request.Tags is not null)
{
IReadOnlyCollection<string> normalizedTags = FeedbackRules.NormalizeTags(request.Tags);
string beforeTags = FormatTags(report.Tags.Select(tag => tag.Name));
bool tagsChanged = ApplyTags(report, normalizedTags);
if (tagsChanged)
{
AddActivity(
report,
developerUserId,
FeedbackActivityTypes.TagsChanged,
beforeTags,
FormatTags(normalizedTags),
null,
now);
changed = true;
}
}
if (changed)
{
report.LastActivityAt = now;
await dbContext.SaveChangesAsync(ct);
}
await SendOkAsync(report.ToDto(), ct);
}
private void AddActivity(
FeedbackReport report,
Guid actorUserId,
string activityType,
string? fromValue,
string? toValue,
string? note,
DateTimeOffset now)
{
report.ActivityEntries.Add(new FeedbackActivityEntry
{
Id = Guid.NewGuid(),
FeedbackReportId = report.Id,
ActorUserId = actorUserId,
ActorDisplayName = User.GetAlias() ?? User.GetName(),
ActorEmail = User.GetEmail(),
ActivityType = activityType,
FromValue = fromValue,
ToValue = toValue,
Note = note,
CreatedAt = now,
});
}
private static bool ApplyTags(FeedbackReport report, IReadOnlyCollection<string> tags)
{
HashSet<string> requestedKeys = tags
.Select(FeedbackRules.NormalizeTagKey)
.ToHashSet(StringComparer.Ordinal);
bool changed = false;
foreach (FeedbackTag existingTag in report.Tags.ToArray())
{
if (!requestedKeys.Contains(existingTag.NormalizedName))
{
report.Tags.Remove(existingTag);
changed = true;
}
}
HashSet<string> existingKeys = report.Tags
.Select(tag => tag.NormalizedName)
.ToHashSet(StringComparer.Ordinal);
foreach (string tag in tags)
{
string key = FeedbackRules.NormalizeTagKey(tag);
if (existingKeys.Contains(key))
{
continue;
}
report.Tags.Add(new FeedbackTag
{
Id = Guid.NewGuid(),
FeedbackReportId = report.Id,
Name = tag,
NormalizedName = key,
});
changed = true;
}
return changed;
}
private static string FormatTags(IEnumerable<string> tags)
{
return string.Join(", ", tags.Order(StringComparer.OrdinalIgnoreCase));
}
}

View File

@@ -0,0 +1,31 @@
using Socialize.Api.Modules.Feedback.Data;
namespace Socialize.Api.Modules.Feedback.Services;
public static class FeedbackAccessRules
{
public static bool CanReporterAccess(FeedbackReport report, Guid userId)
{
return report.ReporterUserId == userId;
}
public static bool CanReporterCancel(FeedbackReport report, Guid userId)
{
return CanReporterAccess(report, userId) && FeedbackRules.CanReporterCancel(report.Status);
}
public static bool CanReporterComment(FeedbackReport report, Guid userId)
{
return CanReporterAccess(report, userId);
}
public static bool CanDeveloperComment(bool isDeveloper)
{
return isDeveloper;
}
public static bool CanAccessScreenshot(FeedbackReport report, Guid userId, bool isDeveloper)
{
return isDeveloper || CanReporterAccess(report, userId);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,9 +2,10 @@
public static class KnownRoles
{
public const string Administrator = nameof(Administrator);
public const string Manager = nameof(Manager);
public const string Client = nameof(Client);
public const string Provider = nameof(Provider);
public const string WorkspaceMember = nameof(WorkspaceMember);
public const string Administrator = "administrator";
public const string Manager = "manager";
public const string Client = "client";
public const string Provider = "provider";
public const string WorkspaceMember = "workspace-member";
public const string Developer = "developer";
}

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,12 @@ 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 string ApprovalMode { get; set; } = "Required";
public bool SchedulePostsAutomaticallyOnApproval { get; set; }
public bool LockContentAfterApproval { get; set; }
public bool SendAutomaticApprovalReminders { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,6 @@
namespace Socialize.Api.Modules.Workspaces.Data;
public static class WorkspaceInviteStatuses
{
public const string Pending = "Pending";
}

View File

@@ -12,7 +12,12 @@ 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.ApprovalMode).HasMaxLength(32).IsRequired().HasDefaultValue("Required");
workspace.Property(x => x.SchedulePostsAutomaticallyOnApproval).HasDefaultValue(false);
workspace.Property(x => x.LockContentAfterApproval).HasDefaultValue(false);
workspace.Property(x => x.SendAutomaticApprovalReminders).HasDefaultValue(false);
workspace.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");

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,7 +75,13 @@ public class CreateWorkspaceHandler(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
[],
workspace.CreatedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct);

View File

@@ -24,7 +24,7 @@ public class CreateWorkspaceInviteRequestValidator
public CreateWorkspaceInviteRequestValidator()
{
RuleFor(x => x.Email).NotEmpty().MaximumLength(256).EmailAddress();
RuleFor(x => x.Role).NotEmpty().Must(role => AllowedRoles.Contains(role));
RuleFor(x => x.Role).NotEmpty().Must(role => AllowedRoles.Contains(role)).WithMessage("A valid role should be specified");
}
}
@@ -65,7 +65,7 @@ public class CreateWorkspaceInviteHandler(
bool duplicateInvite = await dbContext.WorkspaceInvites.AnyAsync(
invite => invite.WorkspaceId == workspaceId &&
invite.Email == normalizedEmail &&
invite.Status == "Pending",
invite.Status == WorkspaceInviteStatuses.Pending,
ct);
if (duplicateInvite)
@@ -81,7 +81,7 @@ public class CreateWorkspaceInviteHandler(
WorkspaceId = workspaceId,
Email = normalizedEmail,
Role = normalizedRole,
Status = "Pending",
Status = WorkspaceInviteStatuses.Pending,
InvitedByUserId = User.GetUserId(),
CreatedAt = DateTimeOffset.UtcNow,
};

View File

@@ -2,18 +2,35 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
public record ApprovalStepConfigurationDto(
Guid Id,
Guid WorkspaceId,
string Name,
int SortOrder,
string TargetType,
string TargetValue,
int RequiredApproverCount,
DateTimeOffset CreatedAt);
public record WorkspaceDto(
Guid Id,
string Name,
string Slug,
string? LogoUrl,
string TimeZone,
string ApprovalMode,
bool SchedulePostsAutomaticallyOnApproval,
bool LockContentAfterApproval,
bool SendAutomaticApprovalReminders,
IReadOnlyCollection<ApprovalStepConfigurationDto> ApprovalSteps,
DateTimeOffset CreatedAt);
public class GetWorkspacesHandler(
internal class GetWorkspacesHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest<IReadOnlyCollection<WorkspaceDto>>
@@ -26,24 +43,61 @@ 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 workspaceRows = await query
.OrderBy(workspace => workspace.Name)
.ToListAsync(ct);
var workspaceIds = workspaceRows.Select(workspace => workspace.Id).ToList();
List<WorkspaceApprovalStepConfiguration> approvalStepRows = await dbContext.WorkspaceApprovalStepConfigurations
.Where(step => workspaceIds.Contains(step.WorkspaceId))
.OrderBy(step => step.SortOrder)
.ThenBy(step => step.Name)
.ToListAsync(ct);
var approvalStepsByWorkspaceId = approvalStepRows
.GroupBy(step => step.WorkspaceId)
.ToDictionary(
group => group.Key,
group => (IReadOnlyCollection<ApprovalStepConfigurationDto>)group
.Select(ToApprovalStepConfigurationDto)
.ToArray());
var workspaces = workspaceRows
.Select(workspace => new WorkspaceDto(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
approvalStepsByWorkspaceId.GetValueOrDefault(workspace.Id) ?? Array.Empty<ApprovalStepConfigurationDto>(),
workspace.CreatedAt))
.ToListAsync(ct);
.ToList();
await SendOkAsync(workspaces, ct);
}
public static ApprovalStepConfigurationDto ToApprovalStepConfigurationDto(WorkspaceApprovalStepConfiguration step)
{
return new ApprovalStepConfigurationDto(
step.Id,
step.WorkspaceId,
step.Name,
step.SortOrder,
step.TargetType,
step.TargetValue,
step.RequiredApproverCount,
step.CreatedAt);
}
}

View File

@@ -0,0 +1,240 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
public record UpdateApprovalStepConfigurationRequest(
string Name,
int SortOrder,
string TargetType,
string TargetValue,
int RequiredApproverCount);
public record UpdateWorkspaceRequest(
string Name,
string TimeZone,
string? ApprovalMode,
bool? SchedulePostsAutomaticallyOnApproval,
bool? LockContentAfterApproval,
bool? SendAutomaticApprovalReminders,
IReadOnlyCollection<UpdateApprovalStepConfigurationRequest>? ApprovalSteps);
public class UpdateWorkspaceRequestValidator
: Validator<UpdateWorkspaceRequest>
{
private static readonly string[] AllowedApprovalModes = ["None", "Optional", "Required", "Multi-level"];
public UpdateWorkspaceRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
RuleFor(x => x.ApprovalMode)
.Must(mode => string.IsNullOrWhiteSpace(mode) || AllowedApprovalModes.Contains(mode.Trim()))
.WithMessage("A valid approval mode should be specified.");
RuleFor(x => x.ApprovalSteps)
.Must(steps => steps is null || steps.Select(step => step.SortOrder).Distinct().Count() == steps.Count)
.WithMessage("Approval step sort orders must be unique.");
RuleForEach(x => x.ApprovalSteps).ChildRules(step =>
{
step.RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
step.RuleFor(x => x.TargetType)
.Must(ApprovalStepConfigurationRules.IsValidTargetType)
.WithMessage("A valid approval step target type should be specified.");
step.RuleFor(x => x.TargetValue).NotEmpty().MaximumLength(128);
step.RuleFor(x => x.RequiredApproverCount).GreaterThanOrEqualTo(1);
});
}
}
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;
}
string nextApprovalMode = string.IsNullOrWhiteSpace(request.ApprovalMode)
? workspace.ApprovalMode
: request.ApprovalMode.Trim();
List<UpdateApprovalStepConfigurationRequest>? requestedApprovalSteps = request.ApprovalSteps?.ToList();
if (nextApprovalMode == ApprovalModes.MultiLevel)
{
bool hasConfiguredSteps = requestedApprovalSteps is null
? await dbContext.WorkspaceApprovalStepConfigurations.AnyAsync(step => step.WorkspaceId == workspace.Id, ct)
: requestedApprovalSteps.Count > 0;
if (!hasConfiguredSteps)
{
AddError(request => request.ApprovalSteps, "Multi-level approval requires at least one approval step.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
}
if (requestedApprovalSteps is not null &&
!await ValidateApprovalStepsAsync(workspace.Id, requestedApprovalSteps, ct))
{
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
workspace.Name = request.Name.Trim();
workspace.TimeZone = request.TimeZone.Trim();
workspace.ApprovalMode = nextApprovalMode;
workspace.SchedulePostsAutomaticallyOnApproval = request.SchedulePostsAutomaticallyOnApproval ?? workspace.SchedulePostsAutomaticallyOnApproval;
workspace.LockContentAfterApproval = request.LockContentAfterApproval ?? workspace.LockContentAfterApproval;
workspace.SendAutomaticApprovalReminders = request.SendAutomaticApprovalReminders ?? workspace.SendAutomaticApprovalReminders;
if (requestedApprovalSteps is not null)
{
List<WorkspaceApprovalStepConfiguration> existingSteps = await dbContext.WorkspaceApprovalStepConfigurations
.Where(step => step.WorkspaceId == workspace.Id)
.ToListAsync(ct);
dbContext.WorkspaceApprovalStepConfigurations.RemoveRange(existingSteps);
List<WorkspaceApprovalStepConfiguration> replacementSteps = requestedApprovalSteps
.OrderBy(step => step.SortOrder)
.Select(step => new WorkspaceApprovalStepConfiguration
{
Id = Guid.NewGuid(),
WorkspaceId = workspace.Id,
Name = step.Name.Trim(),
SortOrder = step.SortOrder,
TargetType = step.TargetType.Trim(),
TargetValue = NormalizeTargetValue(step),
RequiredApproverCount = step.RequiredApproverCount,
CreatedAt = DateTimeOffset.UtcNow,
})
.ToList();
dbContext.WorkspaceApprovalStepConfigurations.AddRange(replacementSteps);
}
await dbContext.SaveChangesAsync(ct);
List<ApprovalStepConfigurationDto> approvalSteps = await dbContext.WorkspaceApprovalStepConfigurations
.Where(step => step.WorkspaceId == workspace.Id)
.OrderBy(step => step.SortOrder)
.ThenBy(step => step.Name)
.Select(step => new ApprovalStepConfigurationDto(
step.Id,
step.WorkspaceId,
step.Name,
step.SortOrder,
step.TargetType,
step.TargetValue,
step.RequiredApproverCount,
step.CreatedAt))
.ToListAsync(ct);
WorkspaceDto dto = new(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
approvalSteps,
workspace.CreatedAt);
await SendOkAsync(dto, ct);
}
private async Task<bool> ValidateApprovalStepsAsync(
Guid workspaceId,
IReadOnlyCollection<UpdateApprovalStepConfigurationRequest> steps,
CancellationToken ct)
{
foreach (UpdateApprovalStepConfigurationRequest step in steps)
{
string targetType = step.TargetType.Trim();
string targetValue = step.TargetValue.Trim();
if (targetType == ApprovalStepTargetTypes.Role &&
!ApprovalStepConfigurationRules.IsValidRoleTarget(targetValue))
{
AddError(request => request.ApprovalSteps, $"'{targetValue}' is not a supported approval role target.");
return false;
}
if (targetType == ApprovalStepTargetTypes.Membership &&
!ApprovalStepConfigurationRules.IsValidMembershipTarget(targetValue))
{
AddError(request => request.ApprovalSteps, $"'{targetValue}' is not a supported approval membership target.");
return false;
}
if (targetType == ApprovalStepTargetTypes.Member)
{
IReadOnlyCollection<Guid> memberUserIds = ApprovalWorkflowRules.ParseMemberTargetIds(targetValue);
if (memberUserIds.Count == 0)
{
AddError(request => request.ApprovalSteps, "Member approval step targets must reference at least one valid user id.");
return false;
}
if (memberUserIds.Count < step.RequiredApproverCount)
{
AddError(request => request.ApprovalSteps, "Member approval step targets must include at least as many members as required approvers.");
return false;
}
string workspaceClaimValue = workspaceId.ToString();
int workspaceMemberCount = await dbContext.UserClaims
.Where(claim => memberUserIds.Contains(claim.UserId) &&
claim.ClaimType == KnownClaims.WorkspaceScope &&
claim.ClaimValue == workspaceClaimValue)
.Select(claim => claim.UserId)
.Distinct()
.CountAsync(ct);
if (workspaceMemberCount != memberUserIds.Count)
{
AddError(request => request.ApprovalSteps, "Member approval step targets must reference users with access to the workspace.");
return false;
}
}
}
return true;
}
private static string NormalizeTargetValue(UpdateApprovalStepConfigurationRequest step)
{
string targetValue = step.TargetValue.Trim();
return step.TargetType.Trim() == ApprovalStepTargetTypes.Member
? ApprovalWorkflowRules.FormatMemberTargetValue(ApprovalWorkflowRules.ParseMemberTargetIds(targetValue))
: targetValue;
}
}

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,350 @@
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
namespace Socialize.Tests.Approvals;
public class ApprovalWorkflowRulesTests
{
[Theory]
[InlineData(ApprovalModes.Optional, true)]
[InlineData(ApprovalModes.Required, true)]
[InlineData(ApprovalModes.None, false)]
[InlineData(ApprovalModes.MultiLevel, false)]
public void CanCreateSingleStepApprovalRequest_matches_basic_modes(string approvalMode, bool expected)
{
bool actual = ApprovalWorkflowRules.CanCreateSingleStepApprovalRequest(approvalMode);
Assert.Equal(expected, actual);
}
[Theory]
[InlineData(ApprovalModes.Required, true)]
[InlineData(ApprovalModes.MultiLevel, true)]
[InlineData(ApprovalModes.Optional, false)]
[InlineData(ApprovalModes.None, false)]
public void BlocksManualApprovedOrScheduledStatus_matches_blocking_modes(string approvalMode, bool expected)
{
bool actual = ApprovalWorkflowRules.BlocksManualApprovedOrScheduledStatus(approvalMode);
Assert.Equal(expected, actual);
}
[Theory]
[InlineData("Approved", true)]
[InlineData("Scheduled", true)]
[InlineData("In approval", false)]
[InlineData("Published", false)]
public void IsApprovalCompletionStatus_only_matches_approval_gate_destinations(string status, bool expected)
{
bool actual = ApprovalWorkflowRules.IsApprovalCompletionStatus(status);
Assert.Equal(expected, actual);
}
[Fact]
public void GetFinalApprovalStatus_schedules_when_option_enabled_and_publish_date_exists()
{
string status = ApprovalWorkflowRules.GetFinalApprovalStatus(
schedulePostsAutomaticallyOnApproval: true,
plannedPublishDate: DateTimeOffset.UtcNow.AddDays(1));
Assert.Equal("Scheduled", status);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void GetFinalApprovalStatus_approves_when_auto_schedule_disabled_or_date_missing(bool scheduleAutomatically)
{
string status = ApprovalWorkflowRules.GetFinalApprovalStatus(
scheduleAutomatically,
plannedPublishDate: null);
Assert.Equal("Approved", status);
}
[Theory]
[InlineData(1, 1, true)]
[InlineData(1, 2, false)]
[InlineData(2, 2, true)]
[InlineData(1, 0, true)]
public void HasRequiredStepApprovals_enforces_configured_approver_count(
int approvedDecisionCount,
int requiredApproverCount,
bool expected)
{
bool actual = ApprovalWorkflowRules.HasRequiredStepApprovals(
approvedDecisionCount,
requiredApproverCount);
Assert.Equal(expected, actual);
}
[Fact]
public void CanApproveWorkflowStep_allows_admin_for_any_step_target()
{
bool actual = ApprovalWorkflowRules.CanApproveWorkflowStep(
isAdministrator: true,
hasWorkspaceAccess: false,
userRoles: [],
userId: Guid.NewGuid(),
targetType: ApprovalStepTargetTypes.Member,
targetValue: Guid.NewGuid().ToString());
Assert.True(actual);
}
[Fact]
public void CanApproveWorkflowStep_requires_role_target_match()
{
bool actual = ApprovalWorkflowRules.CanApproveWorkflowStep(
isAdministrator: false,
hasWorkspaceAccess: true,
userRoles: ["manager"],
userId: Guid.NewGuid(),
targetType: ApprovalStepTargetTypes.Role,
targetValue: "manager");
Assert.True(actual);
}
[Fact]
public void CanApproveWorkflowStep_rejects_later_step_actor_without_target_match()
{
bool actual = ApprovalWorkflowRules.CanApproveWorkflowStep(
isAdministrator: false,
hasWorkspaceAccess: true,
userRoles: ["provider"],
userId: Guid.NewGuid(),
targetType: ApprovalStepTargetTypes.Role,
targetValue: "client");
Assert.False(actual);
}
[Fact]
public void CanApproveWorkflowStep_requires_member_target_identity_match()
{
Guid assignedMemberId = Guid.NewGuid();
Guid secondAssignedMemberId = Guid.NewGuid();
bool matchingMember = ApprovalWorkflowRules.CanApproveWorkflowStep(
isAdministrator: false,
hasWorkspaceAccess: true,
userRoles: ["workspace-member"],
userId: assignedMemberId,
targetType: ApprovalStepTargetTypes.Member,
targetValue: $"{assignedMemberId},{secondAssignedMemberId}");
bool otherMember = ApprovalWorkflowRules.CanApproveWorkflowStep(
isAdministrator: false,
hasWorkspaceAccess: true,
userRoles: ["workspace-member"],
userId: Guid.NewGuid(),
targetType: ApprovalStepTargetTypes.Member,
targetValue: $"{assignedMemberId},{secondAssignedMemberId}");
Assert.True(matchingMember);
Assert.False(otherMember);
}
[Fact]
public void ParseMemberTargetIds_reads_distinct_comma_separated_member_ids()
{
Guid firstMemberId = Guid.NewGuid();
Guid secondMemberId = Guid.NewGuid();
IReadOnlyCollection<Guid> memberIds = ApprovalWorkflowRules.ParseMemberTargetIds(
$" {firstMemberId},not-a-guid,{secondMemberId},{firstMemberId} ");
Assert.Equal([firstMemberId, secondMemberId], memberIds);
}
[Fact]
public void FormatMemberTargetValue_stores_member_ids_stably()
{
Guid firstMemberId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
Guid secondMemberId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
string targetValue = ApprovalWorkflowRules.FormatMemberTargetValue(
[
secondMemberId,
firstMemberId,
secondMemberId,
]);
Assert.Equal($"{firstMemberId},{secondMemberId}", targetValue);
}
[Fact]
public void CanApproveWorkflowStep_matches_membership_targets()
{
bool clientMatchesClient = ApprovalWorkflowRules.CanApproveWorkflowStep(
isAdministrator: false,
hasWorkspaceAccess: true,
userRoles: ["client"],
userId: Guid.NewGuid(),
targetType: ApprovalStepTargetTypes.Membership,
targetValue: ApprovalMembershipTargets.Client);
bool providerMatchesTeam = ApprovalWorkflowRules.CanApproveWorkflowStep(
isAdministrator: false,
hasWorkspaceAccess: true,
userRoles: ["provider"],
userId: Guid.NewGuid(),
targetType: ApprovalStepTargetTypes.Membership,
targetValue: ApprovalMembershipTargets.Team);
bool clientDoesNotMatchTeam = ApprovalWorkflowRules.CanApproveWorkflowStep(
isAdministrator: false,
hasWorkspaceAccess: true,
userRoles: ["client"],
userId: Guid.NewGuid(),
targetType: ApprovalStepTargetTypes.Membership,
targetValue: ApprovalMembershipTargets.Team);
Assert.True(clientMatchesClient);
Assert.True(providerMatchesTeam);
Assert.False(clientDoesNotMatchTeam);
}
[Theory]
[InlineData(ApprovalStepTargetTypes.Role)]
[InlineData(ApprovalStepTargetTypes.Membership)]
[InlineData(ApprovalStepTargetTypes.Member)]
public void IsValidTargetType_accepts_supported_target_types(string targetType)
{
bool valid = ApprovalStepConfigurationRules.IsValidTargetType(targetType);
Assert.True(valid);
}
[Theory]
[InlineData("")]
[InlineData("Group")]
[InlineData("role")]
public void IsValidTargetType_rejects_unsupported_target_types(string targetType)
{
bool valid = ApprovalStepConfigurationRules.IsValidTargetType(targetType);
Assert.False(valid);
}
[Theory]
[InlineData("administrator")]
[InlineData("manager")]
[InlineData("workspace-member")]
[InlineData("client")]
[InlineData("provider")]
public void IsValidRoleTarget_accepts_known_workspace_roles(string role)
{
bool valid = ApprovalStepConfigurationRules.IsValidRoleTarget(role);
Assert.True(valid);
}
[Theory]
[InlineData("")]
[InlineData("developer")]
[InlineData("owner")]
public void IsValidRoleTarget_rejects_non_workspace_approval_roles(string role)
{
bool valid = ApprovalStepConfigurationRules.IsValidRoleTarget(role);
Assert.False(valid);
}
[Theory]
[InlineData(ApprovalMembershipTargets.Team)]
[InlineData(ApprovalMembershipTargets.Client)]
public void IsValidMembershipTarget_accepts_supported_memberships(string membership)
{
bool valid = ApprovalStepConfigurationRules.IsValidMembershipTarget(membership);
Assert.True(valid);
}
[Theory]
[InlineData("")]
[InlineData("Provider")]
[InlineData("External")]
public void IsValidMembershipTarget_rejects_unsupported_memberships(string membership)
{
bool valid = ApprovalStepConfigurationRules.IsValidMembershipTarget(membership);
Assert.False(valid);
}
[Fact]
public void WorkspaceApprovalStepConfiguration_model_persists_workspace_ordering()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql("Host=localhost;Database=socialize_model_test")
.Options;
using var dbContext = new AppDbContext(options);
var entity = dbContext.Model.FindEntityType(typeof(WorkspaceApprovalStepConfiguration));
Assert.NotNull(entity);
Assert.Equal("WorkspaceApprovalStepConfigurations", entity.GetTableName());
Assert.Equal(128, entity.FindProperty(nameof(WorkspaceApprovalStepConfiguration.Name))?.GetMaxLength());
Assert.Equal(32, entity.FindProperty(nameof(WorkspaceApprovalStepConfiguration.TargetType))?.GetMaxLength());
Assert.Equal(128, entity.FindProperty(nameof(WorkspaceApprovalStepConfiguration.TargetValue))?.GetMaxLength());
Assert.Contains(
entity.GetIndexes(),
index => index.IsUnique &&
index.Properties.Select(property => property.Name).SequenceEqual(
[
nameof(WorkspaceApprovalStepConfiguration.WorkspaceId),
nameof(WorkspaceApprovalStepConfiguration.SortOrder),
]));
}
[Fact]
public void ApprovalWorkflowInstance_model_allows_only_one_pending_workflow_per_content_item()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql("Host=localhost;Database=socialize_model_test")
.Options;
using var dbContext = new AppDbContext(options);
var entity = dbContext.Model.FindEntityType(typeof(ApprovalWorkflowInstance));
Assert.NotNull(entity);
Assert.Equal("ApprovalWorkflowInstances", entity.GetTableName());
Assert.Equal(64, entity.FindProperty(nameof(ApprovalWorkflowInstance.State))?.GetMaxLength());
Assert.Equal(64, entity.FindProperty(nameof(ApprovalWorkflowInstance.ApprovalMode))?.GetMaxLength());
Assert.Contains(
entity.GetIndexes(),
index => index.IsUnique &&
index.GetFilter() == "\"State\" = 'Pending'" &&
index.Properties.Select(property => property.Name).SequenceEqual(
[
nameof(ApprovalWorkflowInstance.ContentItemId),
nameof(ApprovalWorkflowInstance.State),
]));
}
[Fact]
public void ApprovalRequest_model_persists_runtime_step_metadata()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql("Host=localhost;Database=socialize_model_test")
.Options;
using var dbContext = new AppDbContext(options);
var entity = dbContext.Model.FindEntityType(typeof(ApprovalRequest));
Assert.NotNull(entity);
Assert.Equal(32, entity.FindProperty(nameof(ApprovalRequest.WorkflowStepTargetType))?.GetMaxLength());
Assert.Equal(128, entity.FindProperty(nameof(ApprovalRequest.WorkflowStepTargetValue))?.GetMaxLength());
Assert.Contains(
entity.GetIndexes(),
index => index.Properties.Select(property => property.Name).SequenceEqual(
[
nameof(ApprovalRequest.WorkflowInstanceId),
]));
}
}

View File

@@ -0,0 +1,274 @@
using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Feedback.Contracts;
using Socialize.Api.Modules.Feedback.Services;
using System.Text.Json;
namespace Socialize.Tests.Feedback;
public class FeedbackRulesTests
{
[Theory]
[InlineData("Bug", FeedbackType.Bug)]
[InlineData("suggestion", FeedbackType.Suggestion)]
[InlineData("Request", FeedbackType.Request)]
public void TryParseType_accepts_supported_types(string value, FeedbackType expected)
{
bool parsed = FeedbackRules.TryParseType(value, out FeedbackType type);
Assert.True(parsed);
Assert.Equal(expected, type);
}
[Theory]
[InlineData("")]
[InlineData("Question")]
[InlineData("Incident")]
public void TryParseType_rejects_unsupported_types(string value)
{
bool parsed = FeedbackRules.TryParseType(value, out _);
Assert.False(parsed);
}
[Theory]
[InlineData("New", FeedbackStatus.New)]
[InlineData("Planned", FeedbackStatus.Planned)]
[InlineData("Resolved", FeedbackStatus.Resolved)]
[InlineData("Won't Do", FeedbackStatus.WontDo)]
[InlineData("WontDo", FeedbackStatus.WontDo)]
[InlineData("Cancelled", FeedbackStatus.Cancelled)]
public void TryParseStatus_accepts_supported_statuses(string value, FeedbackStatus expected)
{
bool parsed = FeedbackRules.TryParseStatus(value, out FeedbackStatus status);
Assert.True(parsed);
Assert.Equal(expected, status);
}
[Fact]
public void CanDeveloperSetStatus_rejects_cancelled_destination()
{
bool allowed = FeedbackRules.CanDeveloperSetStatus(FeedbackStatus.New, FeedbackStatus.Cancelled);
Assert.False(allowed);
}
[Fact]
public void CanDeveloperSetStatus_rejects_changes_after_cancelled()
{
bool allowed = FeedbackRules.CanDeveloperSetStatus(FeedbackStatus.Cancelled, FeedbackStatus.Planned);
Assert.False(allowed);
}
[Fact]
public void CanReporterCancel_rejects_cancelled_report()
{
bool allowed = FeedbackRules.CanReporterCancel(FeedbackStatus.Cancelled);
Assert.False(allowed);
}
[Fact]
public void CanReporterAccess_allows_report_owner()
{
Guid reporterUserId = Guid.NewGuid();
FeedbackReport report = new() { ReporterUserId = reporterUserId };
bool allowed = FeedbackAccessRules.CanReporterAccess(report, reporterUserId);
Assert.True(allowed);
}
[Fact]
public void CanReporterAccess_rejects_other_users()
{
FeedbackReport report = new() { ReporterUserId = Guid.NewGuid() };
bool allowed = FeedbackAccessRules.CanReporterAccess(report, Guid.NewGuid());
Assert.False(allowed);
}
[Fact]
public void CanReporterCancel_requires_owner_and_non_final_status()
{
Guid reporterUserId = Guid.NewGuid();
FeedbackReport report = new()
{
ReporterUserId = reporterUserId,
Status = FeedbackStatus.New,
};
bool ownerAllowed = FeedbackAccessRules.CanReporterCancel(report, reporterUserId);
bool otherUserAllowed = FeedbackAccessRules.CanReporterCancel(report, Guid.NewGuid());
Assert.True(ownerAllowed);
Assert.False(otherUserAllowed);
}
[Fact]
public void CanReporterComment_requires_report_owner()
{
Guid reporterUserId = Guid.NewGuid();
FeedbackReport report = new() { ReporterUserId = reporterUserId };
bool ownerAllowed = FeedbackAccessRules.CanReporterComment(report, reporterUserId);
bool otherUserAllowed = FeedbackAccessRules.CanReporterComment(report, Guid.NewGuid());
Assert.True(ownerAllowed);
Assert.False(otherUserAllowed);
}
[Fact]
public void CanDeveloperComment_requires_developer_role()
{
Assert.True(FeedbackAccessRules.CanDeveloperComment(isDeveloper: true));
Assert.False(FeedbackAccessRules.CanDeveloperComment(isDeveloper: false));
}
[Fact]
public void CanAccessScreenshot_allows_report_owner()
{
Guid reporterUserId = Guid.NewGuid();
FeedbackReport report = new() { ReporterUserId = reporterUserId };
bool allowed = FeedbackAccessRules.CanAccessScreenshot(report, reporterUserId, isDeveloper: false);
Assert.True(allowed);
}
[Fact]
public void CanAccessScreenshot_allows_developer()
{
FeedbackReport report = new() { ReporterUserId = Guid.NewGuid() };
bool allowed = FeedbackAccessRules.CanAccessScreenshot(report, Guid.NewGuid(), isDeveloper: true);
Assert.True(allowed);
}
[Fact]
public void CanAccessScreenshot_rejects_unrelated_non_developer()
{
FeedbackReport report = new() { ReporterUserId = Guid.NewGuid() };
bool allowed = FeedbackAccessRules.CanAccessScreenshot(report, Guid.NewGuid(), isDeveloper: false);
Assert.False(allowed);
}
[Theory]
[InlineData("image/png")]
[InlineData("image/jpeg")]
[InlineData("image/jpg")]
public void Screenshot_content_type_allows_png_and_jpeg(string contentType)
{
bool allowed = FeedbackScreenshotRules.IsAllowedContentType(contentType);
Assert.True(allowed);
}
[Theory]
[InlineData("text/html")]
[InlineData("application/pdf")]
[InlineData("")]
public void Screenshot_content_type_rejects_non_images(string contentType)
{
bool allowed = FeedbackScreenshotRules.IsAllowedContentType(contentType);
Assert.False(allowed);
}
[Theory]
[InlineData(1)]
[InlineData(FeedbackScreenshotRules.MaxScreenshotBytes)]
public void Screenshot_size_allows_non_empty_files_up_to_limit(long sizeBytes)
{
bool allowed = FeedbackScreenshotRules.IsAllowedSize(sizeBytes);
Assert.True(allowed);
}
[Theory]
[InlineData(0)]
[InlineData(FeedbackScreenshotRules.MaxScreenshotBytes + 1)]
public void Screenshot_size_rejects_empty_and_oversized_files(long sizeBytes)
{
bool allowed = FeedbackScreenshotRules.IsAllowedSize(sizeBytes);
Assert.False(allowed);
}
[Fact]
public void NormalizeTags_trims_deduplicates_and_orders()
{
IReadOnlyCollection<string> tags = FeedbackRules.NormalizeTags([" mobile ", "bug", "Mobile", ""]);
Assert.Equal(["bug", "mobile"], tags);
}
[Fact]
public void Feedback_report_dto_returns_mixed_timeline_in_created_order()
{
Guid reportId = Guid.NewGuid();
DateTimeOffset now = DateTimeOffset.UtcNow;
FeedbackReport report = new()
{
Id = reportId,
Type = FeedbackType.Bug,
Status = FeedbackStatus.New,
Description = "Broken layout",
ReporterUserId = Guid.NewGuid(),
ReporterDisplayName = "Reporter",
ReporterEmail = "reporter@example.com",
SubmittedPath = "/app/example",
CreatedAt = now,
LastActivityAt = now.AddMinutes(2),
};
report.ActivityEntries.Add(new FeedbackActivityEntry
{
Id = Guid.NewGuid(),
FeedbackReportId = reportId,
ActorUserId = Guid.NewGuid(),
ActorDisplayName = "Developer",
ActorEmail = "dev@example.com",
ActivityType = FeedbackActivityTypes.StatusChanged,
FromValue = "New",
ToValue = "Planned",
CreatedAt = now.AddMinutes(2),
});
report.Comments.Add(new FeedbackComment
{
Id = Guid.NewGuid(),
FeedbackReportId = reportId,
AuthorUserId = report.ReporterUserId,
AuthorDisplayName = "Reporter",
AuthorEmail = "reporter@example.com",
AuthorRole = "Reporter",
Body = "More context",
CreatedAt = now.AddMinutes(1),
});
FeedbackReportDto dto = report.ToDto();
Assert.Equal(["Comment", "Activity"], dto.Timeline.Select(item => item.Kind).ToArray());
Assert.Equal("More context", dto.Timeline.First().Body);
Assert.Equal(FeedbackActivityTypes.StatusChanged, dto.Timeline.Last().ActivityType);
}
[Fact]
public void Feedback_notification_metadata_includes_target_route()
{
Guid reportId = Guid.NewGuid();
string developerMetadata = FeedbackNotificationRoutes.BuildMetadataJson(reportId, developerRoute: true);
string reporterMetadata = FeedbackNotificationRoutes.BuildMetadataJson(reportId, developerRoute: false);
using JsonDocument developerDocument = JsonDocument.Parse(developerMetadata);
using JsonDocument reporterDocument = JsonDocument.Parse(reporterMetadata);
Assert.Equal($"/app/feedback/{reportId}", developerDocument.RootElement.GetProperty("route").GetString());
Assert.Equal($"/app/my-feedback/{reportId}", reporterDocument.RootElement.GetProperty("route").GetString());
Assert.True(developerDocument.RootElement.GetProperty("isFeedbackNotification").GetBoolean());
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,245 @@
# Approval Workflow
## Status
Draft
## Goal
Define how a workspace decides whether a `ContentItem` is approved.
Approval workflow is one part of the normal `ContentItem` lifecycle:
```txt
Production -> Approval -> Publication
```
Approval workflow is separate from production workflow. Production covers how content is planned, created, revised, and prepared before approval. Publication may later become its own workflow for scheduling, publishing, and post-publish corrections.
## User Stories
- As a workspace owner, I want to configure the approval mode for my brand workspace so that approval fits the way my organization works.
- As a manager, I want to require approval before content can be scheduled or published so that unapproved content does not go out.
- As a manager, I want optional approval for simpler workflows so that teams can collect sign-off without blocking publication.
- As a manager, I want multi-level approval steps so that different people or groups can approve content in sequence.
- As an approver, I want to review the content, discuss changes, and approve when ready so that the approval decision is clear.
- As a collaborator, I want approval discussions with mentions so that I can involve the right people without changing the approval state.
- As an internal team member, I want team-only comments so that internal discussion can happen without exposing it to client users.
- As a publisher, I want to be notified when final approval is complete so that I can schedule or publish the content.
- As a workspace owner or admin, I want to reopen approval after changes so that approval history remains intact while edge cases can still be handled.
- As an external approver, I want to review and approve a specific content item through a secure magic link so that I do not need a full workspace account.
## Reviewable Object
The object being approved is a `ContentItem`.
Approval decisions, comments, mentions, notifications, timestamps, audit history, and approved content snapshots must remain traceable to the `ContentItem`.
A `ContentItem` can have only one active approval workflow instance at a time.
When approval completes, the approval workflow instance remains attached to the `ContentItem` as history. If an admin reopens approval later, a special approval step is appended to the same history instead of deleting prior approval history or restarting the workflow.
Approval workflow history is visible to all users who can view the `ContentItem`. Approval actions remain permission-gated.
## Lifecycle States
`ContentItem` uses a fixed set of lifecycle states:
- `Draft`
- `In production`
- `In approval`
- `Approved`
- `Scheduled`
- `Published`
Lifecycle state meanings:
- `Draft`: the content item exists after a client or team orders, requests, or creates the publication.
- `In production`: the content item is in production workflow. This applies when authored media or preparation work is required, such as video, photos, copywriting, design, or other production tasks.
- `In approval`: the content item is in approval workflow.
- `Approved`: the approval workflow is complete.
- `Scheduled`: the content item is on the calendar and ready to be published at its scheduled date and time.
- `Published`: the content item was successfully published to all intended channels.
Production workflow is optional for a `ContentItem`. Simple content can move from `Draft` directly to `In approval` when no full production workflow is needed.
## Workspace Configuration
Approval behavior is configured per workspace.
A workspace is the brand boundary. The approval workflow configuration is workspace-wide in v1.
Configuration changes apply to new content and to existing content currently in approval workflow. Content that is already `Approved`, `Scheduled`, or `Published` is not recalculated.
When configuration changes affect existing content in approval:
- previous approval decisions are preserved in history
- current required approval steps are recalculated from the new configuration
- if preserved approvals already satisfy the new requirements, the workflow completes and the `ContentItem` becomes `Approved`
- if recalculation creates a new pending current step, that step's approvers are notified immediately
## Approval Modes
Each workspace supports these approval modes in v1:
- `None`: no approval workflow is used. Content skips only the approval workflow and can become `Approved` without approval actions. This does not bypass production workflow when production work is required.
- `Optional`: a basic one-step approval workflow is created automatically. Approval actions are available, but approval is not required before publication workflow.
- `Required`: a basic one-step approval workflow is created automatically. At least one approval is required from any workspace member with approve content permission before the item can become `Approved` or `Scheduled`.
- `Multi-level`: approval is split into one or more ordered steps, and each step defines who can approve that step.
`Optional` and `Required` use one approval step. More flexible approver targeting only applies to `Multi-level`. If a workspace needs custom approver targeting, it should use `Multi-level` with one or more steps.
## Approval Steps
Approval steps have their own status:
- `Pending`
- `Approved`
`Multi-level` is step-based and sequential. Later steps cannot be approved until earlier steps are complete.
Each multi-level step can target:
- `Role`: a named workspace role with a set of permissions, such as Writer, Contributor, or Approver.
- `Membership`: a workspace membership category, such as Team or Client.
- `Member`: one specific workspace user.
For `Role` and `Membership` targets, the user must match the step target and have approve permission. Membership alone does not grant approval permission; for example, a Client member may be a contributor or an approver depending on their permissions.
For `Member` targets, the selected user can approve that step because they were explicitly assigned.
By default, one matching approver can complete a step. When a step targets a role or membership group, the workspace can optionally configure how many matching approvers are required for that step.
Admins can approve any approval step.
## Approval Decisions
Approval workflow supports one formal decision in v1:
- `Approve`
`Reject` and explicit `Request changes` decisions are not part of v1. Approvers should use comments, discussions, and mentions to coordinate changes until the current step can be approved.
Approval decisions must record:
- actor
- decision
- timestamp
- related `ContentItem`
- approval-controlled content version or snapshot that was approved
A pending step must not be treated as approved.
## Approval-Controlled Content
Approval-controlled content includes:
- caption, copy, and text
- attached media and assets
- selected channels and networks
- content title or name
- production output and revisions
Approval-controlled content excludes:
- scheduling date and time
- internal comments and discussions
If approval-controlled content is edited while the item is `In approval` or `Approved`, the approved status is removed from the current approval step and the step becomes pending again. If the item was `Approved`, it moves back to `In approval`.
When an edit invalidates approval, the current step's approvers are notified immediately.
If content is `Scheduled`, only an admin or workspace owner can change approval-controlled content. This scheduled-content edit policy needs validation with SaaS customers.
If content is `Published`, approval-controlled content is read-only except for admin or workspace owner corrections. Corrections to published content likely require an immediate force or push update through publication workflow.
## Approval Options
Each workspace can configure these approval options:
- schedule posts automatically on approval
- lock content after approval
- send automatic reminders for pending approvals
If schedule posts automatically on approval is enabled, final approval moves the `ContentItem` to `Scheduled` when it already has a planned publish date and time. If no planned publish date and time exists, the item remains `Approved`.
If lock content after approval is enabled, approval-controlled content is locked after final approval. Scheduling fields remain editable.
If automatic reminders for pending approvals is enabled, the current step's approvers are reminded daily while the step is pending. Daily reminders start after the original approval notification.
## Reopening Approval
Users with admin permission can reopen approved content for approval.
Reopening approval appends a special approval step to the existing workflow history. The special reopen step is approved by an admin. Admins can use comments and mentions to coordinate with the needed people before closing the approval workflow again.
## Discussions And Mentions
Approval comments and discussions are attached to the overall approval workflow instance, not to a specific step.
Any workspace user with access to the `ContentItem` can participate in approval discussions. Approval actions remain limited to the current step's approvers and admins.
Approval comments can be visible to everyone with access to the `ContentItem` or marked as team-only. Team-only comments are visible only to users with Team membership and are hidden from Client membership users.
Approval comments should support direct mentions, such as mentioning a person or group with `@`, to notify them directly.
Mentions must respect comment visibility. A comment cannot mention a user or group that cannot see that comment.
## Notifications
Notification rules:
- when a `ContentItem` enters `In approval`, the current approval step's approvers are notified immediately
- when an approval step is approved and another step remains, the next step's approvers are notified
- when the final approval step is approved, users with publish permission for the workflow are notified
- when configuration recalculation creates a new pending current step, that step's approvers are notified immediately
- when an edit invalidates approval, the current step's approvers are notified immediately
- when automatic reminders are enabled, the current step's approvers are reminded daily while the step is pending
- when a comment mentions a user or group, mentioned users are notified if they can see the comment
## Magic Approval Links
Approval workflow supports magic approval links for external approvers.
Magic links are intended for `Optional` and `Required` approval modes. They act on the current approval step when the link is opened.
A magic approval link:
- grants access only to the specific `ContentItem` for which the link was created
- does not grant broader workspace access
- allows the recipient to approve and comment on that approval workflow
- is created for a specific recipient email address
- is sent automatically by the app
- expires after 3 days
- expires immediately after it is used to approve
- is not consumed by commenting
- can be revoked by admins or workspace owners before it expires or is used to approve
Possession of a valid, unexpired magic approval link is sufficient to access the linked approval page. The recipient does not need to create an account.
For comment visibility, magic link recipients are treated as Client membership users. They cannot see or create team-only comments.
Magic link activity must record at least the recipient email for audit history.
Magic approval link events are recorded in approval workflow history, including created, sent, revoked, expired, and used events.
Magic approval links can be created or sent by users with publish permission, workspace owners, or admins.
## Business Rules
- `Approved` must come from explicit workflow rules, not optimistic UI state.
- Approval requirements are scoped to the workspace.
- A `ContentItem` has at most one active approval workflow instance.
- Approval mode `None` creates no approval workflow instance.
- Approval mode `Optional` creates a basic approval workflow instance but does not block publication workflow.
- Approval mode `Required` blocks approval and scheduling until its single approval step is approved.
- Approval mode `Multi-level` blocks approval and scheduling until every configured step is approved.
- Approval history must remain visible after revision, approval, scheduling, or publishing.
- Approval-controlled content changes must invalidate affected approval while the item is `In approval` or `Approved`.
## Open Questions
- Should publishing behavior become a separate `PublishingWorkflow` feature?
- What is the exact data model for approval step configuration?
- How should scheduled-content edit policy work after validation with SaaS customers?
- What publication-system behavior is required for correcting already published content?

View File

@@ -1,98 +0,0 @@
# Content Approval Workflow
## Status
Active
## Goal
Support the primary workflow from draft preparation through review, revision, approval decision, and readiness for publishing handoff.
## Actors
- Content contributor
- Provider
- Internal reviewer
- Manager
- Client approver
## Preconditions
- user is authenticated when acting as an internal team member
- work is scoped to a workspace
- content item exists inside a workspace context
## Trigger
A team member wants to send a content item through review and approval.
## Main Flow
1. A team member creates or updates a content item.
2. Assets are linked to the content item, including Google Drive references when appropriate.
3. The content item includes the relevant metadata:
- title
- publication message or caption
- networks
- channels
- due date
- notes
4. The item enters internal review or client review.
5. Reviewers leave comments and record decisions.
6. If changes are requested, the team links a new revision and continues the workflow.
7. Once required review is complete, the item can move to `Ready to publish`.
## Alternate Flows
- If a reviewer requests changes, the item should not be treated as approved.
- If the actor lacks required workspace access, the workflow action must be denied.
- If assets are missing, the item may still exist, but review readiness should remain explicit.
## Business Rules
- approvals and comments must remain attached to the content item context
- workflow state changes must be traceable
- revisions must not overwrite history invisibly
- “Ready to publish” must correspond to explicit workflow completion, not optimistic UI state
## Data / Entities
- Workspace
- ContentItem
- Asset
- AssetRevision
- CommentThread
- ApprovalRequest
- ApprovalDecision
- NotificationEvent
## API / UI Surface
### Frontend
- `/app/content`
- `/app/content/:id`
- `/app/reviews`
### Backend
- content item handlers
- asset linkage / revision handlers
- approval handlers
- comment handlers
- notification handlers
## Acceptance Criteria
- [ ] a content item can carry the metadata needed for review
- [ ] assets and revisions are visible in the item history
- [ ] reviewers can leave comments and decisions in one place
- [ ] the audit trail makes status transitions understandable
- [ ] the approved state is distinguishable from changes-requested and rejected states
- [ ] the workflow supports internal review before client approval
## Open Questions
- Should external review be account-based, magic-link-based, or both in version 1?
- Which approval states are mandatory before transition to `Ready to publish`?
- Should required approvers be modeled in version 1 or phase 2?

View File

@@ -1,60 +0,0 @@
# Feature: Agentic Platform Scaffold
## Status
In Progress
## Goal
Align Socialize with the structure generated by `bootstrap-vdp-agentic.sh` while preserving the current product implementation.
## Backend
The backend is located at:
```txt
backend/src/Socialize.Api
```
The solution is:
```txt
backend/Socialize.slnx
```
The test project is:
```txt
backend/tests/Socialize.Tests
```
## Frontend
The frontend remains the existing Vue 3 app. Feature-owned route views and stores live under `frontend/src/features/<feature>`, while shared app shell code stays under `frontend/src/layouts`, `frontend/src/components`, `frontend/src/plugins`, and `frontend/src/router`.
## API Contract
OpenAPI workflow:
```bash
./scripts/update-openapi.sh
```
Writes:
```txt
shared/openapi/openapi.json
frontend/src/api/schema.d.ts
```
## Done When
- [x] Backend code lives under `backend/src/Socialize.Api`
- [x] Backend solution exists at `backend/Socialize.slnx`
- [x] Test project exists under `backend/tests/Socialize.Tests`
- [x] Root scripts exist
- [x] Docker Compose and Caddy files exist
- [x] Agentic docs, specs, tasks, and prompts exist
- [ ] OpenAPI generation verified against a running backend
- [x] Backend build passes
- [x] Frontend build passes

View File

@@ -0,0 +1,257 @@
# Feature: Product Feedback
## Status
Draft
## Goal
Allow authenticated users to report bugs, suggestions, and requests from inside the app without interrupting their workflow, and give developers a lightweight place to review, discuss, and resolve that feedback.
This is product-level support data for the SaaS operator. It may capture workspace and page context for debugging, but it is not workspace-owned workflow data.
## User Stories
- As an authenticated user, I want to submit feedback from any app page so that I can report a bug, suggestion, or request when I notice it.
- As an authenticated user, I want to optionally capture and annotate the current app viewport so that I can explain visual issues clearly.
- As an authenticated user, I want a global My Feedback page so that I can track my submitted feedback across all workspaces.
- As a developer, I want a global feedback review page so that I can see all submitted product feedback.
- As a developer, I want to comment, update status/type, and add tags so that feedback can be reviewed without turning the app into a ticketing system.
- As a reporter, I want notifications when a developer comments or changes status so that I know when feedback needs my attention.
- As a developer, I want notifications for new reports and reporter replies so that feedback does not stall silently.
## Frontend Areas
- Global authenticated app shell floating Feedback button
- Feedback submission dialog
- Screenshot capture and annotation editor
- `/app/my-feedback`
- `/app/my-feedback/:id`
- `/app/feedback`
- `/app/feedback/:id`
- Existing notification bell
- `frontend/src/features/feedback/`
## Backend Modules
- Identity
- Notifications
- Feedback
- Infrastructure blob storage
## Access Rules
- Only authenticated users can submit feedback.
- Any authenticated user can submit feedback from any authenticated app page.
- A new `Developer` role can access the global developer feedback review pages and APIs.
- Developers can view every feedback report across the SaaS.
- Reporters can view only feedback they submitted.
- Feedback detail access is limited to the reporter and users with the `Developer` role.
- Feedback screenshot access must be authenticated and must follow the same reporter/developer access rules as the report.
- Feedback does not have public or shared links in v1.
## Submission Rules
- The global Feedback button appears on every authenticated app page.
- Submitting feedback is intentionally user-initiated and non-intrusive.
- Feedback type is required and must be one of:
- `Bug`
- `Suggestion`
- `Request`
- Description is required and plain text.
- Screenshot capture is optional.
- Users explicitly click `Capture screen`; opening feedback does not automatically capture the page.
- Capture is limited to the app viewport.
- If capture fails, the user can still submit text-only feedback.
- If a user closes a dirty feedback dialog, the app warns that unsent feedback will be discarded.
- Draft persistence is out of scope for v1.
- Reporters cannot edit or delete submitted feedback in v1.
- Reporters can add follow-up comments.
## Screenshot And Annotation Rules
- Screenshots are uploaded through the blob storage abstraction, not embedded in feedback database rows.
- Feedback screenshots should use a dedicated storage area or prefix.
- Feedback screenshot records store blob container/path metadata and expose a protected API download path, not a public blob URL.
- Annotated captures are exported as compressed image files.
- Backend upload size and content type validation must be enforced.
- The UI must show a friendly error when an image is too large or invalid.
- Annotation tools should support:
- crop
- arrows
- circles or ellipses
- lines
- freehand marks
- text labels
- undo
- clear/reset
- Frontend implementation may use established capture and annotation libraries rather than custom screenshot tooling.
- Developers can preview and download/open the annotated screenshot.
- Reporters can preview the annotated screenshot.
- If feedback deletion is added in the future, associated screenshot blobs must be deleted with the report.
- Feedback reports and screenshots are retained indefinitely until a future retention/deletion feature exists.
## Captured Metadata
Each report should capture useful debugging context automatically when available:
- reporter user id
- reporter name and email
- current app URL/path
- active workspace id/name
- active client id/name
- active project id/name
- active content item id/title
- browser user agent
- viewport size
- app version, if available
- created timestamp
## Status Model
Feedback status is deliberately lightweight:
- `New`
- `Planned`
- `Resolved`
- `Won't Do`
- `Cancelled`
Status rules:
- New reports start as `New`.
- Developers can move reports to `Planned`, `Resolved`, or `Won't Do`.
- Developers can change a report type.
- Developers can add, remove, and update free-form tags.
- Tags are visible to reporters.
- Tag entry should suggest previously used tags.
- Reporters can cancel their own report with an optional plain-text reason.
- `Cancelled` is final in v1.
- Reporters cannot reopen resolved or cancelled feedback; they can add comments where comments remain allowed.
- Developer reason/comment on `Won't Do` is optional.
- No severity, priority, assignment, duplicate linking, or Jira-style workflow is included in v1.
## Comments And Activity
- Feedback comments are visible to both the reporter and developers.
- Internal/private developer comments are out of scope for v1.
- Status/type/tag changes should be stored as activity history.
- Feedback detail should show a simple mixed timeline of comments and activity.
- Reporters can comment on their own feedback.
- Developers can comment on any feedback report.
## Notifications
- New feedback report: notify all users with the `Developer` role.
- Developer comment: notify the reporter.
- Developer status change: notify the reporter.
- Developer type/tag changes do not notify the reporter.
- Reporter comment: notify developers who have previously commented on that report.
- Feedback notifications use the existing in-app notification system.
- The existing notification bell should show feedback notifications and open the relevant feedback detail page.
- Email notifications are out of scope for v1.
- My Feedback should show an unread indicator for reports with unread developer comments or status changes.
## Developer Review Page
The developer review area is global, not workspace-scoped.
`/app/feedback` should support:
- list all reports by default, including final statuses
- filter by type
- filter by status
- filter by tag
- filter by reporter
- filter by workspace context
- filter by date range
- text search
- sort by newest
- sort by oldest
- sort by last activity
`/app/feedback/:id` should support:
- report details and captured metadata
- reporter identity details
- current URL/path link
- screenshot preview and developer download/open-original action
- comments
- activity timeline
- status updates
- type updates
- tag management with suggestions
## Reporter Pages
`/app/my-feedback` is global across workspaces and should default to active reports:
- `New`
- `Planned`
The page should support:
- list own reports only
- filter by status
- filter by type
- sort by newest
- sort by last activity
- unread indicators
- open feedback detail
- view visible tags
`/app/my-feedback/:id` should support:
- report details
- current URL/path link
- screenshot preview
- visible tags
- comments
- activity timeline
- cancel with optional reason when status is not final
## Localization
- User-facing feedback UI must be available in English and French.
- New strings belong in the existing locale files.
## API And Data Expectations
- Backend code should follow the FastEndpoints module pattern under `backend/src/Socialize.Api/Modules/Feedback`.
- Feedback entities should be added to `AppDbContext` with explicit model configuration.
- The `Developer` role should be seeded with the existing identity role setup.
- Screenshot storage should use the existing `IBlobStorage` abstraction.
- Protected screenshot access may require a feedback-specific download endpoint instead of public static blob URLs.
- Backend contract changes require OpenAPI regeneration while the backend is running.
## Out Of Scope For V1
- Public or unauthenticated feedback submission
- Shared feedback links
- Email notifications
- Draft saving
- Feedback deletion UI
- Automatic retention cleanup
- Severity or priority fields
- Assignment/owner workflow
- Duplicate linking
- Internal/private comments
- Workspace-owned exports or audit reports
## Done When
- [ ] Authenticated users can open a global Feedback dialog from every app page.
- [ ] Users can submit feedback with required type and description.
- [ ] Users can optionally capture, annotate, and upload an app viewport screenshot.
- [ ] Feedback records capture debugging metadata when available.
- [ ] Reporters can view their own global My Feedback list and details.
- [ ] Developers with the `Developer` role can view all feedback in `/app/feedback`.
- [ ] Developers can update type, status, and tags.
- [ ] Reporters and developers can comment on feedback.
- [ ] Feedback activity history is shown with comments.
- [ ] Feedback notifications appear in the existing in-app notification system.
- [ ] Feedback screenshot access is authenticated and scoped to reporter/developer access.
- [ ] English and French UI strings are present.
- [ ] Backend build and tests pass.
- [ ] Frontend build passes.
- [ ] OpenAPI is updated after backend contracts are implemented.

View File

@@ -0,0 +1,54 @@
# Production Workflow
## Status
Draft
## Goal
Define how a `ContentItem` is planned, created, revised, and prepared before or during approval.
This feature is intentionally separate from approval workflow. Approval workflow decides whether content is approved for publishing handoff. Production workflow describes how content gets made.
## Reviewable Object
Production work happens around a `ContentItem`.
Assets, revisions, comments, assignments, due dates, channels, notes, and content metadata are part of the production context.
## Initial Scope
Production workflow may eventually cover:
- draft creation
- copy/caption preparation
- media production
- asset linking
- revision history
- internal comments
- assignments and ownership
- due dates
- readiness for review
## Relationship To Approval Workflow
Production workflow can prepare a `ContentItem` for approval, but it does not define who must approve it.
A `ContentItem` may require production work before it can enter review, especially for content formats such as video where work includes planning, filming, editing, revisions, and asset delivery.
## Initial Business Rules
- Production history must remain attached to the `ContentItem`.
- Revisions must not overwrite prior work invisibly.
- The latest asset or revision must be clear.
- Production collaboration should support both simple posts and more involved media work.
- Approval decisions should remain distinguishable from production comments or internal readiness signals.
## Open Questions
- Which production states are needed beyond the fixed `ContentItem` lifecycle states?
- Should production have assignments or owners in v1?
- Should production readiness be required before approval can start?
- How should asset revisions be compared or marked as current?
- Should different content types have different production requirements?

View File

@@ -1,90 +0,0 @@
# Review Workflows
## Status
Active
## Use Case 1: Internal Review Before Client Review
### Actors
- Content contributor
- Provider
- Internal reviewer
- Manager
### Scenario
1. A contributor or provider creates or updates a draft.
2. The team links assets and updates the content item metadata.
3. An internal reviewer leaves comments or requests changes.
4. Revisions are linked or uploaded.
5. A manager decides the content item is ready for client review.
### Outcome
- the content item has an internal review history
- revisions are traceable
- the item advances to client review only after internal readiness
## Use Case 2: Client Approval
### Actors
- Social media manager
- Client approver
### Scenario
1. The team sends a content item for client review.
2. The client reviews assets, caption/copy, dates, and notes.
3. The client records a decision:
- approve
- reject
- request changes
4. The team responds with comments or revisions when necessary.
### Outcome
- the decision is captured in the system
- the audit trail shows who decided what and when
- the team knows whether the item is approved, blocked, or requires changes
## Use Case 3: Revision Loop
### Actors
- Provider or internal contributor
- Reviewer
### Scenario
1. A reviewer requests changes.
2. The owner of the work creates a revised asset or revised copy.
3. The new revision is linked to the content item.
4. The reviewer can compare current state against prior feedback context.
### Outcome
- the latest revision is identifiable
- older revisions remain traceable
- feedback does not get detached from the work item
## Use Case 4: Ready For Publishing Handoff
### Actors
- Manager
- Publishing owner
### Scenario
1. All required review and approval work is complete.
2. The content item transitions to `Ready to publish`.
3. The downstream publishing owner uses the item as the approved handoff package.
### Outcome
- publishing handoff is based on an approved state
- the approved revision and metadata are clear
- the workflow history remains visible

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,66 @@
# Feature: Workspace Invites
## Status
Draft
## Goal
Allow workspace managers to invite teammates, clients, and providers into a workspace and allow invited people to accept access with the correct role and workspace scope.
## User Stories
- As a workspace manager, I want to invite a person by email and role so that they can access the right workspace.
- As an invited person, I want to accept an invite from a link so that I can join the workspace without administrator help.
- As an invited person without an account, I want to create my account as part of accepting the invite.
- As an invited person with an account, I want the accepted workspace to appear after sign-in.
- As a workspace manager, I want to see pending, accepted, cancelled, and expired invites so that I understand who has access or still needs follow-up.
## Domain Rules
- Workspace invites belong to exactly one workspace.
- Invite email matching should use normalized email addresses.
- Pending invite tokens must be single-use and should expire.
- Accepted invites must grant the invited role and a workspace scope claim for the invite workspace.
- Signed-in users may accept invites only when their account email matches the invite email.
- New users may create an account during invite acceptance, then receive the invited role and workspace scope.
- Accepted, cancelled, and expired invites must not be accepted again.
- Managers can create, list, cancel, and resend invites only for workspaces they can manage.
- Managers must not be able to create duplicate pending invites for the same normalized email in the same workspace.
- Invite acceptance must be auditable through stored status and timestamp changes.
## Proposed Statuses
- `Pending`
- `Accepted`
- `Cancelled`
- `Expired`
## Backend Surface
- `POST /api/workspaces/{workspaceId:guid}/invites`
- `GET /api/workspaces/{workspaceId:guid}/invites`
- `POST /api/workspace-invites/{inviteId:guid}/resend`
- `POST /api/workspace-invites/{inviteId:guid}/cancel`
- `GET /api/workspace-invites/accept/{token}`
- `POST /api/workspace-invites/accept`
## Frontend Surface
- Workspace settings members tab for invite creation and invite management.
- Public invite acceptance route.
- Authenticated invite acceptance route for signed-in users.
- Registration/sign-in handoff for invited users without a usable session.
## Done When
- [ ] Invite creation sends an email with an acceptance link.
- [ ] Acceptance link validates a pending, unexpired, single-use token.
- [ ] Signed-in users can accept matching-email invites.
- [ ] New users can register through the invite path.
- [ ] Accepted invites grant role and workspace scope.
- [ ] Accepted users see the workspace after token refresh or sign-in.
- [ ] Managers can cancel and resend pending invites.
- [ ] Invite statuses are represented without magic strings.
- [ ] Backend tests cover create, duplicate, accept, expired, cancelled, and email mismatch cases.
- [ ] OpenAPI and frontend API usage are updated after contract changes.

View File

@@ -1,52 +0,0 @@
# Feature: Workspace Review Workflow
## Status
Draft
## Goal
Support workspace-scoped social media content review from content creation through comments, revision, approval, and ready-to-publish handoff.
## User Stories
- As a social media manager, I want content items grouped by workspace, client, and project so that I can manage review work for multiple accounts.
- As a client approver, I want one clear place to review content, comment, and approve or request changes.
- As an account manager, I want notifications and review queues so that work does not stall silently.
## Backend Modules
- Identity
- Workspaces
- Clients
- Projects
- ContentItems
- Assets
- Comments
- Approvals
- Notifications
## Frontend Areas
- `/app`
- `/app/workspaces/new`
- `/app/clients`
- `/app/projects`
- `/app/content`
- `/app/content/:id`
- `/app/reviews`
- `/app/settings`
## Domain Rules
- Workspace is the top-level scoping boundary.
- Content items belong to a workspace and may belong to a client or project.
- Comments, approvals, assets, and notifications must remain traceable to the workflow entity they relate to.
- Ready-to-publish state should come from explicit approval workflow transitions.
## Done When
- [ ] Workspace access is enforced consistently
- [ ] Content item lifecycle is documented as a state machine
- [ ] Approval decisions create traceable notifications/events
- [ ] Review queue behavior is covered by tasks and validation

View File

@@ -0,0 +1,46 @@
# Task: Define approval workflow
## Feature
`docs/FEATURES/approval-workflow.md`
## Goal
Define the v1 approval workflow behavior for `ContentItem` approval.
## Context
Approval workflow has been split from production workflow. The object being approved is a `ContentItem`, and workspaces configure approval behavior without defining a fully custom state machine.
## Scope
- Define approval modes.
- Define approval actors.
- Define approval decisions.
- Define the state transitions affected by approval.
- Define notification and audit side effects.
- Define open questions that remain outside v1.
## Constraints
- Documentation-only task.
- Do not change backend or frontend code.
- Keep production workflow details out of this task except where needed to explain boundaries.
## Done When
- [ ] approval modes are defined
- [ ] actor permissions are defined
- [ ] approval decisions are defined
- [ ] state transition behavior is defined
- [ ] workspace configuration fields are described
- [ ] notification side effects are listed
- [ ] audit requirements are listed
- [ ] out-of-scope production behavior is explicitly separated
## Validation Commands
```bash
git diff -- docs/FEATURES docs/TASKS
```

View File

@@ -0,0 +1,43 @@
# Task: Align content lifecycle statuses
## Feature
`docs/FEATURES/approval-workflow.md`
## Goal
Align `ContentItem.Status` with the fixed lifecycle states defined by the approval workflow spec.
## Scope
- Replace older review/rework/publishing statuses with the fixed lifecycle set:
- `Draft`
- `In production`
- `In approval`
- `Approved`
- `Scheduled`
- `Published`
- Update backend status validation and approval side effects.
- Update development seed content statuses.
- Update frontend status filters, labels, and manual status actions that referenced retired statuses.
## Constraints
- Do not redesign the approval data model in this task.
- Do not implement workspace approval configuration, multi-level approval, comments, reminders, or magic links in this task.
- Keep the current approval request endpoints working as a compatibility layer until the workflow data model task replaces them.
## Done When
- [x] Backend accepts only the fixed lifecycle statuses for manual content status updates.
- [x] Creating an approval request moves content to `In approval`.
- [x] Recording an approved decision moves content to `Approved`.
- [x] Frontend no longer offers or filters against retired content statuses.
- [x] Development seed data uses fixed lifecycle statuses.
## Validation Commands
```bash
dotnet build backend/Socialize.slnx
cd frontend && npm run build
```

Some files were not shown because too many files have changed in this diff Show More