Compare commits

..

18 Commits

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

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,17 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Socialize.Modules.Approvals.Data; using Socialize.Api.Modules.Approvals.Data;
using Socialize.Modules.Assets.Data; using Socialize.Api.Modules.Assets.Data;
using Socialize.Modules.Clients.Data; using Socialize.Api.Modules.Clients.Data;
using Socialize.Modules.Comments.Data; using Socialize.Api.Modules.Comments.Data;
using Socialize.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Modules.Identity.Data; using Socialize.Api.Modules.Feedback.Data;
using Socialize.Modules.Notifications.Data; using Socialize.Api.Modules.Identity.Data;
using Socialize.Modules.Projects.Data; using Socialize.Api.Modules.Notifications.Data;
using Socialize.Modules.Workspaces.Data; using Socialize.Api.Modules.Projects.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Data; namespace Socialize.Api.Data;
public class AppDbContext( public class AppDbContext(
DbContextOptions<AppDbContext> options) DbContextOptions<AppDbContext> options)
@@ -27,18 +29,24 @@ public class AppDbContext(
public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>(); public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>(); public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>();
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>(); public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
public DbSet<FeedbackReport> FeedbackReports => Set<FeedbackReport>();
public DbSet<FeedbackTag> FeedbackTags => Set<FeedbackTag>();
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(); builder.ConfigureWorkspacesModule();
modelBuilder.ConfigureClientsModule(); builder.ConfigureClientsModule();
modelBuilder.ConfigureProjectsModule(); builder.ConfigureProjectsModule();
modelBuilder.ConfigureContentItemsModule(); builder.ConfigureContentItemsModule();
modelBuilder.ConfigureAssetsModule(); builder.ConfigureAssetsModule();
modelBuilder.ConfigureCommentsModule(); builder.ConfigureCommentsModule();
modelBuilder.ConfigureApprovalsModule(); builder.ConfigureApprovalsModule();
modelBuilder.ConfigureNotificationsModule(); builder.ConfigureNotificationsModule();
builder.ConfigureFeedbackModule();
} }
} }

View File

@@ -1,6 +1,7 @@
using System.Text; using System.Text;
using Socialize.Data; using Socialize.Api.Data;
using Socialize.Infrastructure.Security; using Socialize.Api.Infrastructure.Security;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Facebook; using Microsoft.AspNetCore.Authentication.Facebook;
using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.Authentication.Google;
@@ -49,7 +50,7 @@ public static class DependencyInjection
{ {
using IServiceScope scope = app.ApplicationServices.CreateScope(); using IServiceScope scope = app.ApplicationServices.CreateScope();
await using AppDbContext context = scope.ServiceProvider.GetRequiredService<AppDbContext>(); await using AppDbContext context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await context.Database.EnsureCreatedAsync(cancellationToken); await context.Database.MigrateAsync(cancellationToken);
return app; return app;
} }

View File

@@ -1,14 +1,2 @@
global using FluentValidation; global using FluentValidation;
global using FastEndpoints;
global using JetBrains.Annotations; 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 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 internal static class ContainerNames
{ {
public const string Users = "users"; public const string Users = "users";
public const string Clients = "clients"; public const string Clients = "clients";
public const string Workspaces = "workspaces";
public const string Creators = "creators"; public const string Creators = "creators";
public const string Feedback = "feedback";
} }

View File

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

View File

@@ -1,4 +1,4 @@
namespace Socialize.Infrastructure.BlobStorage.Contracts; namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
public interface IBlobStorage 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 static class SubDirectoryNames
{ {
public const string Profile = "profile"; public const string Profile = "profile";
public const string Contents = "contents"; public const string Contents = "contents";
public const string Albums = "albums"; 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 public class WebsiteOptions
{ {

View File

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

View File

@@ -1,11 +1,21 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims; using System.Security.Claims;
using Socialize.Infrastructure.Security; using Socialize.Api.Data;
using Socialize.Modules.Identity.Contracts; using Socialize.Api.Infrastructure.Security;
using Socialize.Modules.Identity.Data; 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.AspNetCore.Identity;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Socialize.Infrastructure.Development; namespace Socialize.Api.Infrastructure.Development;
public static class DevelopmentSeedExtensions public static class DevelopmentSeedExtensions
{ {
@@ -41,8 +51,6 @@ public static class DevelopmentSeedExtensions
UserManager userManager = scope.ServiceProvider.GetRequiredService<UserManager>(); UserManager userManager = scope.ServiceProvider.GetRequiredService<UserManager>();
AppDbContext dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>(); AppDbContext dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await RemoveLegacyDevUserAsync(userManager);
User manager = await EnsureUserAsync( User manager = await EnsureUserAsync(
userManager, userManager,
id: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), id: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
@@ -94,6 +102,21 @@ public static class DevelopmentSeedExtensions
new Claim(KnownClaims.ProjectScope, ScopedProjectId.ToString()), 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( await EnsureWorkspaceDataAsync(
manager.Id, manager.Id,
clientUser.Id, clientUser.Id,
@@ -104,19 +127,6 @@ public static class DevelopmentSeedExtensions
return app; 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( private static async Task<User> EnsureUserAsync(
UserManager userManager, UserManager userManager,
Guid id, Guid id,

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
namespace Socialize.Infrastructure.Emailer.Contracts; namespace Socialize.Api.Infrastructure.Emailer.Contracts;
public interface IEmailSender 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) public class LoggerEmailSender(ILogger<IEmailSender> logger)
: IEmailSender : IEmailSender

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
using System.Security.Claims; 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 public sealed class AccessScopeService
{ {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; 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. // If we need to add special characters we can alternate between 2 pools.
public static class PasswordGenerator public static class PasswordGenerator

View File

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

View File

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

View File

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

@@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Socialize.Data; using Socialize.Api.Data;
#nullable disable #nullable disable
@@ -125,7 +125,7 @@ namespace Socialize.Api.Migrations
b.ToTable("AspNetUserTokens", (string)null); b.ToTable("AspNetUserTokens", (string)null);
}); });
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalDecision", b => modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalDecision", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -168,7 +168,7 @@ namespace Socialize.Api.Migrations
b.ToTable("ApprovalDecisions", (string)null); b.ToTable("ApprovalDecisions", (string)null);
}); });
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalRequest", b => modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -230,7 +230,7 @@ namespace Socialize.Api.Migrations
b.ToTable("ApprovalRequests", (string)null); b.ToTable("ApprovalRequests", (string)null);
}); });
modelBuilder.Entity("Socialize.Modules.Assets.Data.Asset", b => modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -286,7 +286,7 @@ namespace Socialize.Api.Migrations
b.ToTable("Assets", (string)null); b.ToTable("Assets", (string)null);
}); });
modelBuilder.Entity("Socialize.Modules.Assets.Data.AssetRevision", b => modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.AssetRevision", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -329,7 +329,7 @@ namespace Socialize.Api.Migrations
b.ToTable("AssetRevisions", (string)null); b.ToTable("AssetRevisions", (string)null);
}); });
modelBuilder.Entity("Socialize.Modules.Clients.Data.Client", b => modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -379,7 +379,7 @@ namespace Socialize.Api.Migrations
b.ToTable("Clients", (string)null); b.ToTable("Clients", (string)null);
}); });
modelBuilder.Entity("Socialize.Modules.Comments.Data.Comment", b => modelBuilder.Entity("Socialize.Api.Modules.Comments.Data.Comment", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -434,7 +434,7 @@ namespace Socialize.Api.Migrations
b.ToTable("Comments", (string)null); b.ToTable("Comments", (string)null);
}); });
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItem", b => modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItem", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -500,7 +500,7 @@ namespace Socialize.Api.Migrations
b.ToTable("ContentItems", (string)null); b.ToTable("ContentItems", (string)null);
}); });
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItemRevision", b => modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -558,7 +558,298 @@ namespace Socialize.Api.Migrations
b.ToTable("ContentItemRevisions", (string)null); b.ToTable("ContentItemRevisions", (string)null);
}); });
modelBuilder.Entity("Socialize.Modules.Identity.Data.Role", b => modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.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") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -585,7 +876,7 @@ namespace Socialize.Api.Migrations
b.ToTable("AspNetRoles", (string)null); b.ToTable("AspNetRoles", (string)null);
}); });
modelBuilder.Entity("Socialize.Modules.Identity.Data.User", b => modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.User", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -688,7 +979,7 @@ namespace Socialize.Api.Migrations
b.ToTable("AspNetUsers", (string)null); b.ToTable("AspNetUsers", (string)null);
}); });
modelBuilder.Entity("Socialize.Modules.Notifications.Data.NotificationEvent", b => modelBuilder.Entity("Socialize.Api.Modules.Notifications.Data.NotificationEvent", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -750,7 +1041,7 @@ namespace Socialize.Api.Migrations
b.ToTable("NotificationEvents", (string)null); b.ToTable("NotificationEvents", (string)null);
}); });
modelBuilder.Entity("Socialize.Modules.Projects.Data.Project", b => modelBuilder.Entity("Socialize.Api.Modules.Projects.Data.Project", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -803,7 +1094,7 @@ namespace Socialize.Api.Migrations
b.ToTable("Projects", (string)null); b.ToTable("Projects", (string)null);
}); });
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.Workspace", b => modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -814,6 +1105,10 @@ namespace Socialize.Api.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("LogoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
@@ -842,7 +1137,7 @@ namespace Socialize.Api.Migrations
b.ToTable("Workspaces", (string)null); b.ToTable("Workspaces", (string)null);
}); });
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.WorkspaceInvite", b => modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.WorkspaceInvite", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -885,7 +1180,7 @@ namespace Socialize.Api.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{ {
b.HasOne("Socialize.Modules.Identity.Data.Role", null) b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null)
.WithMany() .WithMany()
.HasForeignKey("RoleId") .HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -894,7 +1189,7 @@ namespace Socialize.Api.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{ {
b.HasOne("Socialize.Modules.Identity.Data.User", null) b.HasOne("Socialize.Api.Modules.Identity.Data.User", null)
.WithMany() .WithMany()
.HasForeignKey("UserId") .HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -903,7 +1198,7 @@ namespace Socialize.Api.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{ {
b.HasOne("Socialize.Modules.Identity.Data.User", null) b.HasOne("Socialize.Api.Modules.Identity.Data.User", null)
.WithMany() .WithMany()
.HasForeignKey("UserId") .HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -912,13 +1207,13 @@ namespace Socialize.Api.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{ {
b.HasOne("Socialize.Modules.Identity.Data.Role", null) b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null)
.WithMany() .WithMany()
.HasForeignKey("RoleId") .HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Socialize.Modules.Identity.Data.User", null) b.HasOne("Socialize.Api.Modules.Identity.Data.User", null)
.WithMany() .WithMany()
.HasForeignKey("UserId") .HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -927,12 +1222,67 @@ namespace Socialize.Api.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{ {
b.HasOne("Socialize.Modules.Identity.Data.User", null) b.HasOne("Socialize.Api.Modules.Identity.Data.User", null)
.WithMany() .WithMany()
.HasForeignKey("UserId") .HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.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 #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 public class ApprovalDecision
{ {

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,12 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography; using System.Security.Cryptography;
using Socialize.Infrastructure.Security; using Socialize.Api.Data;
using Socialize.Modules.Notifications.Contracts; using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Notifications.Contracts;
namespace Socialize.Modules.Approvals.Handlers; namespace Socialize.Api.Modules.Approvals.Handlers;
public record CreateApprovalRequestRequest( public record CreateApprovalRequestRequest(
Guid WorkspaceId, Guid WorkspaceId,
@@ -39,7 +43,8 @@ public class CreateApprovalRequestHandler(
public override async Task HandleAsync(CreateApprovalRequestRequest request, CancellationToken ct) public override async Task HandleAsync(CreateApprovalRequestRequest request, CancellationToken ct)
{ {
ContentItem? contentItem = await dbContext.ContentItems var contentItem = await dbContext
.ContentItems
.SingleOrDefaultAsync( .SingleOrDefaultAsync(
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId, candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
ct); ct);
@@ -57,7 +62,7 @@ public class CreateApprovalRequestHandler(
return; return;
} }
ApprovalRequest approval = new() var approval = new ApprovalRequest()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId, WorkspaceId = request.WorkspaceId,

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); public record GetApprovalsRequest(Guid ContentItemId);

View File

@@ -1,7 +1,12 @@
using Socialize.Infrastructure.Security; using FastEndpoints;
using Socialize.Modules.Notifications.Contracts; 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.Notifications.Contracts;
namespace Socialize.Modules.Approvals.Handlers; namespace Socialize.Api.Modules.Approvals.Handlers;
public record SubmitApprovalDecisionRequest( public record SubmitApprovalDecisionRequest(
string Decision, string Decision,

View File

@@ -1,4 +1,4 @@
namespace Socialize.Modules.Assets.Data; namespace Socialize.Api.Modules.Assets.Data;
public class Asset 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 public static class AssetModelConfiguration
{ {

View File

@@ -1,4 +1,4 @@
namespace Socialize.Modules.Assets.Data; namespace Socialize.Api.Modules.Assets.Data;
public class AssetRevision 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 public static class DependencyInjection
{ {

View File

@@ -1,7 +1,12 @@
using Socialize.Infrastructure.Security; using FastEndpoints;
using Socialize.Modules.Notifications.Contracts; 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( public record CreateAssetRevisionRequest(
string SourceReference, string SourceReference,

View File

@@ -1,7 +1,12 @@
using Socialize.Infrastructure.Security; using FastEndpoints;
using Socialize.Modules.Notifications.Contracts; 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( public record CreateGoogleDriveAssetRequest(
Guid WorkspaceId, Guid WorkspaceId,

View File

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

View File

@@ -1,4 +1,4 @@
namespace Socialize.Modules.Clients.Data; namespace Socialize.Api.Modules.Clients.Data;
public class Client 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 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 public static class DependencyInjection
{ {

View File

@@ -1,8 +1,11 @@
using Socialize.Infrastructure.BlobStorage.Contracts; using FastEndpoints;
using Socialize.Infrastructure.Security; using Microsoft.EntityFrameworkCore;
using Socialize.Modules.Clients.Data; 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( public record ChangeClientPortraitRequest(
IFormFile File); IFormFile File);

View File

@@ -1,5 +1,10 @@
using Socialize.Infrastructure.Security; using FastEndpoints;
namespace Socialize.Modules.Clients.Handlers; 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( public record CreateClientRequest(
Guid WorkspaceId, Guid WorkspaceId,

View File

@@ -1,7 +1,10 @@
using Socialize.Infrastructure.Security; using FastEndpoints;
using Socialize.Modules.Clients.Data; 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); public record GetClientsRequest(Guid? WorkspaceId);

View File

@@ -1,7 +1,10 @@
using Socialize.Infrastructure.Security; using FastEndpoints;
using Socialize.Modules.Clients.Data; 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( public record UpdateClientRequest(
string Name, string Name,

View File

@@ -1,4 +1,4 @@
namespace Socialize.Modules.Comments.Data; namespace Socialize.Api.Modules.Comments.Data;
public class Comment 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 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 public static class DependencyInjection
{ {

View File

@@ -1,7 +1,12 @@
using Socialize.Infrastructure.Security; using FastEndpoints;
using Socialize.Modules.Notifications.Contracts; 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( public record CreateCommentRequest(
Guid WorkspaceId, 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); public record GetCommentsRequest(Guid ContentItemId);

View File

@@ -1,7 +1,12 @@
using Socialize.Infrastructure.Security; using FastEndpoints;
using Socialize.Modules.Notifications.Contracts; 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( public class ResolveCommentHandler(
AppDbContext dbContext, AppDbContext dbContext,

View File

@@ -1,4 +1,4 @@
namespace Socialize.Modules.ContentItems.Data; namespace Socialize.Api.Modules.ContentItems.Data;
public class ContentItem 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 public static class ContentItemModelConfiguration
{ {

View File

@@ -1,4 +1,4 @@
namespace Socialize.Modules.ContentItems.Data; namespace Socialize.Api.Modules.ContentItems.Data;
public class ContentItemRevision 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 public static class DependencyInjection
{ {

View File

@@ -1,7 +1,12 @@
using Socialize.Infrastructure.Security; using FastEndpoints;
using Socialize.Modules.Notifications.Contracts; 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( public record CreateContentItemRequest(
Guid WorkspaceId, Guid WorkspaceId,

View File

@@ -1,7 +1,11 @@
using Socialize.Infrastructure.Security; using FastEndpoints;
using Socialize.Modules.Notifications.Contracts; 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( public record CreateContentItemRevisionRequest(
string Title, string Title,

View File

@@ -1,7 +1,10 @@
using Socialize.Infrastructure.Security; using FastEndpoints;
using Socialize.Modules.ContentItems.Data; 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( public record ContentItemDetailDto(
Guid Id, Guid Id,

View File

@@ -1,5 +1,10 @@
using Socialize.Infrastructure.Security; using FastEndpoints;
namespace Socialize.Modules.ContentItems.Handlers; 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( public record ContentItemRevisionDto(
Guid Id, Guid Id,

View File

@@ -1,7 +1,10 @@
using Socialize.Infrastructure.Security; using FastEndpoints;
using Socialize.Modules.ContentItems.Data; 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); public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? ProjectId);

View File

@@ -1,8 +1,11 @@
using Socialize.Infrastructure.Security; using FastEndpoints;
using Socialize.Modules.ContentItems.Data; using Microsoft.EntityFrameworkCore;
using Socialize.Modules.Notifications.Contracts; 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 UpdateContentItemStatusRequest(string Status); public record UpdateContentItemStatusRequest(string Status);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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