Compare commits

24 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
43bcf449fd wip 2026-04-29 20:58:36 -04:00
259 changed files with 28968 additions and 3090 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

@@ -1,4 +1,4 @@
namespace Socialize.Common.Domain;
namespace Socialize.Api.Common.Domain;
public abstract class Entity
{

View File

@@ -1,15 +1,17 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Socialize.Modules.Approvals.Data;
using Socialize.Modules.Assets.Data;
using Socialize.Modules.Clients.Data;
using Socialize.Modules.Comments.Data;
using Socialize.Modules.ContentItems.Data;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Notifications.Data;
using Socialize.Modules.Projects.Data;
using Socialize.Modules.Workspaces.Data;
using Socialize.Api.Modules.Approvals.Data;
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;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Data;
namespace Socialize.Api.Data;
public class AppDbContext(
DbContextOptions<AppDbContext> options)
@@ -24,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

@@ -1,6 +1,7 @@
using System.Text;
using Socialize.Data;
using Socialize.Infrastructure.Security;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Facebook;
using Microsoft.AspNetCore.Authentication.Google;
@@ -49,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

@@ -1,14 +1,2 @@
global using FluentValidation;
global using FastEndpoints;
global using JetBrains.Annotations;
global using Microsoft.EntityFrameworkCore;
global using Socialize.Data;
global using Socialize.Modules.Approvals.Data;
global using Socialize.Modules.Assets.Data;
global using Socialize.Modules.Clients.Data;
global using Socialize.Modules.Comments.Data;
global using Socialize.Modules.ContentItems.Data;
global using Socialize.Modules.Identity.Data;
global using Socialize.Modules.Notifications.Data;
global using Socialize.Modules.Projects.Data;
global using Socialize.Modules.Workspaces.Data;

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

@@ -1,4 +1,4 @@
namespace Socialize.Infrastructure.BlobStorage.Contracts;
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
public static class CommonFileNames
{

View File

@@ -1,8 +1,10 @@
namespace Socialize.Infrastructure.BlobStorage.Contracts;
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
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

@@ -1,6 +1,6 @@
using System.Text;
namespace Socialize.Infrastructure.BlobStorage.Contracts;
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
public static class ContentTypes
{

View File

@@ -1,4 +1,4 @@
namespace Socialize.Infrastructure.BlobStorage.Contracts;
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
public interface IBlobStorage
{

View File

@@ -1,8 +1,9 @@
namespace Socialize.Infrastructure.BlobStorage.Contracts;
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
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.Infrastructure.BlobStorage.Contracts;
namespace Socialize.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,4 +1,4 @@
namespace Socialize.Infrastructure.Configuration;
namespace Socialize.Api.Infrastructure.Configuration;
public class WebsiteOptions
{

View File

@@ -1,12 +1,13 @@
using Socialize.Infrastructure.BlobStorage.Contracts;
using Socialize.Infrastructure.BlobStorage.Services;
using Socialize.Infrastructure.Configuration;
using Socialize.Infrastructure.Emailer.Configuration;
using Socialize.Infrastructure.Emailer.Contracts;
using Socialize.Infrastructure.Emailer.Services;
using Socialize.Infrastructure.Payments.Stripe.Configuration;
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;
using Socialize.Api.Infrastructure.Emailer.Services;
using Socialize.Api.Infrastructure.Payments.Stripe.Configuration;
namespace Socialize.Infrastructure;
namespace Socialize.Api.Infrastructure;
public static class DependencyInjection
{
@@ -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

@@ -1,11 +1,21 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Contracts;
using Socialize.Modules.Identity.Data;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Clients.Data;
using Socialize.Api.Modules.Notifications.Data;
using Socialize.Api.Modules.Projects.Data;
using Socialize.Api.Modules.Workspaces.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Socialize.Infrastructure.Development;
namespace Socialize.Api.Infrastructure.Development;
public static class DevelopmentSeedExtensions
{
@@ -41,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"),
@@ -94,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,
@@ -104,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,
@@ -297,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

@@ -1,4 +1,4 @@
namespace Socialize.Infrastructure.Development;
namespace Socialize.Api.Infrastructure.Development;
public record DevelopmentSeedOptions
{

View File

@@ -1,4 +1,4 @@
namespace Socialize.Infrastructure.Emailer.Configuration;
namespace Socialize.Api.Infrastructure.Emailer.Configuration;
public class EmailerOptions
{

View File

@@ -1,4 +1,4 @@
namespace Socialize.Infrastructure.Emailer.Contracts;
namespace Socialize.Api.Infrastructure.Emailer.Contracts;
public interface IEmailSender
{

View File

@@ -1,6 +1,6 @@
using Socialize.Infrastructure.Emailer.Contracts;
using Socialize.Api.Infrastructure.Emailer.Contracts;
namespace Socialize.Infrastructure.Emailer.Services;
namespace Socialize.Api.Infrastructure.Emailer.Services;
public class LoggerEmailSender(ILogger<IEmailSender> logger)
: IEmailSender

View File

@@ -1,9 +1,9 @@
using Socialize.Infrastructure.Emailer.Configuration;
using Socialize.Infrastructure.Emailer.Contracts;
using Socialize.Api.Infrastructure.Emailer.Configuration;
using Socialize.Api.Infrastructure.Emailer.Contracts;
using Microsoft.Extensions.Options;
using PostmarkDotNet;
namespace Socialize.Infrastructure.Emailer.Services;
namespace Socialize.Api.Infrastructure.Emailer.Services;
public class PostmarkEmailSender : IEmailSender
{

View File

@@ -1,11 +1,11 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Socialize.Infrastructure.Emailer.Configuration;
using Socialize.Infrastructure.Emailer.Contracts;
using Socialize.Api.Infrastructure.Emailer.Configuration;
using Socialize.Api.Infrastructure.Emailer.Contracts;
using Microsoft.Extensions.Options;
namespace Socialize.Infrastructure.Emailer.Services;
namespace Socialize.Api.Infrastructure.Emailer.Services;
public class ResendEmailSender : IEmailSender
{

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Socialize.Infrastructure.Payments.Stripe.Configuration;
namespace Socialize.Api.Infrastructure.Payments.Stripe.Configuration;
public class StripeOptions
{

View File

@@ -1,7 +1,7 @@
using System.Security.Claims;
using Socialize.Modules.Identity.Contracts;
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Infrastructure.Security;
namespace Socialize.Api.Infrastructure.Security;
public sealed class AccessScopeService
{

View File

@@ -1,6 +1,6 @@
using System.Security.Claims;
namespace Socialize.Infrastructure.Security;
namespace Socialize.Api.Infrastructure.Security;
public static class ClaimsPrincipalExtensions
{

View File

@@ -3,7 +3,7 @@ using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace Socialize.Infrastructure.Security;
namespace Socialize.Api.Infrastructure.Security;
public static class JwtTokenHelper
{

View File

@@ -1,4 +1,4 @@
namespace Socialize.Infrastructure.Security;
namespace Socialize.Api.Infrastructure.Security;
public static class KnownClaims
{

View File

@@ -1,4 +1,4 @@
namespace Socialize.Infrastructure.Security;
namespace Socialize.Api.Infrastructure.Security;
public class MissingClaimException(
string claimName)

View File

@@ -1,7 +1,7 @@
using System.Security.Cryptography;
using System.Text;
namespace Socialize.Infrastructure.Security;
namespace Socialize.Api.Infrastructure.Security;
// If we need to add special characters we can alternate between 2 pools.
public static class PasswordGenerator

View File

@@ -1,6 +1,6 @@
using System.Security.Cryptography;
namespace Socialize.Infrastructure.Security;
namespace Socialize.Api.Infrastructure.Security;
public static class RefreshTokenGenerator
{

View File

@@ -1,6 +1,6 @@
using System.Text.RegularExpressions;
namespace Socialize.Infrastructure.YouTube;
namespace Socialize.Api.Infrastructure.YouTube;
public static class YouTubeUrlHelper
{

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Socialize.Data;
using Socialize.Api.Data;
#nullable disable

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

@@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Socialize.Data;
using Socialize.Api.Data;
#nullable disable
@@ -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

@@ -1,4 +1,4 @@
namespace Socialize.Modules.Approvals.Data;
namespace Socialize.Api.Modules.Approvals.Data;
public class ApprovalDecision
{

View File

@@ -1,13 +1,33 @@
namespace Socialize.Modules.Approvals.Data;
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.Approvals.Data;
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();
@@ -18,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);
});
@@ -35,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

@@ -1,10 +1,15 @@
namespace Socialize.Modules.Approvals.Data;
namespace Socialize.Api.Modules.Approvals.Data;
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,12 +1,14 @@
using Socialize.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
namespace Socialize.Modules.Approvals;
namespace Socialize.Api.Modules.Approvals;
public static class DependencyInjection
{
public static WebApplicationBuilder AddApprovalsModule(
this WebApplicationBuilder builder)
{
builder.Services.AddScoped<ApprovalWorkflowRuntimeService>();
return builder;
}
}

View File

@@ -1,8 +1,14 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
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.Modules.Approvals.Handlers;
namespace Socialize.Api.Modules.Approvals.Handlers;
public record CreateApprovalRequestRequest(
Guid WorkspaceId,
@@ -39,7 +45,8 @@ public class CreateApprovalRequestHandler(
public override async Task HandleAsync(CreateApprovalRequestRequest request, CancellationToken ct)
{
ContentItem? contentItem = await dbContext.ContentItems
var contentItem = await dbContext
.ContentItems
.SingleOrDefaultAsync(
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
ct);
@@ -57,7 +64,23 @@ public class CreateApprovalRequestHandler(
return;
}
ApprovalRequest approval = new()
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(),
WorkspaceId = request.WorkspaceId,
@@ -74,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);
@@ -102,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

@@ -1,6 +1,11 @@
using Socialize.Infrastructure.Security;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Infrastructure.Security;
namespace Socialize.Modules.Approvals.Handlers;
namespace Socialize.Api.Modules.Approvals.Handlers;
public record GetApprovalsRequest(Guid ContentItemId);
@@ -19,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,
@@ -60,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
@@ -86,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

@@ -1,7 +1,14 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
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.Modules.Approvals.Handlers;
namespace Socialize.Api.Modules.Approvals.Handlers;
public record SubmitApprovalDecisionRequest(
string Decision,
@@ -14,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));
@@ -24,6 +34,7 @@ public class SubmitApprovalDecisionRequestValidator
public class SubmitApprovalDecisionHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
{
@@ -59,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()
@@ -79,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);
@@ -118,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)
@@ -153,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

@@ -1,4 +1,4 @@
namespace Socialize.Modules.Assets.Data;
namespace Socialize.Api.Modules.Assets.Data;
public class Asset
{

View File

@@ -1,4 +1,6 @@
namespace Socialize.Modules.Assets.Data;
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.Assets.Data;
public static class AssetModelConfiguration
{

View File

@@ -1,4 +1,4 @@
namespace Socialize.Modules.Assets.Data;
namespace Socialize.Api.Modules.Assets.Data;
public class AssetRevision
{

View File

@@ -1,6 +1,6 @@
using Socialize.Modules.Assets.Data;
using Socialize.Api.Modules.Assets.Data;
namespace Socialize.Modules.Assets;
namespace Socialize.Api.Modules.Assets;
public static class DependencyInjection
{

View File

@@ -1,7 +1,12 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts;
namespace Socialize.Modules.Assets.Handlers;
namespace Socialize.Api.Modules.Assets.Handlers;
public record CreateAssetRevisionRequest(
string SourceReference,

View File

@@ -1,7 +1,12 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts;
namespace Socialize.Modules.Assets.Handlers;
namespace Socialize.Api.Modules.Assets.Handlers;
public record CreateGoogleDriveAssetRequest(
Guid WorkspaceId,

View File

@@ -1,5 +1,9 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Assets.Handlers;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
namespace Socialize.Api.Modules.Assets.Handlers;
public record GetAssetsRequest(Guid ContentItemId);
@@ -40,7 +44,7 @@ public class GetAssetsHandler(
public override async Task HandleAsync(GetAssetsRequest request, CancellationToken ct)
{
ContentItem? item = await dbContext.ContentItems
var item = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
if (item is null)
{

View File

@@ -1,4 +1,4 @@
namespace Socialize.Modules.Clients.Data;
namespace Socialize.Api.Modules.Clients.Data;
public class Client
{

View File

@@ -1,4 +1,6 @@
namespace Socialize.Modules.Clients.Data;
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.Clients.Data;
public static class ClientModelConfiguration
{

View File

@@ -1,6 +1,6 @@
using Socialize.Modules.Clients.Data;
using Socialize.Api.Modules.Clients.Data;
namespace Socialize.Modules.Clients;
namespace Socialize.Api.Modules.Clients;
public static class DependencyInjection
{

View File

@@ -1,8 +1,11 @@
using Socialize.Infrastructure.BlobStorage.Contracts;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Clients.Data;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Clients.Data;
namespace Socialize.Modules.Clients.Handlers;
namespace Socialize.Api.Modules.Clients.Handlers;
public record ChangeClientPortraitRequest(
IFormFile File);

View File

@@ -1,5 +1,10 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Clients.Handlers;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Clients.Data;
using Socialize.Api.Infrastructure.Security;
namespace Socialize.Api.Modules.Clients.Handlers;
public record CreateClientRequest(
Guid WorkspaceId,

View File

@@ -1,7 +1,10 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Clients.Data;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Clients.Data;
namespace Socialize.Modules.Clients.Handlers;
namespace Socialize.Api.Modules.Clients.Handlers;
public record GetClientsRequest(Guid? WorkspaceId);

View File

@@ -1,7 +1,10 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Clients.Data;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Clients.Data;
namespace Socialize.Modules.Clients.Handlers;
namespace Socialize.Api.Modules.Clients.Handlers;
public record UpdateClientRequest(
string Name,

View File

@@ -1,4 +1,4 @@
namespace Socialize.Modules.Comments.Data;
namespace Socialize.Api.Modules.Comments.Data;
public class Comment
{

View File

@@ -1,4 +1,6 @@
namespace Socialize.Modules.Comments.Data;
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.Comments.Data;
public static class CommentModelConfiguration
{

View File

@@ -1,6 +1,6 @@
using Socialize.Modules.Comments.Data;
using Socialize.Api.Modules.Comments.Data;
namespace Socialize.Modules.Comments;
namespace Socialize.Api.Modules.Comments;
public static class DependencyInjection
{

View File

@@ -1,7 +1,12 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.Notifications.Contracts;
namespace Socialize.Modules.Comments.Handlers;
namespace Socialize.Api.Modules.Comments.Handlers;
public record CreateCommentRequest(
Guid WorkspaceId,

View File

@@ -1,6 +1,11 @@
using Socialize.Infrastructure.Security;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Infrastructure.Security;
namespace Socialize.Modules.Comments.Handlers;
namespace Socialize.Api.Modules.Comments.Handlers;
public record GetCommentsRequest(Guid ContentItemId);

View File

@@ -1,7 +1,12 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts;
namespace Socialize.Modules.Comments.Handlers;
namespace Socialize.Api.Modules.Comments.Handlers;
public class ResolveCommentHandler(
AppDbContext dbContext,

View File

@@ -1,4 +1,4 @@
namespace Socialize.Modules.ContentItems.Data;
namespace Socialize.Api.Modules.ContentItems.Data;
public class ContentItem
{

View File

@@ -1,4 +1,6 @@
namespace Socialize.Modules.ContentItems.Data;
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.ContentItems.Data;
public static class ContentItemModelConfiguration
{

View File

@@ -1,4 +1,4 @@
namespace Socialize.Modules.ContentItems.Data;
namespace Socialize.Api.Modules.ContentItems.Data;
public class ContentItemRevision
{

View File

@@ -1,6 +1,6 @@
using Socialize.Modules.ContentItems.Data;
using Socialize.Api.Modules.ContentItems.Data;
namespace Socialize.Modules.ContentItems;
namespace Socialize.Api.Modules.ContentItems;
public static class DependencyInjection
{

View File

@@ -1,7 +1,12 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Modules.ContentItems.Handlers;
namespace Socialize.Api.Modules.ContentItems.Handlers;
public record CreateContentItemRequest(
Guid WorkspaceId,

View File

@@ -1,7 +1,11 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts;
namespace Socialize.Modules.ContentItems.Handlers;
namespace Socialize.Api.Modules.ContentItems.Handlers;
public record CreateContentItemRevisionRequest(
string Title,
@@ -62,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

@@ -1,7 +1,10 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.ContentItems.Data;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ContentItems.Data;
namespace Socialize.Modules.ContentItems.Handlers;
namespace Socialize.Api.Modules.ContentItems.Handlers;
public record ContentItemDetailDto(
Guid Id,

View File

@@ -1,5 +1,10 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.ContentItems.Handlers;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ContentItems.Data;
namespace Socialize.Api.Modules.ContentItems.Handlers;
public record ContentItemRevisionDto(
Guid Id,

View File

@@ -1,7 +1,10 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.ContentItems.Data;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ContentItems.Data;
namespace Socialize.Modules.ContentItems.Handlers;
namespace Socialize.Api.Modules.ContentItems.Handlers;
public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? ProjectId);

View File

@@ -1,8 +1,13 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.ContentItems.Data;
using Socialize.Modules.Notifications.Contracts;
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.Modules.ContentItems.Handlers;
namespace Socialize.Api.Modules.ContentItems.Handlers;
public record UpdateContentItemStatusRequest(string Status);
@@ -18,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()
@@ -69,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;
}
}

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