chore: moving towards agentic development
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-04-24 21:12:26 -04:00
parent df3e602015
commit b6eb692c27
179 changed files with 2880 additions and 866 deletions

View File

@@ -0,0 +1,10 @@
namespace Socialize.Common.Domain;
public abstract class Entity
{
public Guid Id { get; init; }
public Guid CreatedBy { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public Guid? DeletedBy { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}

View File

@@ -0,0 +1,226 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Socialize.Modules.Approvals.Data;
using Socialize.Modules.Assets.Data;
using Socialize.Modules.Clients.Data;
using Socialize.Modules.Comments.Data;
using Socialize.Modules.ContentItems.Data;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Notifications.Data;
using Socialize.Modules.Projects.Data;
using Socialize.Modules.Workspaces.Data;
namespace Socialize.Data;
public class AppDbContext(
DbContextOptions<AppDbContext> options)
: IdentityDbContext<User, Role, Guid>(options)
{
public DbSet<Workspace> Workspaces => Set<Workspace>();
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
public DbSet<Client> Clients => Set<Client>();
public DbSet<Project> Projects => Set<Project>();
public DbSet<ContentItem> ContentItems => Set<ContentItem>();
public DbSet<ContentItemRevision> ContentItemRevisions => Set<ContentItemRevision>();
public DbSet<Asset> Assets => Set<Asset>();
public DbSet<AssetRevision> AssetRevisions => Set<AssetRevision>();
public DbSet<Comment> Comments => Set<Comment>();
public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>();
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Workspace>(workspace =>
{
workspace.ToTable("Workspaces");
workspace.HasKey(x => x.Id);
workspace.Property(x => x.Name).HasMaxLength(256).IsRequired();
workspace.Property(x => x.Slug).HasMaxLength(128).IsRequired();
workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired();
workspace.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
workspace.HasIndex(x => x.Slug).IsUnique();
workspace.HasIndex(x => x.OwnerUserId);
});
modelBuilder.Entity<WorkspaceInvite>(workspaceInvite =>
{
workspaceInvite.ToTable("WorkspaceInvites");
workspaceInvite.HasKey(x => x.Id);
workspaceInvite.Property(x => x.Email).HasMaxLength(256).IsRequired();
workspaceInvite.Property(x => x.Role).HasMaxLength(64).IsRequired();
workspaceInvite.Property(x => x.Status).HasMaxLength(64).IsRequired();
workspaceInvite.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
workspaceInvite.HasIndex(x => x.WorkspaceId);
workspaceInvite.HasIndex(x => new { x.WorkspaceId, x.Email, x.Status });
});
modelBuilder.Entity<Client>(client =>
{
client.ToTable("Clients");
client.HasKey(x => x.Id);
client.Property(x => x.Name).HasMaxLength(256).IsRequired();
client.Property(x => x.Status).HasMaxLength(64).IsRequired();
client.Property(x => x.PortraitUrl).HasMaxLength(2048);
client.Property(x => x.PrimaryContactName).HasMaxLength(256);
client.Property(x => x.PrimaryContactEmail).HasMaxLength(256);
client.Property(x => x.PrimaryContactPortraitUrl).HasMaxLength(2048);
client.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
client.HasIndex(x => new { x.WorkspaceId, x.Name }).IsUnique();
client.HasIndex(x => x.WorkspaceId);
});
modelBuilder.Entity<Project>(project =>
{
project.ToTable("Projects");
project.HasKey(x => x.Id);
project.Property(x => x.Name).HasMaxLength(256).IsRequired();
project.Property(x => x.Description).HasMaxLength(4000);
project.Property(x => x.Notes).HasMaxLength(4000);
project.Property(x => x.Status).HasMaxLength(64).IsRequired();
project.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
project.HasIndex(x => new { x.ClientId, x.Name }).IsUnique();
project.HasIndex(x => x.WorkspaceId);
project.HasIndex(x => x.ClientId);
});
modelBuilder.Entity<ContentItem>(contentItem =>
{
contentItem.ToTable("ContentItems");
contentItem.HasKey(x => x.Id);
contentItem.Property(x => x.Title).HasMaxLength(256).IsRequired();
contentItem.Property(x => x.PublicationMessage).HasMaxLength(4000).IsRequired();
contentItem.Property(x => x.PublicationTargets).HasMaxLength(512).IsRequired();
contentItem.Property(x => x.Hashtags).HasMaxLength(1024);
contentItem.Property(x => x.Status).HasMaxLength(64).IsRequired();
contentItem.Property(x => x.CurrentRevisionLabel).HasMaxLength(32).IsRequired();
contentItem.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
contentItem.HasIndex(x => x.WorkspaceId);
contentItem.HasIndex(x => x.ClientId);
contentItem.HasIndex(x => x.ProjectId);
});
modelBuilder.Entity<ContentItemRevision>(revision =>
{
revision.ToTable("ContentItemRevisions");
revision.HasKey(x => x.Id);
revision.Property(x => x.RevisionLabel).HasMaxLength(32).IsRequired();
revision.Property(x => x.Title).HasMaxLength(256).IsRequired();
revision.Property(x => x.PublicationMessage).HasMaxLength(4000).IsRequired();
revision.Property(x => x.PublicationTargets).HasMaxLength(512).IsRequired();
revision.Property(x => x.Hashtags).HasMaxLength(1024);
revision.Property(x => x.ChangeSummary).HasMaxLength(1024);
revision.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
revision.HasIndex(x => x.ContentItemId);
revision.HasIndex(x => new { x.ContentItemId, x.RevisionNumber }).IsUnique();
});
modelBuilder.Entity<Asset>(asset =>
{
asset.ToTable("Assets");
asset.HasKey(x => x.Id);
asset.Property(x => x.AssetType).HasMaxLength(64).IsRequired();
asset.Property(x => x.SourceType).HasMaxLength(64).IsRequired();
asset.Property(x => x.DisplayName).HasMaxLength(256).IsRequired();
asset.Property(x => x.GoogleDriveFileId).HasMaxLength(256);
asset.Property(x => x.GoogleDriveLink).HasMaxLength(2048);
asset.Property(x => x.PreviewUrl).HasMaxLength(2048);
asset.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
asset.HasIndex(x => x.WorkspaceId);
asset.HasIndex(x => x.ContentItemId);
});
modelBuilder.Entity<AssetRevision>(revision =>
{
revision.ToTable("AssetRevisions");
revision.HasKey(x => x.Id);
revision.Property(x => x.SourceReference).HasMaxLength(2048).IsRequired();
revision.Property(x => x.PreviewUrl).HasMaxLength(2048);
revision.Property(x => x.Notes).HasMaxLength(1024);
revision.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
revision.HasIndex(x => x.AssetId);
revision.HasIndex(x => new { x.AssetId, x.RevisionNumber }).IsUnique();
});
modelBuilder.Entity<Comment>(comment =>
{
comment.ToTable("Comments");
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.Body).HasMaxLength(4000).IsRequired();
comment.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
comment.HasIndex(x => x.WorkspaceId);
comment.HasIndex(x => x.ContentItemId);
comment.HasIndex(x => x.ParentCommentId);
});
modelBuilder.Entity<ApprovalRequest>(approvalRequest =>
{
approvalRequest.ToTable("ApprovalRequests");
approvalRequest.HasKey(x => x.Id);
approvalRequest.Property(x => x.Stage).HasMaxLength(64).IsRequired();
approvalRequest.Property(x => x.ReviewerName).HasMaxLength(256).IsRequired();
approvalRequest.Property(x => x.ReviewerEmail).HasMaxLength(256).IsRequired();
approvalRequest.Property(x => x.State).HasMaxLength(64).IsRequired();
approvalRequest.Property(x => x.AccessToken).HasMaxLength(64).IsRequired();
approvalRequest.Property(x => x.SentAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
approvalRequest.HasIndex(x => x.WorkspaceId);
approvalRequest.HasIndex(x => x.ContentItemId);
approvalRequest.HasIndex(x => x.ReviewerEmail);
});
modelBuilder.Entity<ApprovalDecision>(approvalDecision =>
{
approvalDecision.ToTable("ApprovalDecisions");
approvalDecision.HasKey(x => x.Id);
approvalDecision.Property(x => x.Decision).HasMaxLength(64).IsRequired();
approvalDecision.Property(x => x.Comment).HasMaxLength(2048);
approvalDecision.Property(x => x.DecidedByName).HasMaxLength(256).IsRequired();
approvalDecision.Property(x => x.DecidedByEmail).HasMaxLength(256).IsRequired();
approvalDecision.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
approvalDecision.HasIndex(x => x.ApprovalRequestId);
});
modelBuilder.Entity<NotificationEvent>(notificationEvent =>
{
notificationEvent.ToTable("NotificationEvents");
notificationEvent.HasKey(x => x.Id);
notificationEvent.Property(x => x.EventType).HasMaxLength(128).IsRequired();
notificationEvent.Property(x => x.EntityType).HasMaxLength(128).IsRequired();
notificationEvent.Property(x => x.Message).HasMaxLength(1024).IsRequired();
notificationEvent.Property(x => x.RecipientEmail).HasMaxLength(256);
notificationEvent.Property(x => x.MetadataJson).HasMaxLength(4000);
notificationEvent.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
notificationEvent.HasIndex(x => x.WorkspaceId);
notificationEvent.HasIndex(x => x.ContentItemId);
notificationEvent.HasIndex(x => x.RecipientUserId);
notificationEvent.HasIndex(x => x.CreatedAt);
});
}
}

View File

@@ -0,0 +1,112 @@
using System.Text;
using Socialize.Data;
using Socialize.Infrastructure.Security;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Facebook;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
namespace Socialize;
public static class DependencyInjection
{
public static IServiceCollection AddWebServices(this IServiceCollection services)
{
services.AddDatabaseDeveloperPageExceptionFilter();
services.AddHttpContextAccessor();
services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>();
services.AddHttpClient();
services.AddScoped<AccessScopeService>();
// Customise default API behaviour
services.Configure<ApiBehaviorOptions>(options =>
options.SuppressModelStateInvalidFilter = true);
services.AddEndpointsApiExplorer();
return services;
}
public static IServiceCollection AddAppData(
this IServiceCollection services,
string postgresConnectionString)
{
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(postgresConnectionString));
return services;
}
public static async Task<IApplicationBuilder> UseAppDataAsync(
this IApplicationBuilder app,
CancellationToken cancellationToken = default)
{
using IServiceScope scope = app.ApplicationServices.CreateScope();
await using AppDbContext context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await context.Database.EnsureCreatedAsync(cancellationToken);
return app;
}
public static IServiceCollection AddAuthorizationAndAuthentication(this IServiceCollection services,
ConfigurationManager configuration)
{
AuthenticationBuilder authenticationBuilder = services
.AddAuthentication(options =>
{
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
});
IConfigurationSection authJwt = configuration.GetSection("Authentication:Jwt");
if (authJwt.Exists())
{
authenticationBuilder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtBearerOptions =>
{
jwtBearerOptions.Authority = "https://hutopy.com";
jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = authJwt["Issuer"],
ValidateAudience = true,
ValidAudience = authJwt["Audience"],
ValidateLifetime = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authJwt["Key"] ??
throw new ArgumentNullException("The Jwt Key is missing.")))
};
});
}
IConfigurationSection authGoogle = configuration.GetSection("Authentication:Google");
if (authGoogle.Exists())
{
authenticationBuilder.AddGoogle(GoogleDefaults.AuthenticationScheme, options =>
{
options.ClientId = authGoogle["ClientId"] ??
throw new ArgumentNullException("The Google ClientId is missing.");
options.ClientSecret = authGoogle["ClientSecret"] ??
throw new ArgumentNullException("The Google ClientSecret is missing.");
});
}
IConfigurationSection authFacebook = configuration.GetSection("Authentication:Facebook");
if (authFacebook.Exists())
{
authenticationBuilder.AddFacebook(FacebookDefaults.AuthenticationScheme, options =>
{
options.ClientId = authFacebook["ClientId"] ??
throw new ArgumentNullException("The Facebook ClientId is missing.");
options.ClientSecret = authFacebook["ClientSecret"] ??
throw new ArgumentNullException("The Facebook ClientSecret is missing.");
});
}
return services;
}
}

View File

@@ -0,0 +1,25 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY backend/Socialize.slnx backend/
COPY backend/src/Socialize.Api/Socialize.Api.csproj backend/src/Socialize.Api/
COPY backend/tests/Socialize.Tests/Socialize.Tests.csproj backend/tests/Socialize.Tests/
RUN dotnet restore backend/Socialize.slnx
COPY backend/ backend/
RUN dotnet publish backend/src/Socialize.Api/Socialize.Api.csproj \
-c Release \
-o /app/publish \
--no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
ENV ASPNETCORE_ENVIRONMENT=Production
EXPOSE 8080
ENTRYPOINT ["dotnet", "Socialize.Api.dll"]

View File

@@ -0,0 +1,5 @@
<wpf:ResourceDictionary xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:s="clr-namespace:System;assembly=mscorlib"
xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xml:space="preserve">
<s:Boolean x:Key="/Default/UserDictionary/Words/=hutopy/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

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

View File

@@ -0,0 +1,33 @@
users/
├── userId1/
│ ├── profile/
│ │ └── profilePicture.jpg
│ │ └── data.json
│ │
│ ├── posts/
│ │ ├── post1/
│ │ │ ├── image1.jpg
│ │ │ ├── video1.mp4
│ │ │ └── audio1.mp3
│ │ ├── post2/
│ │ │ ├── image2.jpg
│ │ │ └── video2.mp4
│ │ └── ...
├── userId2/
│ ├── profile/
│ │ └── profilePicture.jpg
│ │ └── data.json
│ │
│ ├── posts/
│ │ ├── post1/
│ │ │ ├── image1.jpg
│ │ │ ├── video1.mp4
│ │ │ └── audio1.mp3
│ │ ├── post2/
│ │ │ ├── image2.jpg
│ │ │ └── video2.mp4
│ │ └── ...
└── ...

View File

@@ -0,0 +1,8 @@
namespace Socialize.Infrastructure.BlobStorage.Contracts;
public static class CommonFileNames
{
public const string ProfilePicture = "profilePicture";
public const string LogoPicture = "logoPicture";
public const string BannerPicture = "bannerPicture";
}

View File

@@ -0,0 +1,8 @@
namespace Socialize.Infrastructure.BlobStorage.Contracts;
internal static class ContainerNames
{
public const string Users = "users";
public const string Clients = "clients";
public const string Creators = "creators";
}

View File

@@ -0,0 +1,44 @@
using System.Text;
namespace Socialize.Infrastructure.BlobStorage.Contracts;
public static class ContentTypes
{
private const string ImagePng = "image/png";
private const string ImageJpeg = "image/jpeg";
private const string ImageJpg = "image/jpg";
private const string TextHtml = "text/html";
private static readonly HashSet<string> AllowedContentTypes = [ImagePng, ImageJpeg, ImageJpg, TextHtml];
public static bool IsAllowed(
string contentType,
Stream fileStream)
{
return IsValidFileType(fileStream) && AllowedContentTypes.Contains(contentType);
}
private static bool IsValidFileType(
Stream fileStream)
{
byte[] buffer = new byte[512];
_ = fileStream.Read(buffer, 0, buffer.Length);
fileStream.Position = 0;
// PNG file signature: 89 50 4E 47 (in hex)
if (buffer[0] == 0x89 && buffer[1] == 0x50 && buffer[2] == 0x4E && buffer[3] == 0x47)
{
return true;
}
// JPEG file signature: FF D8 FF (in hex)
if (buffer[0] == 0xFF && buffer[1] == 0xD8 && buffer[2] == 0xFF)
{
return true;
}
// Check for HTML content by looking for "<!DOCTYPE html>" or "<html>" tags
string content = Encoding.UTF8.GetString(buffer);
return content.Contains("<!DOCTYPE html>");
}
}

View File

@@ -0,0 +1,32 @@
namespace Socialize.Infrastructure.BlobStorage.Contracts;
public interface IBlobStorage
{
/// <summary>
/// Upload a file to 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>
Task<string> UploadFileAsync(
string containerName,
string blobName,
Stream stream,
string contentType,
CancellationToken ct = default);
/// <summary>
/// Download a file to 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>
Task<MemoryStream> DownloadFileAsync(
string containerName,
string blobName,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
namespace Socialize.Infrastructure.BlobStorage.Contracts;
public static class SubDirectoryNames
{
public const string Profile = "profile";
public const string Contents = "contents";
public const string Albums = "albums";
}

View File

@@ -0,0 +1,154 @@
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,8 @@
namespace Socialize.Infrastructure.Configuration;
public class WebsiteOptions
{
public const string SectionName = "Website";
public string FrontendBaseUrl { get; set; } = "http://localhost:5173";
}

View File

@@ -0,0 +1,32 @@
using Socialize.Infrastructure.BlobStorage.Contracts;
using Socialize.Infrastructure.BlobStorage.Services;
using Socialize.Infrastructure.Configuration;
using Socialize.Infrastructure.Emailer.Configuration;
using Socialize.Infrastructure.Emailer.Contracts;
using Socialize.Infrastructure.Emailer.Services;
using Socialize.Infrastructure.Payments.Stripe.Configuration;
namespace Socialize.Infrastructure;
public static class DependencyInjection
{
public static WebApplicationBuilder AddInfrastructureModule(
this WebApplicationBuilder builder)
{
builder.Services.Configure<WebsiteOptions>(
builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName));
builder.Services.AddTransient<IBlobStorage, AzureBlobStorage>();
builder.Services.Configure<StripeOptions>(
builder.Configuration.GetSection(StripeOptions.ConfigurationSection));
builder.Services.Configure<EmailerOptions>(
builder.Configuration.GetSection(EmailerOptions.ConfigurationSection));
builder.Services.AddTransient<IEmailSender, ResendEmailSender>();
//builder.Services.AddTransient<IEmailSender, EmailSender>();
builder.Services.AddHttpClient();
return builder;
}
}

View File

@@ -0,0 +1,633 @@
using System.Security.Claims;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Contracts;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Socialize.Infrastructure.Development;
public static class DevelopmentSeedExtensions
{
private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222");
private static readonly Guid HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333");
private static readonly Guid ScopedProjectId = Guid.Parse("33333333-3333-3333-3333-333333333333");
private static readonly Guid HiddenProjectId = Guid.Parse("33333333-3333-3333-3333-444444444444");
private static readonly Guid ScopedContentItemId = Guid.Parse("44444444-4444-4444-4444-444444444444");
private static readonly Guid HiddenContentItemId = Guid.Parse("44444444-4444-4444-4444-555555555555");
private static readonly Guid ScopedAssetId = Guid.Parse("55555555-5555-5555-5555-555555555555");
private static readonly Guid ScopedApprovalRequestId = Guid.Parse("66666666-6666-6666-6666-666666666666");
private static readonly Guid ClientCommentId = Guid.Parse("77777777-7777-7777-7777-777777777777");
private static readonly Guid NotificationId = Guid.Parse("88888888-8888-8888-8888-888888888888");
public static async Task<IApplicationBuilder> UseDevelopmentSeedAsync(
this IApplicationBuilder app,
CancellationToken cancellationToken = default)
{
IHostEnvironment environment = app.ApplicationServices.GetRequiredService<IHostEnvironment>();
if (!environment.IsDevelopment())
{
return app;
}
using IServiceScope scope = app.ApplicationServices.CreateScope();
IOptions<DevelopmentSeedOptions> options = scope.ServiceProvider.GetRequiredService<IOptions<DevelopmentSeedOptions>>();
if (!options.Value.Enabled)
{
return app;
}
UserManager userManager = scope.ServiceProvider.GetRequiredService<UserManager>();
AppDbContext dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await RemoveLegacyDevUserAsync(userManager);
User manager = await EnsureUserAsync(
userManager,
id: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
username: "manager",
email: "manager@socialize.local",
password: "manager",
alias: "Northstar Manager",
firstname: "Morgan",
lastname: "Reid",
portraitUrl: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80",
roles: [KnownRoles.Administrator, KnownRoles.Manager, KnownRoles.WorkspaceMember],
claims:
[
new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()),
]);
User clientUser = await EnsureUserAsync(
userManager,
id: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
username: "client",
email: "client@socialize.local",
password: "client",
alias: "Sofia Martin",
firstname: "Sofia",
lastname: "Martin",
portraitUrl: "https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=200&q=80",
roles: [KnownRoles.Client, KnownRoles.WorkspaceMember],
claims:
[
new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()),
new Claim(KnownClaims.ClientScope, ScopedClientId.ToString()),
]);
User provider = await EnsureUserAsync(
userManager,
id: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
username: "provider",
email: "provider@socialize.local",
password: "provider",
alias: "Alex Studio",
firstname: "Alex",
lastname: "Studio",
portraitUrl: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=200&q=80",
roles: [KnownRoles.Provider, KnownRoles.WorkspaceMember],
claims:
[
new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()),
new Claim(KnownClaims.ClientScope, ScopedClientId.ToString()),
new Claim(KnownClaims.ProjectScope, ScopedProjectId.ToString()),
]);
await EnsureWorkspaceDataAsync(
manager.Id,
clientUser.Id,
provider.Id,
dbContext,
cancellationToken);
return app;
}
private static async Task RemoveLegacyDevUserAsync(UserManager userManager)
{
User? legacyUser = await userManager.FindByNameAsync("dev")
?? await userManager.FindByEmailAsync("dev@socialize.local");
if (legacyUser is null)
{
return;
}
await userManager.DeleteAsync(legacyUser);
}
private static async Task<User> EnsureUserAsync(
UserManager userManager,
Guid id,
string username,
string email,
string password,
string alias,
string firstname,
string lastname,
string? portraitUrl,
IReadOnlyCollection<string> roles,
IReadOnlyCollection<Claim> claims)
{
User? user = await userManager.FindByNameAsync(username)
?? await userManager.FindByEmailAsync(email);
if (user is null)
{
user = new User
{
Id = id,
UserName = username,
Email = email,
Alias = alias,
Firstname = firstname,
Lastname = lastname,
PortraitUrl = portraitUrl,
EmailConfirmed = true,
};
IdentityResult createResult = await userManager.CreateAsync(user, password);
if (!createResult.Succeeded)
{
throw new InvalidOperationException(
$"Failed to seed development user '{username}': {string.Join(", ", createResult.Errors.Select(error => error.Description))}");
}
}
user.UserName = username;
user.Email = email;
user.Alias = alias;
user.Firstname = firstname;
user.Lastname = lastname;
user.PortraitUrl = portraitUrl;
user.EmailConfirmed = true;
await userManager.UpdateAsync(user);
if (!await userManager.CheckPasswordAsync(user, password))
{
string resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
IdentityResult passwordResetResult = await userManager.ResetPasswordAsync(user, resetToken, password);
if (!passwordResetResult.Succeeded)
{
throw new InvalidOperationException(
$"Failed to set development password for '{username}': {string.Join(", ", passwordResetResult.Errors.Select(error => error.Description))}");
}
}
IList<string> existingRoles = await userManager.GetRolesAsync(user);
foreach (string role in roles.Except(existingRoles, StringComparer.Ordinal))
{
await userManager.AddToRoleAsync(user, role);
}
foreach (string role in existingRoles
.Where(role => role is KnownRoles.Manager or KnownRoles.Client or KnownRoles.Provider or KnownRoles.Administrator or KnownRoles.WorkspaceMember)
.Except(roles, StringComparer.Ordinal))
{
await userManager.RemoveFromRoleAsync(user, role);
}
IList<Claim> existingClaims = await userManager.GetClaimsAsync(user);
List<Claim> managedClaims = existingClaims
.Where(claim => claim.Type is KnownClaims.WorkspaceScope or KnownClaims.ClientScope or KnownClaims.ProjectScope or KnownClaims.Persona)
.ToList();
foreach (Claim claim in managedClaims)
{
await userManager.RemoveClaimAsync(user, claim);
}
string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal)
? KnownRoles.Manager
: roles.Contains(KnownRoles.Client, StringComparer.Ordinal)
? KnownRoles.Client
: roles.Contains(KnownRoles.Provider, StringComparer.Ordinal)
? KnownRoles.Provider
: KnownRoles.WorkspaceMember;
foreach (Claim claim in claims.Concat([new Claim(KnownClaims.Persona, persona)]))
{
await userManager.AddClaimAsync(user, claim);
}
return user;
}
private static async Task EnsureWorkspaceDataAsync(
Guid managerUserId,
Guid clientUserId,
Guid providerUserId,
AppDbContext dbContext,
CancellationToken cancellationToken)
{
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == WorkspaceId, cancellationToken);
if (workspace is null)
{
workspace = new Workspace
{
Id = WorkspaceId,
Name = string.Empty,
Slug = string.Empty,
TimeZone = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Workspaces.Add(workspace);
}
workspace.Name = "Northstar Studio";
workspace.Slug = "northstar-studio";
workspace.OwnerUserId = managerUserId;
workspace.TimeZone = "America/Montreal";
await dbContext.SaveChangesAsync(cancellationToken);
await UpsertClientAsync(
dbContext,
ScopedClientId,
"Luma Coffee",
"Active",
"https://images.unsplash.com/photo-1511920170033-f8396924c348?auto=format&fit=crop&w=200&q=80",
"Sofia Martin",
"client@socialize.local",
WorkspaceId,
cancellationToken);
await UpsertClientAsync(
dbContext,
HiddenClientId,
"Atlas Bakery",
"Active",
"https://images.unsplash.com/photo-1509440159596-0249088772ff?auto=format&fit=crop&w=200&q=80",
"Nina Cole",
"nina@atlasbakery.test",
WorkspaceId,
cancellationToken);
await UpsertProjectAsync(
dbContext,
ScopedProjectId,
WorkspaceId,
ScopedClientId,
"Spring Launch",
"In progress",
DateTimeOffset.UtcNow.AddDays(1),
DateTimeOffset.UtcNow.AddDays(7),
"Cross-channel launch campaign for the spring offer.",
"Coordinate creative approvals before the final week.",
cancellationToken);
await UpsertProjectAsync(
dbContext,
HiddenProjectId,
WorkspaceId,
HiddenClientId,
"Summer Retention",
"Planned",
DateTimeOffset.UtcNow.AddDays(10),
DateTimeOffset.UtcNow.AddDays(16),
"Retention campaign aimed at existing subscribers.",
"Sequence email and paid social updates together.",
cancellationToken);
await UpsertContentItemAsync(
dbContext,
ScopedContentItemId,
WorkspaceId,
ScopedClientId,
ScopedProjectId,
"Spring launch hero video",
"Fresh seasonal menu launch across Instagram and TikTok.",
"Instagram Reel, TikTok",
"In client review",
DateTimeOffset.UtcNow.AddDays(3),
"v3",
3,
cancellationToken);
await UpsertContentItemAsync(
dbContext,
HiddenContentItemId,
WorkspaceId,
HiddenClientId,
HiddenProjectId,
"Bakery loyalty carousel",
"Reward regular customers with a four-card retention carousel.",
"Instagram Carousel",
"Draft",
DateTimeOffset.UtcNow.AddDays(10),
"v1",
1,
cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000001"), ScopedContentItemId, 1, "v1", "Spring launch hero video", "Initial draft for the seasonal menu launch.", "Instagram Reel, TikTok", "Initial concept draft.", providerUserId, DateTimeOffset.UtcNow.AddDays(-5), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000002"), ScopedContentItemId, 2, "v2", "Spring launch hero video", "Updated hook and transitions after internal review.", "Instagram Reel, TikTok", "Addressed internal pacing feedback.", providerUserId, DateTimeOffset.UtcNow.AddDays(-3), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000003"), ScopedContentItemId, 3, "v3", "Spring launch hero video", "Fresh seasonal menu launch across Instagram and TikTok.", "Instagram Reel, TikTok", "Client-facing draft after copy cleanup.", providerUserId, DateTimeOffset.UtcNow.AddDays(-1), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000004"), HiddenContentItemId, 1, "v1", "Bakery loyalty carousel", "Reward regular customers with a four-card retention carousel.", "Instagram Carousel", "First draft.", managerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken);
Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == ScopedAssetId, cancellationToken);
if (asset is null)
{
asset = new Asset
{
Id = ScopedAssetId,
AssetType = string.Empty,
SourceType = string.Empty,
DisplayName = string.Empty,
CreatedAt = DateTimeOffset.UtcNow.AddDays(-4),
};
dbContext.Assets.Add(asset);
}
asset.WorkspaceId = WorkspaceId;
asset.ContentItemId = ScopedContentItemId;
asset.AssetType = "Video";
asset.SourceType = "GoogleDrive";
asset.DisplayName = "Spring launch cut";
asset.GoogleDriveFileId = "dev-socialize-demo";
asset.GoogleDriveLink = "https://drive.google.com/file/d/dev-socialize-demo/view";
asset.PreviewUrl = "https://drive.google.com/thumbnail?id=dev-socialize-demo";
asset.CurrentRevisionNumber = 2;
await dbContext.SaveChangesAsync(cancellationToken);
await EnsureAssetRevisionAsync(dbContext, Guid.Parse("55555555-5555-5555-5555-000000000001"), ScopedAssetId, 1, "https://drive.google.com/file/d/dev-socialize-demo-v1/view", "https://drive.google.com/thumbnail?id=dev-socialize-demo-v1", "First uploaded cut from the editor.", providerUserId, DateTimeOffset.UtcNow.AddDays(-4), cancellationToken);
await EnsureAssetRevisionAsync(dbContext, Guid.Parse("55555555-5555-5555-5555-000000000002"), ScopedAssetId, 2, "https://drive.google.com/file/d/dev-socialize-demo-v2/view", "https://drive.google.com/thumbnail?id=dev-socialize-demo-v2", "Re-export with pacing changes and updated title card.", providerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken);
Comment? comment = await dbContext.Comments.SingleOrDefaultAsync(candidate => candidate.Id == ClientCommentId, cancellationToken);
if (comment is null)
{
comment = new Comment
{
Id = ClientCommentId,
AuthorDisplayName = string.Empty,
AuthorEmail = string.Empty,
Body = string.Empty,
CreatedAt = DateTimeOffset.UtcNow.AddHours(-20),
};
dbContext.Comments.Add(comment);
}
comment.WorkspaceId = WorkspaceId;
comment.ContentItemId = ScopedContentItemId;
comment.AuthorUserId = clientUserId;
comment.AuthorDisplayName = "Sofia Martin";
comment.AuthorEmail = "client@socialize.local";
comment.Body = "Please tighten the opening three seconds and make the launch CTA more explicit.";
comment.IsResolved = false;
comment.ResolvedAt = null;
await dbContext.SaveChangesAsync(cancellationToken);
ApprovalRequest? approvalRequest = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == ScopedApprovalRequestId, cancellationToken);
if (approvalRequest is null)
{
approvalRequest = new ApprovalRequest
{
Id = ScopedApprovalRequestId,
Stage = string.Empty,
ReviewerName = string.Empty,
ReviewerEmail = string.Empty,
State = string.Empty,
AccessToken = string.Empty,
SentAt = DateTimeOffset.UtcNow.AddHours(-12),
};
dbContext.ApprovalRequests.Add(approvalRequest);
}
approvalRequest.WorkspaceId = WorkspaceId;
approvalRequest.ContentItemId = ScopedContentItemId;
approvalRequest.Stage = "Client";
approvalRequest.ReviewerName = "Sofia Martin";
approvalRequest.ReviewerEmail = "client@socialize.local";
approvalRequest.RequestedByUserId = managerUserId;
approvalRequest.DueAt = DateTimeOffset.UtcNow.AddDays(1);
approvalRequest.State = "Pending";
approvalRequest.AccessToken = "seed-client-review-token";
approvalRequest.CompletedAt = null;
await dbContext.SaveChangesAsync(cancellationToken);
NotificationEvent? approvalNotification = await dbContext.NotificationEvents.SingleOrDefaultAsync(candidate => candidate.Id == NotificationId, cancellationToken);
if (approvalNotification is null)
{
approvalNotification = new NotificationEvent
{
Id = NotificationId,
EventType = string.Empty,
EntityType = string.Empty,
Message = string.Empty,
CreatedAt = DateTimeOffset.UtcNow.AddHours(-12),
};
dbContext.NotificationEvents.Add(approvalNotification);
}
approvalNotification.WorkspaceId = WorkspaceId;
approvalNotification.ContentItemId = ScopedContentItemId;
approvalNotification.EventType = "approval.requested";
approvalNotification.EntityType = "ApprovalRequest";
approvalNotification.EntityId = ScopedApprovalRequestId;
approvalNotification.Message = "Approval requested from Sofia Martin for Spring launch hero video.";
approvalNotification.RecipientEmail = "client@socialize.local";
approvalNotification.MetadataJson = """{"stage":"Client"}""";
approvalNotification.ReadAt = null;
Guid commentNotificationId = Guid.Parse("88888888-8888-8888-8888-000000000002");
NotificationEvent? commentNotification = await dbContext.NotificationEvents.SingleOrDefaultAsync(candidate => candidate.Id == commentNotificationId, cancellationToken);
if (commentNotification is null)
{
commentNotification = new NotificationEvent
{
Id = commentNotificationId,
EventType = string.Empty,
EntityType = string.Empty,
Message = string.Empty,
CreatedAt = DateTimeOffset.UtcNow.AddHours(-20),
};
dbContext.NotificationEvents.Add(commentNotification);
}
commentNotification.WorkspaceId = WorkspaceId;
commentNotification.ContentItemId = ScopedContentItemId;
commentNotification.EventType = "comment.created";
commentNotification.EntityType = "Comment";
commentNotification.EntityId = ClientCommentId;
commentNotification.Message = "Sofia Martin commented on Spring launch hero video.";
commentNotification.RecipientUserId = managerUserId;
commentNotification.RecipientEmail = "manager@socialize.local";
commentNotification.MetadataJson = null;
commentNotification.ReadAt = null;
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task UpsertClientAsync(
AppDbContext dbContext,
Guid id,
string name,
string status,
string portraitUrl,
string primaryContactName,
string primaryContactEmail,
Guid workspaceId,
CancellationToken cancellationToken)
{
Client? client = await dbContext.Clients.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
if (client is null)
{
client = new Client
{
Id = id,
Name = string.Empty,
Status = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Clients.Add(client);
}
client.WorkspaceId = workspaceId;
client.Name = name;
client.Status = status;
client.PortraitUrl = portraitUrl;
client.PrimaryContactName = primaryContactName;
client.PrimaryContactEmail = primaryContactEmail;
client.PrimaryContactPortraitUrl = null;
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task UpsertProjectAsync(
AppDbContext dbContext,
Guid id,
Guid workspaceId,
Guid clientId,
string name,
string status,
DateTimeOffset startDate,
DateTimeOffset endDate,
string? description,
string? notes,
CancellationToken cancellationToken)
{
Project? project = await dbContext.Projects.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
if (project is null)
{
project = new Project
{
Id = id,
Name = string.Empty,
Status = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Projects.Add(project);
}
project.WorkspaceId = workspaceId;
project.ClientId = clientId;
project.Name = name;
project.Description = description;
project.Notes = notes;
project.Status = status;
project.StartDate = startDate;
project.EndDate = endDate;
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task UpsertContentItemAsync(
AppDbContext dbContext,
Guid id,
Guid workspaceId,
Guid clientId,
Guid projectId,
string title,
string publicationMessage,
string publicationTargets,
string status,
DateTimeOffset? dueDate,
string currentRevisionLabel,
int currentRevisionNumber,
CancellationToken cancellationToken)
{
ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
if (item is null)
{
item = new ContentItem
{
Id = id,
Title = string.Empty,
PublicationMessage = string.Empty,
PublicationTargets = string.Empty,
Status = string.Empty,
CurrentRevisionLabel = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.ContentItems.Add(item);
}
item.WorkspaceId = workspaceId;
item.ClientId = clientId;
item.ProjectId = projectId;
item.Title = title;
item.PublicationMessage = publicationMessage;
item.PublicationTargets = publicationTargets;
item.Status = status;
item.DueDate = dueDate;
item.CurrentRevisionLabel = currentRevisionLabel;
item.CurrentRevisionNumber = currentRevisionNumber;
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task EnsureRevisionAsync(
AppDbContext dbContext,
Guid id,
Guid contentItemId,
int revisionNumber,
string revisionLabel,
string title,
string publicationMessage,
string publicationTargets,
string changeSummary,
Guid createdByUserId,
DateTimeOffset createdAt,
CancellationToken cancellationToken)
{
ContentItemRevision? revision = await dbContext.ContentItemRevisions.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
if (revision is null)
{
revision = new ContentItemRevision
{
Id = id,
RevisionLabel = string.Empty,
Title = string.Empty,
PublicationMessage = string.Empty,
PublicationTargets = string.Empty,
CreatedAt = createdAt,
};
dbContext.ContentItemRevisions.Add(revision);
}
revision.ContentItemId = contentItemId;
revision.RevisionNumber = revisionNumber;
revision.RevisionLabel = revisionLabel;
revision.Title = title;
revision.PublicationMessage = publicationMessage;
revision.PublicationTargets = publicationTargets;
revision.ChangeSummary = changeSummary;
revision.CreatedByUserId = createdByUserId;
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task EnsureAssetRevisionAsync(
AppDbContext dbContext,
Guid id,
Guid assetId,
int revisionNumber,
string sourceReference,
string? previewUrl,
string? notes,
Guid createdByUserId,
DateTimeOffset createdAt,
CancellationToken cancellationToken)
{
AssetRevision? revision = await dbContext.AssetRevisions.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
if (revision is null)
{
revision = new AssetRevision
{
Id = id,
SourceReference = string.Empty,
CreatedAt = createdAt,
};
dbContext.AssetRevisions.Add(revision);
}
revision.AssetId = assetId;
revision.RevisionNumber = revisionNumber;
revision.SourceReference = sourceReference;
revision.PreviewUrl = previewUrl;
revision.Notes = notes;
revision.CreatedByUserId = createdByUserId;
await dbContext.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,8 @@
namespace Socialize.Infrastructure.Development;
public record DevelopmentSeedOptions
{
public const string SectionName = "DevelopmentSeed";
public bool Enabled { get; init; } = true;
}

View File

@@ -0,0 +1,9 @@
namespace Socialize.Infrastructure.Emailer.Configuration;
public class EmailerOptions
{
public const string ConfigurationSection = "Emailer";
public string ApiKey { get; set; } = default!;
public string FromEmail { get; set; } = default!;
}

View File

@@ -0,0 +1,6 @@
namespace Socialize.Infrastructure.Emailer.Contracts;
public interface IEmailSender
{
Task SendEmailAsync(string email, string subject, string message);
}

View File

@@ -0,0 +1,22 @@
using Socialize.Infrastructure.Emailer.Contracts;
namespace Socialize.Infrastructure.Emailer.Services;
public class LoggerEmailSender(ILogger<IEmailSender> logger)
: IEmailSender
{
public async Task SendEmailAsync(string email, string subject, string message)
{
try
{
logger.LogInformation("Sending email to {Email} with subject: {Subject}", email, subject);
await Task.Delay(1000);
logger.LogInformation("Email sent successfully to {Email}", email);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send email to {Email}", email);
throw;
}
}
}

View File

@@ -0,0 +1,37 @@
using Socialize.Infrastructure.Emailer.Configuration;
using Socialize.Infrastructure.Emailer.Contracts;
using Microsoft.Extensions.Options;
using PostmarkDotNet;
namespace Socialize.Infrastructure.Emailer.Services;
public class PostmarkEmailSender : IEmailSender
{
private readonly PostmarkClient _client;
private readonly EmailerOptions _options;
public PostmarkEmailSender(IOptions<EmailerOptions> options)
{
_options = options.Value;
_client = new PostmarkClient(_options.ApiKey);
}
public async Task SendEmailAsync(string email, string subject, string message)
{
PostmarkResponse? sendResult = await _client.SendMessageAsync(new PostmarkMessage
{
From = _options.FromEmail,
To = email,
Subject = subject,
HtmlBody = message,
TrackOpens = true,
MessageStream = "outbound" // Optional: use "broadcast" for bulk
});
if (sendResult.Status != PostmarkStatus.Success)
{
throw new InvalidOperationException(
$"Postmark failed to send email: {sendResult.Message}");
}
}
}

View File

@@ -0,0 +1,46 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Socialize.Infrastructure.Emailer.Configuration;
using Socialize.Infrastructure.Emailer.Contracts;
using Microsoft.Extensions.Options;
namespace Socialize.Infrastructure.Emailer.Services;
public class ResendEmailSender : IEmailSender
{
private static readonly Uri EndpointUri = new("https://api.resend.com/emails");
private readonly HttpClient _httpClient;
private readonly EmailerOptions _options;
public ResendEmailSender(
IHttpClientFactory httpClientFactory,
IOptions<EmailerOptions> options)
{
_httpClient = httpClientFactory.CreateClient();
_options = options.Value;
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _options.ApiKey);
_httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
}
public async Task SendEmailAsync(string toEmail, string subject, string htmlMessage)
{
var payload = new { from = _options.FromEmail, to = toEmail, subject, html = htmlMessage };
string json = JsonSerializer.Serialize(payload);
StringContent content = new(json, Encoding.UTF8, "application/json");
HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException(
$"Resend email failed: {response.StatusCode} - {body}");
}
}
}

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Socialize.Infrastructure.Payments.Stripe.Configuration;
public class StripeOptions
{
public const string ConfigurationSection = "Stripe";
[Required] public required string SecretKey { get; init; }
[Required] public required string WebhookSecret { get; init; }
[Required] [Range(0, 1)] public required decimal SocializeRate { get; init; }
}

View File

@@ -0,0 +1,56 @@
using System.Security.Claims;
using Socialize.Modules.Identity.Contracts;
namespace Socialize.Infrastructure.Security;
public sealed class AccessScopeService
{
public bool IsManager(ClaimsPrincipal user)
{
return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager);
}
public bool IsProvider(ClaimsPrincipal user)
{
return user.IsInRole(KnownRoles.Provider);
}
public bool IsClient(ClaimsPrincipal user)
{
return user.IsInRole(KnownRoles.Client);
}
public bool CanAccessWorkspace(ClaimsPrincipal user, Guid workspaceId)
{
return IsManager(user) || user.GetWorkspaceScopeIds().Contains(workspaceId);
}
public bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId)
{
return IsManager(user) && CanAccessWorkspace(user, workspaceId);
}
public bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId)
{
return IsManager(user)
|| (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId));
}
public bool CanAccessProject(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId)
{
return IsManager(user)
|| (CanAccessClient(user, workspaceId, clientId) && user.GetProjectScopeIds().Contains(projectId));
}
public bool CanContributeToProject(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId)
{
return IsManager(user) || (IsProvider(user) && CanAccessProject(user, workspaceId, clientId, projectId));
}
public bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId)
{
return IsManager(user)
|| IsProvider(user) && CanAccessProject(user, workspaceId, clientId, projectId)
|| IsClient(user) && CanAccessClient(user, workspaceId, clientId);
}
}

View File

@@ -0,0 +1,91 @@
using System.Security.Claims;
namespace Socialize.Infrastructure.Security;
public static class ClaimsPrincipalExtensions
{
public static IReadOnlyCollection<Guid> GetScopeIds(this ClaimsPrincipal claims, string key)
{
return claims.FindAll(key)
.Select(claim => Guid.TryParse(claim.Value, out Guid value) ? value : Guid.Empty)
.Where(value => value != Guid.Empty)
.Distinct()
.ToArray();
}
public static IReadOnlyCollection<Guid> GetWorkspaceScopeIds(this ClaimsPrincipal claims)
{
return claims.GetScopeIds(KnownClaims.WorkspaceScope);
}
public static IReadOnlyCollection<Guid> GetClientScopeIds(this ClaimsPrincipal claims)
{
return claims.GetScopeIds(KnownClaims.ClientScope);
}
public static IReadOnlyCollection<Guid> GetProjectScopeIds(this ClaimsPrincipal claims)
{
return claims.GetScopeIds(KnownClaims.ProjectScope);
}
public static string? GetPersona(this ClaimsPrincipal claims)
{
return (string?)claims.GetClaim<string?>(KnownClaims.Persona);
}
public static Guid GetUserId(this ClaimsPrincipal claims)
{
return (Guid)claims.GetRequiredClaim<Guid>(ClaimTypes.NameIdentifier);
}
public static string GetName(this ClaimsPrincipal claims)
{
return (string)claims.GetRequiredClaim<string>(ClaimTypes.Name);
}
public static string? GetAlias(this ClaimsPrincipal claims)
{
return (string?)claims.GetClaim<string?>(KnownClaims.Alias);
}
public static string? GetPortraitUrl(this ClaimsPrincipal claims)
{
return (string?)claims.GetClaim<string?>(KnownClaims.PortraitUrl);
}
public static string GetFirstName(this ClaimsPrincipal claims)
{
return (string)claims.GetRequiredClaim<string>(ClaimTypes.GivenName);
}
public static string GetLastName(this ClaimsPrincipal claims)
{
return (string)claims.GetRequiredClaim<string>(ClaimTypes.Surname);
}
public static string GetEmail(this ClaimsPrincipal claims)
{
return (string)claims.GetRequiredClaim<string>(ClaimTypes.Email);
}
private static object? GetClaim<TValue>(this ClaimsPrincipal claims, string key)
{
Claim? claim = claims.FindFirst(key);
return claim is null ? null : claims.GetRequiredClaim<TValue>(key);
}
private static object GetRequiredClaim<TValue>(this ClaimsPrincipal claims, string key)
{
Claim? claim = claims.FindFirst(key);
if (claim is null)
{
throw new MissingClaimException(key);
}
return typeof(TValue) == typeof(Guid)
? Guid.Parse(claim.Value)
: Convert.ChangeType(claim.Value, typeof(TValue));
}
}

View File

@@ -0,0 +1,66 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace Socialize.Infrastructure.Security;
public static class JwtTokenHelper
{
public static string GenerateJwtToken(
TimeSpan expiresIn,
string issuer,
string audience,
string key,
string userId,
string email,
string? alias,
string firstname,
string lastname,
string? portraitUrl,
IEnumerable<string> roles,
IEnumerable<Claim> additionalClaims)
{
SymmetricSecurityKey securityKey = new(Encoding.UTF8.GetBytes(key));
SigningCredentials credentials = new(securityKey, SecurityAlgorithms.HmacSha256);
List<Claim> claims = new([
new Claim(JwtRegisteredClaimNames.Sub, userId),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(ClaimTypes.NameIdentifier, userId), new Claim(ClaimTypes.Email, email),
new Claim(ClaimTypes.Name, email), new Claim(ClaimTypes.GivenName, firstname),
new Claim(ClaimTypes.Surname, lastname)
]);
if (alias is not null)
{
claims.Add(new Claim(KnownClaims.Alias, alias));
}
if (portraitUrl is not null)
{
claims.Add(new Claim(KnownClaims.PortraitUrl, portraitUrl));
}
foreach (string role in roles.Distinct(StringComparer.Ordinal))
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
foreach (Claim claim in additionalClaims
.Where(claim => !string.IsNullOrWhiteSpace(claim.Type) && !string.IsNullOrWhiteSpace(claim.Value))
.DistinctBy(claim => $"{claim.Type}:{claim.Value}"))
{
claims.Add(claim);
}
JwtSecurityToken token = new(
issuer,
audience,
claims,
expires: DateTime.Now.Add(expiresIn),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}

View File

@@ -0,0 +1,11 @@
namespace Socialize.Infrastructure.Security;
public static class KnownClaims
{
public const string Alias = "alias";
public const string PortraitUrl = "portraitUrl";
public const string WorkspaceScope = "workspace";
public const string ClientScope = "client";
public const string ProjectScope = "project";
public const string Persona = "persona";
}

View File

@@ -0,0 +1,5 @@
namespace Socialize.Infrastructure.Security;
public class MissingClaimException(
string claimName)
: Exception($"Claim '{claimName}' is missing.");

View File

@@ -0,0 +1,92 @@
using System.Security.Cryptography;
using System.Text;
namespace Socialize.Infrastructure.Security;
// If we need to add special characters we can alternate between 2 pools.
public static class PasswordGenerator
{
private const string LowerLetters = "abcdefghijklmnopqrstuvwxyz";
private const string UpperLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
private const string Numbers = "0123456789";
private const string SpecialCharacters = "!@#$%^&*()_+-=[];',./`~{}|:\"<>?";
private static readonly Random Random = new();
public static string Next(
int length = 15,
bool requireNumber = true,
bool requireLowercase = true,
bool requireCapital = true,
bool requireSpecialCharacter = true)
{
// Create pools based on the requirements
StringBuilder characterPool = new();
if (requireNumber)
{
characterPool.Append(LowerLetters);
}
if (requireCapital)
{
characterPool.Append(UpperLetters);
}
if (requireNumber)
{
characterPool.Append(Numbers);
}
if (requireSpecialCharacter)
{
characterPool.Append(SpecialCharacters);
}
// Ensure that the length is within the specified bounds
char[] password = new char[length];
// Ensure at least one character from each required category is included
int index = 0;
if (requireLowercase)
{
password[index++] = LowerLetters[Random.Next(LowerLetters.Length)];
}
if (requireCapital)
{
password[index++] = UpperLetters[Random.Next(UpperLetters.Length)];
}
if (requireNumber)
{
password[index++] = Numbers[Random.Next(Numbers.Length)];
}
if (requireSpecialCharacter)
{
password[index++] = SpecialCharacters[Random.Next(SpecialCharacters.Length)];
}
// Fill the rest with the password
for (int i = index; i < length; i++)
{
password[i] = characterPool[RandomNumberGenerator.GetInt32(characterPool.Length)];
}
// Shuffle the password to randomize the placement of the required characters
Shuffle(password);
return new string(password);
}
private static void Shuffle(
char[] array)
{
for (int i = array.Length - 1; i > 0; i--)
{
int j = Random.Next(i + 1);
(array[i], array[j]) = (array[j], array[i]); // Swap elements
}
}
}

View File

@@ -0,0 +1,13 @@
using System.Security.Cryptography;
namespace Socialize.Infrastructure.Security;
public static class RefreshTokenGenerator
{
public static string Next()
{
byte[] randomNumber = new byte[32];
RandomNumberGenerator.Fill(randomNumber);
return Convert.ToBase64String(randomNumber);
}
}

View File

@@ -0,0 +1,74 @@
using System.Text.RegularExpressions;
namespace Socialize.Infrastructure.YouTube;
public static class YouTubeUrlHelper
{
private static readonly Regex VideoIdRegex = new(
@"(?:youtube\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)/|.*[?&]v=)|youtu\.be/)([^""&?/\s]{11})",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex ShortUrlRegex = new(
@"^[a-zA-Z0-9_-]{11}$",
RegexOptions.Compiled);
/// <summary>
/// Extracts the video ID from a YouTube URL or returns the input if it's already a video ID.
/// </summary>
/// <param name="input">The YouTube URL or video ID</param>
/// <returns>The extracted video ID or null if invalid</returns>
public static string? ExtractVideoId(string? input)
{
if (string.IsNullOrWhiteSpace(input))
{
return null;
}
// If it's already a valid video ID, return it
if (IsValidVideoId(input))
{
return input;
}
// Try to extract video ID from URL
Match match = VideoIdRegex.Match(input);
return match.Success ? match.Groups[1].Value : null;
}
/// <summary>
/// Validates if the input is a valid YouTube video ID.
/// </summary>
/// <param name="input">The video ID to validate</param>
/// <returns>True if the input is a valid video ID</returns>
public static bool IsValidVideoId(string? input)
{
if (string.IsNullOrWhiteSpace(input))
{
return false;
}
return ShortUrlRegex.IsMatch(input);
}
/// <summary>
/// Validates if the input is a valid YouTube URL or video ID.
/// </summary>
/// <param name="input">The URL or video ID to validate</param>
/// <returns>True if the input is a valid YouTube URL or video ID</returns>
public static bool IsValidYouTubeUrlOrId(string? input)
{
if (string.IsNullOrWhiteSpace(input))
{
return false;
}
// Check if it's a valid video ID
if (IsValidVideoId(input))
{
return true;
}
// Check if it's a valid YouTube URL
return VideoIdRegex.IsMatch(input);
}
}

View File

@@ -0,0 +1,942 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Socialize.Data;
#nullable disable
namespace Socialize.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260423061407_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalDecision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApprovalRequestId")
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("DecidedByEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("DecidedByName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("DecidedByUserId")
.HasColumnType("uuid");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("ApprovalRequestId");
b.ToTable("ApprovalDecisions", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AccessToken")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DueAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("RequestedByUserId")
.HasColumnType("uuid");
b.Property<string>("ReviewerEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ReviewerName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("SentAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Stage")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("ReviewerEmail");
b.HasIndex("WorkspaceId");
b.ToTable("ApprovalRequests", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Assets.Data.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssetType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<int>("CurrentRevisionNumber")
.HasColumnType("integer");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveFileId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveLink")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("PreviewUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("SourceType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("WorkspaceId");
b.ToTable("Assets", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Assets.Data.AssetRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AssetId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("CreatedByUserId")
.HasColumnType("uuid");
b.Property<string>("Notes")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("PreviewUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("RevisionNumber")
.HasColumnType("integer");
b.Property<string>("SourceReference")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("AssetId", "RevisionNumber")
.IsUnique();
b.ToTable("AssetRevisions", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Clients.Data.Client", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("PrimaryContactEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PrimaryContactName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PrimaryContactPortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "Name")
.IsUnique();
b.ToTable("Clients", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Comments.Data.Comment", 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<Guid>("AuthorUserId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<bool>("IsResolved")
.HasColumnType("boolean");
b.Property<Guid?>("ParentCommentId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("ParentCommentId");
b.HasIndex("WorkspaceId");
b.ToTable("Comments", (string)null);
});
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("CurrentRevisionLabel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("CurrentRevisionNumber")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Hashtags")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("PublicationMessage")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("PublicationTargets")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("ProjectId");
b.HasIndex("WorkspaceId");
b.ToTable("ContentItems", (string)null);
});
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItemRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ChangeSummary")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("CreatedByUserId")
.HasColumnType("uuid");
b.Property<string>("Hashtags")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("PublicationMessage")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("PublicationTargets")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RevisionLabel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("RevisionNumber")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("ContentItemId", "RevisionNumber")
.IsUnique();
b.ToTable("ContentItemRevisions", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Identity.Data.Role", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Identity.Data.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("Address")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Alias")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("BirthDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("FacebookId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Firstname")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Lastname")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("PortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("RefreshToken")
.HasMaxLength(44)
.HasColumnType("character varying(44)");
b.Property<DateTime>("RefreshTokenExpiryTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Notifications.Data.NotificationEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("EntityId")
.HasColumnType("uuid");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("MetadataJson")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset?>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("RecipientEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("RecipientUserId")
.HasColumnType("uuid");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("CreatedAt");
b.HasIndex("RecipientUserId");
b.HasIndex("WorkspaceId");
b.ToTable("NotificationEvents", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Projects.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Notes")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("WorkspaceId");
b.HasIndex("ClientId", "Name")
.IsUnique();
b.ToTable("Projects", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.Workspace", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TimeZone")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Workspaces", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.WorkspaceInvite", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("InvitedByUserId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "Email", "Status");
b.ToTable("WorkspaceInvites", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,657 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ApprovalDecisions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ApprovalRequestId = table.Column<Guid>(type: "uuid", nullable: false),
Decision = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Comment = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
DecidedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
DecidedByName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
DecidedByEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_ApprovalDecisions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ApprovalRequests",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
Stage = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ReviewerName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ReviewerEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
RequestedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
DueAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
State = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
AccessToken = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
SentAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ApprovalRequests", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Alias = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Firstname = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Lastname = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
BirthDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
PortraitUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
GoogleId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
FacebookId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
RefreshToken = table.Column<string>(type: "character varying(44)", maxLength: 44, nullable: true),
RefreshTokenExpiryTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AssetRevisions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
AssetId = table.Column<Guid>(type: "uuid", nullable: false),
RevisionNumber = table.Column<int>(type: "integer", nullable: false),
SourceReference = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
PreviewUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
Notes = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_AssetRevisions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Assets",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
AssetType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
SourceType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
DisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
GoogleDriveFileId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
GoogleDriveLink = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
PreviewUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
CurrentRevisionNumber = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Assets", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Clients",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Status = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
PortraitUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
PrimaryContactName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
PrimaryContactEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
PrimaryContactPortraitUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Clients", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Comments",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
ParentCommentId = table.Column<Guid>(type: "uuid", nullable: true),
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),
Body = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
IsResolved = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
ResolvedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Comments", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ContentItemRevisions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
RevisionNumber = table.Column<int>(type: "integer", nullable: false),
RevisionLabel = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
PublicationMessage = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
PublicationTargets = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
Hashtags = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
ChangeSummary = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_ContentItemRevisions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ContentItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ClientId = table.Column<Guid>(type: "uuid", nullable: false),
ProjectId = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
PublicationMessage = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
PublicationTargets = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
Hashtags = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
Status = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
DueDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CurrentRevisionLabel = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
CurrentRevisionNumber = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_ContentItems", x => x.Id);
});
migrationBuilder.CreateTable(
name: "NotificationEvents",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ContentItemId = table.Column<Guid>(type: "uuid", nullable: true),
EventType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
EntityType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
EntityId = table.Column<Guid>(type: "uuid", nullable: false),
Message = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
RecipientUserId = table.Column<Guid>(type: "uuid", nullable: true),
RecipientEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
MetadataJson = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
ReadAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NotificationEvents", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Projects",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
ClientId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Description = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
Notes = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
Status = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
StartDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
EndDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Projects", x => x.Id);
});
migrationBuilder.CreateTable(
name: "WorkspaceInvites",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Role = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Status = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
InvitedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_WorkspaceInvites", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Workspaces",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Slug = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
OwnerUserId = table.Column<Guid>(type: "uuid", nullable: false),
TimeZone = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Workspaces", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
RoleId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ApprovalDecisions_ApprovalRequestId",
table: "ApprovalDecisions",
column: "ApprovalRequestId");
migrationBuilder.CreateIndex(
name: "IX_ApprovalRequests_ContentItemId",
table: "ApprovalRequests",
column: "ContentItemId");
migrationBuilder.CreateIndex(
name: "IX_ApprovalRequests_ReviewerEmail",
table: "ApprovalRequests",
column: "ReviewerEmail");
migrationBuilder.CreateIndex(
name: "IX_ApprovalRequests_WorkspaceId",
table: "ApprovalRequests",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AssetRevisions_AssetId",
table: "AssetRevisions",
column: "AssetId");
migrationBuilder.CreateIndex(
name: "IX_AssetRevisions_AssetId_RevisionNumber",
table: "AssetRevisions",
columns: new[] { "AssetId", "RevisionNumber" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Assets_ContentItemId",
table: "Assets",
column: "ContentItemId");
migrationBuilder.CreateIndex(
name: "IX_Assets_WorkspaceId",
table: "Assets",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_Clients_WorkspaceId",
table: "Clients",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_Clients_WorkspaceId_Name",
table: "Clients",
columns: new[] { "WorkspaceId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Comments_ContentItemId",
table: "Comments",
column: "ContentItemId");
migrationBuilder.CreateIndex(
name: "IX_Comments_ParentCommentId",
table: "Comments",
column: "ParentCommentId");
migrationBuilder.CreateIndex(
name: "IX_Comments_WorkspaceId",
table: "Comments",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_ContentItemRevisions_ContentItemId",
table: "ContentItemRevisions",
column: "ContentItemId");
migrationBuilder.CreateIndex(
name: "IX_ContentItemRevisions_ContentItemId_RevisionNumber",
table: "ContentItemRevisions",
columns: new[] { "ContentItemId", "RevisionNumber" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ContentItems_ClientId",
table: "ContentItems",
column: "ClientId");
migrationBuilder.CreateIndex(
name: "IX_ContentItems_ProjectId",
table: "ContentItems",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_ContentItems_WorkspaceId",
table: "ContentItems",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_NotificationEvents_ContentItemId",
table: "NotificationEvents",
column: "ContentItemId");
migrationBuilder.CreateIndex(
name: "IX_NotificationEvents_CreatedAt",
table: "NotificationEvents",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_NotificationEvents_RecipientUserId",
table: "NotificationEvents",
column: "RecipientUserId");
migrationBuilder.CreateIndex(
name: "IX_NotificationEvents_WorkspaceId",
table: "NotificationEvents",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_Projects_ClientId",
table: "Projects",
column: "ClientId");
migrationBuilder.CreateIndex(
name: "IX_Projects_ClientId_Name",
table: "Projects",
columns: new[] { "ClientId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Projects_WorkspaceId",
table: "Projects",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_WorkspaceInvites_WorkspaceId",
table: "WorkspaceInvites",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_WorkspaceInvites_WorkspaceId_Email_Status",
table: "WorkspaceInvites",
columns: new[] { "WorkspaceId", "Email", "Status" });
migrationBuilder.CreateIndex(
name: "IX_Workspaces_OwnerUserId",
table: "Workspaces",
column: "OwnerUserId");
migrationBuilder.CreateIndex(
name: "IX_Workspaces_Slug",
table: "Workspaces",
column: "Slug",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ApprovalDecisions");
migrationBuilder.DropTable(
name: "ApprovalRequests");
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "AssetRevisions");
migrationBuilder.DropTable(
name: "Assets");
migrationBuilder.DropTable(
name: "Clients");
migrationBuilder.DropTable(
name: "Comments");
migrationBuilder.DropTable(
name: "ContentItemRevisions");
migrationBuilder.DropTable(
name: "ContentItems");
migrationBuilder.DropTable(
name: "NotificationEvents");
migrationBuilder.DropTable(
name: "Projects");
migrationBuilder.DropTable(
name: "WorkspaceInvites");
migrationBuilder.DropTable(
name: "Workspaces");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}

View File

@@ -0,0 +1,939 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Socialize.Data;
#nullable disable
namespace Socialize.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalDecision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApprovalRequestId")
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("DecidedByEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("DecidedByName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("DecidedByUserId")
.HasColumnType("uuid");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("ApprovalRequestId");
b.ToTable("ApprovalDecisions", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AccessToken")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DueAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("RequestedByUserId")
.HasColumnType("uuid");
b.Property<string>("ReviewerEmail")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ReviewerName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("SentAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Stage")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("ReviewerEmail");
b.HasIndex("WorkspaceId");
b.ToTable("ApprovalRequests", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Assets.Data.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssetType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<int>("CurrentRevisionNumber")
.HasColumnType("integer");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveFileId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveLink")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("PreviewUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("SourceType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("WorkspaceId");
b.ToTable("Assets", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Assets.Data.AssetRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AssetId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("CreatedByUserId")
.HasColumnType("uuid");
b.Property<string>("Notes")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("PreviewUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("RevisionNumber")
.HasColumnType("integer");
b.Property<string>("SourceReference")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("AssetId", "RevisionNumber")
.IsUnique();
b.ToTable("AssetRevisions", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Clients.Data.Client", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("PrimaryContactEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PrimaryContactName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PrimaryContactPortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "Name")
.IsUnique();
b.ToTable("Clients", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Comments.Data.Comment", 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<Guid>("AuthorUserId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<bool>("IsResolved")
.HasColumnType("boolean");
b.Property<Guid?>("ParentCommentId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("ParentCommentId");
b.HasIndex("WorkspaceId");
b.ToTable("Comments", (string)null);
});
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("CurrentRevisionLabel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("CurrentRevisionNumber")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Hashtags")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("PublicationMessage")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("PublicationTargets")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("ProjectId");
b.HasIndex("WorkspaceId");
b.ToTable("ContentItems", (string)null);
});
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItemRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ChangeSummary")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("CreatedByUserId")
.HasColumnType("uuid");
b.Property<string>("Hashtags")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("PublicationMessage")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("PublicationTargets")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RevisionLabel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("RevisionNumber")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("ContentItemId", "RevisionNumber")
.IsUnique();
b.ToTable("ContentItemRevisions", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Identity.Data.Role", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Identity.Data.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("Address")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Alias")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("BirthDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("FacebookId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Firstname")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Lastname")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("PortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("RefreshToken")
.HasMaxLength(44)
.HasColumnType("character varying(44)");
b.Property<DateTime>("RefreshTokenExpiryTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Notifications.Data.NotificationEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("ContentItemId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("EntityId")
.HasColumnType("uuid");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("MetadataJson")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset?>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("RecipientEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("RecipientUserId")
.HasColumnType("uuid");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ContentItemId");
b.HasIndex("CreatedAt");
b.HasIndex("RecipientUserId");
b.HasIndex("WorkspaceId");
b.ToTable("NotificationEvents", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Projects.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClientId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Notes")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("WorkspaceId");
b.HasIndex("ClientId", "Name")
.IsUnique();
b.ToTable("Projects", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.Workspace", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("TimeZone")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Workspaces", (string)null);
});
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.WorkspaceInvite", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("InvitedByUserId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "Email", "Status");
b.ToTable("WorkspaceInvites", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("Socialize.Modules.Identity.Data.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,13 @@
namespace Socialize.Modules.Approvals.Data;
public class ApprovalDecision
{
public Guid Id { get; init; }
public Guid ApprovalRequestId { get; set; }
public required string Decision { get; set; }
public string? Comment { get; set; }
public Guid? DecidedByUserId { get; set; }
public required string DecidedByName { get; set; }
public required string DecidedByEmail { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace Socialize.Modules.Approvals.Data;
public class ApprovalRequest
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ContentItemId { get; set; }
public required string Stage { get; set; }
public required string ReviewerName { get; set; }
public required string ReviewerEmail { get; set; }
public Guid RequestedByUserId { get; set; }
public DateTimeOffset? DueAt { get; set; }
public required string State { get; set; }
public required string AccessToken { get; set; }
public DateTimeOffset SentAt { get; init; }
public DateTimeOffset? CompletedAt { get; set; }
}

View File

@@ -0,0 +1,12 @@
using Socialize.Modules.Approvals.Data;
namespace Socialize.Modules.Approvals;
public static class DependencyInjection
{
public static WebApplicationBuilder AddApprovalsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -0,0 +1,118 @@
using System.Security.Cryptography;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.Approvals.Handlers;
public record CreateApprovalRequestRequest(
Guid WorkspaceId,
Guid ContentItemId,
string Stage,
string ReviewerName,
string ReviewerEmail,
DateTimeOffset? DueAt);
public class CreateApprovalRequestRequestValidator
: Validator<CreateApprovalRequestRequest>
{
public CreateApprovalRequestRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ContentItemId).NotEmpty();
RuleFor(x => x.Stage).NotEmpty().MaximumLength(64);
RuleFor(x => x.ReviewerName).NotEmpty().MaximumLength(256);
RuleFor(x => x.ReviewerEmail).NotEmpty().MaximumLength(256).EmailAddress();
}
}
public class CreateApprovalRequestHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateApprovalRequestRequest, ApprovalRequestDto>
{
public override void Configure()
{
Post("/api/approvals");
Options(o => o.WithTags("Approvals"));
}
public override async Task HandleAsync(CreateApprovalRequestRequest request, CancellationToken ct)
{
ContentItem? contentItem = await dbContext.ContentItems
.SingleOrDefaultAsync(
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
ct);
if (contentItem is null)
{
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, contentItem.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
ApprovalRequest approval = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
ContentItemId = request.ContentItemId,
Stage = request.Stage.Trim(),
ReviewerName = request.ReviewerName.Trim(),
ReviewerEmail = request.ReviewerEmail.Trim(),
RequestedByUserId = User.GetUserId(),
DueAt = request.DueAt,
State = "Pending",
AccessToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
SentAt = DateTimeOffset.UtcNow,
};
dbContext.ApprovalRequests.Add(approval);
if (approval.Stage == "Internal")
{
contentItem.Status = "In internal review";
}
else if (approval.Stage == "Client")
{
contentItem.Status = "In client review";
}
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.requested",
"ApprovalRequest",
approval.Id,
$"Approval requested from {approval.ReviewerName} for {contentItem.Title}.",
null,
approval.ReviewerEmail,
$$"""{"stage":"{{approval.Stage}}","accessToken":"{{approval.AccessToken}}"}"""),
ct);
ApprovalRequestDto dto = new(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,
approval.RequestedByUserId,
approval.DueAt,
approval.State,
approval.AccessToken,
approval.SentAt,
approval.CompletedAt,
[]);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,117 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Approvals.Handlers;
public record GetApprovalsRequest(Guid ContentItemId);
public record ApprovalDecisionDto(
Guid Id,
Guid ApprovalRequestId,
string Decision,
string? Comment,
Guid? DecidedByUserId,
string DecidedByName,
string DecidedByEmail,
string? DecidedByPortraitUrl,
DateTimeOffset CreatedAt);
public record ApprovalRequestDto(
Guid Id,
Guid WorkspaceId,
Guid ContentItemId,
string Stage,
string ReviewerName,
string ReviewerEmail,
Guid RequestedByUserId,
DateTimeOffset? DueAt,
string State,
string AccessToken,
DateTimeOffset SentAt,
DateTimeOffset? CompletedAt,
IReadOnlyCollection<ApprovalDecisionDto> Decisions);
public class GetApprovalsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetApprovalsRequest, IReadOnlyCollection<ApprovalRequestDto>>
{
public override void Configure()
{
Get("/api/approvals");
Options(o => o.WithTags("Approvals"));
}
public override async Task HandleAsync(GetApprovalsRequest request, CancellationToken ct)
{
ContentItem? item = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
List<ApprovalRequest> approvals = await dbContext.ApprovalRequests
.Where(approval => approval.ContentItemId == request.ContentItemId)
.OrderByDescending(approval => approval.SentAt)
.ToListAsync(ct);
List<Guid> approvalIds = approvals
.Select(approval => approval.Id)
.ToList();
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
.Where(decision => approvalIds.Contains(decision.ApprovalRequestId))
.OrderByDescending(decision => decision.CreatedAt)
.ToListAsync(ct);
List<Guid> decidedByUserIds = decisions
.Where(decision => decision.DecidedByUserId.HasValue)
.Select(decision => decision.DecidedByUserId!.Value)
.Distinct()
.ToList();
Dictionary<Guid, string?> decisionPortraits = await dbContext.Users
.Where(user => decidedByUserIds.Contains(user.Id))
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
List<ApprovalRequestDto> dtos = approvals
.Select(approval => new ApprovalRequestDto(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,
approval.RequestedByUserId,
approval.DueAt,
approval.State,
approval.AccessToken,
approval.SentAt,
approval.CompletedAt,
decisions
.Where(decision => decision.ApprovalRequestId == approval.Id)
.Select(decision => new ApprovalDecisionDto(
decision.Id,
decision.ApprovalRequestId,
decision.Decision,
decision.Comment,
decision.DecidedByUserId,
decision.DecidedByName,
decision.DecidedByEmail,
decision.DecidedByUserId.HasValue
? decisionPortraits.GetValueOrDefault(decision.DecidedByUserId.Value)
: null,
decision.CreatedAt))
.ToList()))
.ToList();
await SendOkAsync(dtos, ct);
}
}

View File

@@ -0,0 +1,169 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.Approvals.Handlers;
public record SubmitApprovalDecisionRequest(
string Decision,
string? Comment,
string? ReviewerName,
string? ReviewerEmail);
public class SubmitApprovalDecisionRequestValidator
: Validator<SubmitApprovalDecisionRequest>
{
public SubmitApprovalDecisionRequestValidator()
{
RuleFor(x => x.Decision).NotEmpty().MaximumLength(64);
RuleFor(x => x.Comment).MaximumLength(2048);
RuleFor(x => x.ReviewerName).MaximumLength(256);
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
}
}
public class SubmitApprovalDecisionHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
{
public override void Configure()
{
Post("/api/approvals/{id}/decisions");
AllowAnonymous();
Options(o => o.WithTags("Approvals"));
}
public override async Task HandleAsync(SubmitApprovalDecisionRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
ApprovalRequest? approval = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (approval is null)
{
await SendNotFoundAsync(ct);
return;
}
ContentItem? contentItem = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == approval.ContentItemId, ct);
if (contentItem is null)
{
await SendNotFoundAsync(ct);
return;
}
if (User?.Identity?.IsAuthenticated == true &&
!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
string normalizedDecision = request.Decision.Trim();
string decidedByName = User?.Identity?.IsAuthenticated == true
? User.GetAlias() ?? User.GetName()
: string.IsNullOrWhiteSpace(request.ReviewerName) ? approval.ReviewerName : request.ReviewerName.Trim();
string decidedByEmail = User?.Identity?.IsAuthenticated == true
? User.GetEmail()
: string.IsNullOrWhiteSpace(request.ReviewerEmail) ? approval.ReviewerEmail : request.ReviewerEmail.Trim();
ApprovalDecision decision = new()
{
Id = Guid.NewGuid(),
ApprovalRequestId = approval.Id,
Decision = normalizedDecision,
Comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment.Trim(),
DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null,
DecidedByName = decidedByName,
DecidedByEmail = decidedByEmail,
CreatedAt = DateTimeOffset.UtcNow,
};
approval.State = normalizedDecision;
approval.CompletedAt = DateTimeOffset.UtcNow;
if (approval.Stage == "Internal")
{
contentItem.Status = normalizedDecision switch
{
"Approved" => "Ready for client review",
"Changes requested" => "Changes requested internally",
"Rejected" => "Rejected",
_ => contentItem.Status,
};
}
else if (approval.Stage == "Client")
{
contentItem.Status = normalizedDecision switch
{
"Approved" => "Approved",
"Changes requested" => "Changes requested by client",
"Rejected" => "Rejected",
_ => contentItem.Status,
};
}
dbContext.ApprovalDecisions.Add(decision);
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.decision.recorded",
"ApprovalDecision",
decision.Id,
$"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.",
null,
decidedByEmail,
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
ct);
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
.OrderByDescending(candidate => candidate.CreatedAt)
.ToListAsync(ct);
List<Guid> decidedByUserIds = decisions
.Where(candidate => candidate.DecidedByUserId.HasValue)
.Select(candidate => candidate.DecidedByUserId!.Value)
.Distinct()
.ToList();
Dictionary<Guid, string?> decisionPortraits = await dbContext.Users
.Where(user => decidedByUserIds.Contains(user.Id))
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
List<ApprovalDecisionDto> decisionDtos = decisions
.Select(candidate => new ApprovalDecisionDto(
candidate.Id,
candidate.ApprovalRequestId,
candidate.Decision,
candidate.Comment,
candidate.DecidedByUserId,
candidate.DecidedByName,
candidate.DecidedByEmail,
candidate.DecidedByUserId.HasValue
? decisionPortraits.GetValueOrDefault(candidate.DecidedByUserId.Value)
: null,
candidate.CreatedAt))
.ToList();
ApprovalRequestDto dto = new(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,
approval.RequestedByUserId,
approval.DueAt,
approval.State,
approval.AccessToken,
approval.SentAt,
approval.CompletedAt,
decisionDtos);
await SendOkAsync(dto, ct);
}
}

View File

@@ -0,0 +1,16 @@
namespace Socialize.Modules.Assets.Data;
public class Asset
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ContentItemId { get; set; }
public required string AssetType { get; set; }
public required string SourceType { get; set; }
public required string DisplayName { get; set; }
public string? GoogleDriveFileId { get; set; }
public string? GoogleDriveLink { get; set; }
public string? PreviewUrl { get; set; }
public int CurrentRevisionNumber { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,13 @@
namespace Socialize.Modules.Assets.Data;
public class AssetRevision
{
public Guid Id { get; init; }
public Guid AssetId { get; set; }
public int RevisionNumber { get; set; }
public required string SourceReference { get; set; }
public string? PreviewUrl { get; set; }
public string? Notes { get; set; }
public Guid? CreatedByUserId { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,12 @@
using Socialize.Modules.Assets.Data;
namespace Socialize.Modules.Assets;
public static class DependencyInjection
{
public static WebApplicationBuilder AddAssetsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -0,0 +1,102 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.Assets.Handlers;
public record CreateAssetRevisionRequest(
string SourceReference,
string? PreviewUrl,
string? Notes);
public class CreateAssetRevisionRequestValidator
: Validator<CreateAssetRevisionRequest>
{
public CreateAssetRevisionRequestValidator()
{
RuleFor(x => x.SourceReference).NotEmpty().MaximumLength(2048);
RuleFor(x => x.PreviewUrl).MaximumLength(2048);
RuleFor(x => x.Notes).MaximumLength(1024);
}
}
public class CreateAssetRevisionHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateAssetRevisionRequest, AssetRevisionDto>
{
public override void Configure()
{
Post("/api/assets/{id}/revisions");
Options(o => o.WithTags("Assets"));
}
public override async Task HandleAsync(CreateAssetRevisionRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (asset is null)
{
await SendNotFoundAsync(ct);
return;
}
ContentItem? contentItem = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct);
if (contentItem is not null &&
!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
int revisionNumber = asset.CurrentRevisionNumber + 1;
asset.CurrentRevisionNumber = revisionNumber;
asset.PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? asset.PreviewUrl : request.PreviewUrl.Trim();
AssetRevision revision = new()
{
Id = Guid.NewGuid(),
AssetId = asset.Id,
RevisionNumber = revisionNumber,
SourceReference = request.SourceReference.Trim(),
PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(),
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
CreatedByUserId = User.GetUserId(),
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.AssetRevisions.Add(revision);
await dbContext.SaveChangesAsync(ct);
if (contentItem is not null)
{
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
asset.WorkspaceId,
asset.ContentItemId,
"asset.revision.created",
"AssetRevision",
revision.Id,
$"A new asset revision was added to {asset.DisplayName}.",
User.GetUserId(),
User.GetEmail(),
$$"""{"revisionNumber":"{{revisionNumber}}"}"""),
ct);
}
AssetRevisionDto dto = new(
revision.Id,
revision.AssetId,
revision.RevisionNumber,
revision.SourceReference,
revision.PreviewUrl,
revision.Notes,
revision.CreatedByUserId,
revision.CreatedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,130 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.Assets.Handlers;
public record CreateGoogleDriveAssetRequest(
Guid WorkspaceId,
Guid ContentItemId,
string AssetType,
string DisplayName,
string GoogleDriveFileId,
string GoogleDriveLink,
string? PreviewUrl);
public class CreateGoogleDriveAssetRequestValidator
: Validator<CreateGoogleDriveAssetRequest>
{
public CreateGoogleDriveAssetRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ContentItemId).NotEmpty();
RuleFor(x => x.AssetType).NotEmpty().MaximumLength(64);
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(256);
RuleFor(x => x.GoogleDriveFileId).NotEmpty().MaximumLength(256);
RuleFor(x => x.GoogleDriveLink).NotEmpty().MaximumLength(2048);
RuleFor(x => x.PreviewUrl).MaximumLength(2048);
}
}
public class CreateGoogleDriveAssetHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateGoogleDriveAssetRequest, AssetDto>
{
public override void Configure()
{
Post("/api/assets/google-drive");
Options(o => o.WithTags("Assets"));
}
public override async Task HandleAsync(CreateGoogleDriveAssetRequest request, CancellationToken ct)
{
ContentItem? contentItem = await dbContext.ContentItems
.SingleOrDefaultAsync(
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
ct);
if (contentItem is null)
{
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
Asset asset = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
ContentItemId = request.ContentItemId,
AssetType = request.AssetType.Trim(),
SourceType = "GoogleDrive",
DisplayName = request.DisplayName.Trim(),
GoogleDriveFileId = request.GoogleDriveFileId.Trim(),
GoogleDriveLink = request.GoogleDriveLink.Trim(),
PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(),
CurrentRevisionNumber = 1,
CreatedAt = DateTimeOffset.UtcNow,
};
AssetRevision revision = new()
{
Id = Guid.NewGuid(),
AssetId = asset.Id,
RevisionNumber = 1,
SourceReference = asset.GoogleDriveLink,
PreviewUrl = asset.PreviewUrl,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Assets.Add(asset);
dbContext.AssetRevisions.Add(revision);
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
asset.WorkspaceId,
asset.ContentItemId,
"asset.google-drive-linked",
"Asset",
asset.Id,
$"Google Drive asset {asset.DisplayName} was linked to {contentItem.Title}.",
null,
null,
$$"""{"googleDriveFileId":"{{asset.GoogleDriveFileId}}"}"""),
ct);
AssetDto dto = new(
asset.Id,
asset.WorkspaceId,
asset.ContentItemId,
asset.AssetType,
asset.SourceType,
asset.DisplayName,
asset.GoogleDriveFileId,
asset.GoogleDriveLink,
asset.PreviewUrl,
asset.CurrentRevisionNumber,
asset.CreatedAt,
[
new AssetRevisionDto(
revision.Id,
revision.AssetId,
revision.RevisionNumber,
revision.SourceReference,
revision.PreviewUrl,
revision.Notes,
revision.CreatedByUserId,
revision.CreatedAt)
]);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,89 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Assets.Handlers;
public record GetAssetsRequest(Guid ContentItemId);
public record AssetRevisionDto(
Guid Id,
Guid AssetId,
int RevisionNumber,
string SourceReference,
string? PreviewUrl,
string? Notes,
Guid? CreatedByUserId,
DateTimeOffset CreatedAt);
public record AssetDto(
Guid Id,
Guid WorkspaceId,
Guid ContentItemId,
string AssetType,
string SourceType,
string DisplayName,
string? GoogleDriveFileId,
string? GoogleDriveLink,
string? PreviewUrl,
int CurrentRevisionNumber,
DateTimeOffset CreatedAt,
IReadOnlyCollection<AssetRevisionDto> Revisions);
public class GetAssetsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetAssetsRequest, IReadOnlyCollection<AssetDto>>
{
public override void Configure()
{
Get("/api/assets");
Options(o => o.WithTags("Assets"));
}
public override async Task HandleAsync(GetAssetsRequest request, CancellationToken ct)
{
ContentItem? item = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
List<AssetDto> assets = await dbContext.Assets
.Where(asset => asset.ContentItemId == request.ContentItemId)
.OrderBy(asset => asset.DisplayName)
.Select(asset => new AssetDto(
asset.Id,
asset.WorkspaceId,
asset.ContentItemId,
asset.AssetType,
asset.SourceType,
asset.DisplayName,
asset.GoogleDriveFileId,
asset.GoogleDriveLink,
asset.PreviewUrl,
asset.CurrentRevisionNumber,
asset.CreatedAt,
dbContext.AssetRevisions
.Where(revision => revision.AssetId == asset.Id)
.OrderByDescending(revision => revision.RevisionNumber)
.Select(revision => new AssetRevisionDto(
revision.Id,
revision.AssetId,
revision.RevisionNumber,
revision.SourceReference,
revision.PreviewUrl,
revision.Notes,
revision.CreatedByUserId,
revision.CreatedAt))
.ToList()))
.ToListAsync(ct);
await SendOkAsync(assets, ct);
}
}

View File

@@ -0,0 +1,14 @@
namespace Socialize.Modules.Clients.Data;
public class Client
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public required string Name { get; set; }
public required string Status { get; set; }
public string? PortraitUrl { get; set; }
public string? PrimaryContactName { get; set; }
public string? PrimaryContactEmail { get; set; }
public string? PrimaryContactPortraitUrl { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,12 @@
using Socialize.Modules.Clients.Data;
namespace Socialize.Modules.Clients;
public static class DependencyInjection
{
public static WebApplicationBuilder AddClientsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -0,0 +1,65 @@
using Socialize.Infrastructure.BlobStorage.Contracts;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Clients.Data;
namespace Socialize.Modules.Clients.Handlers;
public record ChangeClientPortraitRequest(
IFormFile File);
public record ChangeClientPortraitResponse(
string BlobUrl);
public sealed class ChangeClientPortraitRequestValidator : Validator<ChangeClientPortraitRequest>
{
public ChangeClientPortraitRequestValidator()
{
RuleFor(x => x.File)
.NotNull()
.NotEmpty();
}
}
public class ChangeClientPortraitHandler(
AppDbContext clientsDbContext,
IBlobStorage blobStorage,
AccessScopeService accessScopeService)
: Endpoint<ChangeClientPortraitRequest, ChangeClientPortraitResponse>
{
public override void Configure()
{
Post("/api/clients/{id}/portrait");
Options(o => o.WithTags("Clients"));
AllowFileUploads();
}
public override async Task HandleAsync(ChangeClientPortraitRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
Client? client = await clientsDbContext.Clients.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (client is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
string blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Clients,
$"{client.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.LogoPicture}",
request.File.OpenReadStream(),
request.File.ContentType,
ct);
client.PortraitUrl = blobUrl;
await clientsDbContext.SaveChangesAsync(ct);
await SendOkAsync(new ChangeClientPortraitResponse(blobUrl), ct);
}
}

View File

@@ -0,0 +1,101 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Clients.Handlers;
public record CreateClientRequest(
Guid WorkspaceId,
string Name,
string? PortraitUrl,
string? PrimaryContactName,
string? PrimaryContactEmail,
string? PrimaryContactPortraitUrl);
public class CreateClientRequestValidator
: Validator<CreateClientRequest>
{
public CreateClientRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.PortraitUrl).MaximumLength(2048);
RuleFor(x => x.PrimaryContactName).MaximumLength(256);
RuleFor(x => x.PrimaryContactEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.PrimaryContactEmail));
RuleFor(x => x.PrimaryContactPortraitUrl).MaximumLength(2048);
}
}
public class CreateClientHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<CreateClientRequest, ClientDto>
{
public override void Configure()
{
Post("/api/clients");
Options(o => o.WithTags("Clients"));
}
public override async Task HandleAsync(CreateClientRequest request, CancellationToken ct)
{
if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
bool workspaceExists = await dbContext.Workspaces
.AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct);
if (!workspaceExists)
{
AddError(request => request.WorkspaceId, "The selected workspace does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
string normalizedName = request.Name.Trim();
string? normalizedPortraitUrl = request.PortraitUrl?.Trim();
string? normalizedPrimaryContactName = request.PrimaryContactName?.Trim();
string? normalizedPrimaryContactEmail = request.PrimaryContactEmail?.Trim();
string? normalizedPrimaryContactPortraitUrl = request.PrimaryContactPortraitUrl?.Trim();
bool duplicateClient = await dbContext.Clients
.AnyAsync(
client => client.WorkspaceId == request.WorkspaceId && client.Name == normalizedName,
ct);
if (duplicateClient)
{
AddError(request => request.Name, "A client with this name already exists in the active workspace.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
Client client = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
Name = normalizedName,
Status = "Active",
PortraitUrl = string.IsNullOrWhiteSpace(normalizedPortraitUrl) ? null : normalizedPortraitUrl,
PrimaryContactName = string.IsNullOrWhiteSpace(normalizedPrimaryContactName) ? null : normalizedPrimaryContactName,
PrimaryContactEmail = string.IsNullOrWhiteSpace(normalizedPrimaryContactEmail) ? null : normalizedPrimaryContactEmail,
PrimaryContactPortraitUrl = string.IsNullOrWhiteSpace(normalizedPrimaryContactPortraitUrl) ? null : normalizedPrimaryContactPortraitUrl,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Clients.Add(client);
await dbContext.SaveChangesAsync(ct);
ClientDto dto = new(
client.Id,
client.WorkspaceId,
client.Name,
client.Status,
client.PortraitUrl,
client.PrimaryContactName,
client.PrimaryContactEmail,
client.PrimaryContactPortraitUrl);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,73 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Clients.Data;
namespace Socialize.Modules.Clients.Handlers;
public record GetClientsRequest(Guid? WorkspaceId);
public record ClientDto(
Guid Id,
Guid WorkspaceId,
string Name,
string Status,
string? PortraitUrl,
string? PrimaryContactName,
string? PrimaryContactEmail,
string? PrimaryContactPortraitUrl);
public class GetClientsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetClientsRequest, IReadOnlyCollection<ClientDto>>
{
public override void Configure()
{
Get("/api/clients");
Options(o => o.WithTags("Clients"));
}
public override async Task HandleAsync(GetClientsRequest request, CancellationToken ct)
{
IQueryable<Client> query = dbContext.Clients.AsQueryable();
if (accessScopeService.IsManager(User))
{
if (request.WorkspaceId.HasValue)
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
}
}
else
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
if (clientScopeIds.Count > 0)
{
query = query.Where(client => clientScopeIds.Contains(client.Id));
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
}
}
List<ClientDto> clients = await query
.OrderBy(client => client.Name)
.Select(client => new ClientDto(
client.Id,
client.WorkspaceId,
client.Name,
client.Status,
client.PortraitUrl,
client.PrimaryContactName,
client.PrimaryContactEmail,
client.PrimaryContactPortraitUrl))
.ToListAsync(ct);
await SendOkAsync(clients, ct);
}
}

View File

@@ -0,0 +1,98 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Clients.Data;
namespace Socialize.Modules.Clients.Handlers;
public record UpdateClientRequest(
string Name,
string? PortraitUrl,
string Status,
string? PrimaryContactName,
string? PrimaryContactEmail,
string? PrimaryContactPortraitUrl);
public class UpdateClientRequestValidator
: Validator<UpdateClientRequest>
{
public UpdateClientRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.PortraitUrl).MaximumLength(2048);
RuleFor(x => x.Status).NotEmpty().MaximumLength(64);
RuleFor(x => x.PrimaryContactName).MaximumLength(256);
RuleFor(x => x.PrimaryContactEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.PrimaryContactEmail));
RuleFor(x => x.PrimaryContactPortraitUrl).MaximumLength(2048);
}
}
public class UpdateClientHandler(
AppDbContext clientsDbContext,
AccessScopeService accessScopeService)
: Endpoint<UpdateClientRequest, ClientDto>
{
public override void Configure()
{
Put("/api/clients/{id}");
Options(o => o.WithTags("Clients"));
}
public override async Task HandleAsync(UpdateClientRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
Client? client = await clientsDbContext.Clients.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (client is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
string normalizedName = request.Name.Trim();
string normalizedStatus = request.Status.Trim();
string? normalizedPortraitUrl = request.PortraitUrl?.Trim();
string? normalizedPrimaryContactName = request.PrimaryContactName?.Trim();
string? normalizedPrimaryContactEmail = request.PrimaryContactEmail?.Trim();
string? normalizedPrimaryContactPortraitUrl = request.PrimaryContactPortraitUrl?.Trim();
bool duplicateClient = await clientsDbContext.Clients
.AnyAsync(
candidate => candidate.Id != id
&& candidate.WorkspaceId == client.WorkspaceId
&& candidate.Name == normalizedName,
ct);
if (duplicateClient)
{
AddError(request => request.Name, "A client with this name already exists in the active workspace.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
client.Name = normalizedName;
client.Status = normalizedStatus;
client.PortraitUrl = string.IsNullOrWhiteSpace(normalizedPortraitUrl) ? null : normalizedPortraitUrl;
client.PrimaryContactName = string.IsNullOrWhiteSpace(normalizedPrimaryContactName) ? null : normalizedPrimaryContactName;
client.PrimaryContactEmail = string.IsNullOrWhiteSpace(normalizedPrimaryContactEmail) ? null : normalizedPrimaryContactEmail;
client.PrimaryContactPortraitUrl = string.IsNullOrWhiteSpace(normalizedPrimaryContactPortraitUrl) ? null : normalizedPrimaryContactPortraitUrl;
await clientsDbContext.SaveChangesAsync(ct);
ClientDto dto = new(
client.Id,
client.WorkspaceId,
client.Name,
client.Status,
client.PortraitUrl,
client.PrimaryContactName,
client.PrimaryContactEmail,
client.PrimaryContactPortraitUrl);
await SendOkAsync(dto, ct);
}
}

View File

@@ -0,0 +1,16 @@
namespace Socialize.Modules.Comments.Data;
public class Comment
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ContentItemId { get; set; }
public Guid? ParentCommentId { get; set; }
public Guid AuthorUserId { get; set; }
public required string AuthorDisplayName { get; set; }
public required string AuthorEmail { get; set; }
public required string Body { get; set; }
public bool IsResolved { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ResolvedAt { get; set; }
}

View File

@@ -0,0 +1,12 @@
using Socialize.Modules.Comments.Data;
namespace Socialize.Modules.Comments;
public static class DependencyInjection
{
public static WebApplicationBuilder AddCommentsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -0,0 +1,120 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.Comments.Handlers;
public record CreateCommentRequest(
Guid WorkspaceId,
Guid ContentItemId,
Guid? ParentCommentId,
string Body);
public class CreateCommentRequestValidator
: Validator<CreateCommentRequest>
{
public CreateCommentRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ContentItemId).NotEmpty();
RuleFor(x => x.Body).NotEmpty().MaximumLength(4000);
}
}
public class CreateCommentHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateCommentRequest, CommentDto>
{
public override void Configure()
{
Post("/api/comments");
Options(o => o.WithTags("Comments"));
}
public override async Task HandleAsync(CreateCommentRequest request, CancellationToken ct)
{
ContentItem? contentItem = await dbContext.ContentItems
.SingleOrDefaultAsync(
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
ct);
if (contentItem is null)
{
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
if (request.ParentCommentId.HasValue)
{
bool parentExists = await dbContext.Comments
.AnyAsync(
comment => comment.Id == request.ParentCommentId.Value && comment.ContentItemId == request.ContentItemId,
ct);
if (!parentExists)
{
AddError(request => request.ParentCommentId, "The selected parent comment does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
}
Comment comment = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
ContentItemId = request.ContentItemId,
ParentCommentId = request.ParentCommentId,
AuthorUserId = User.GetUserId(),
AuthorDisplayName = User.GetAlias() ?? User.GetName(),
AuthorEmail = User.GetEmail(),
Body = request.Body.Trim(),
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Comments.Add(comment);
await dbContext.SaveChangesAsync(ct);
string? authorPortraitUrl = await dbContext.Users
.Where(candidate => candidate.Id == comment.AuthorUserId)
.Select(candidate => candidate.PortraitUrl)
.SingleOrDefaultAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
comment.WorkspaceId,
comment.ContentItemId,
"comment.created",
"Comment",
comment.Id,
$"{comment.AuthorDisplayName} commented on {contentItem.Title}.",
null,
null,
$$"""{"parentCommentId":"{{comment.ParentCommentId}}"}"""),
ct);
CommentDto dto = new(
comment.Id,
comment.WorkspaceId,
comment.ContentItemId,
comment.ParentCommentId,
comment.AuthorUserId,
comment.AuthorDisplayName,
comment.AuthorEmail,
authorPortraitUrl,
comment.Body,
comment.IsResolved,
comment.CreatedAt,
comment.ResolvedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,80 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Comments.Handlers;
public record GetCommentsRequest(Guid ContentItemId);
public record CommentDto(
Guid Id,
Guid WorkspaceId,
Guid ContentItemId,
Guid? ParentCommentId,
Guid AuthorUserId,
string AuthorDisplayName,
string AuthorEmail,
string? AuthorPortraitUrl,
string Body,
bool IsResolved,
DateTimeOffset CreatedAt,
DateTimeOffset? ResolvedAt);
public class GetCommentsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetCommentsRequest, IReadOnlyCollection<CommentDto>>
{
public override void Configure()
{
Get("/api/comments");
Options(o => o.WithTags("Comments"));
}
public override async Task HandleAsync(GetCommentsRequest request, CancellationToken ct)
{
ContentItem? item = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
List<Comment> comments = await dbContext.Comments
.Where(comment => comment.ContentItemId == request.ContentItemId)
.OrderBy(comment => comment.CreatedAt)
.ToListAsync(ct);
List<Guid> authorIds = comments
.Select(comment => comment.AuthorUserId)
.Distinct()
.ToList();
Dictionary<Guid, string?> authorPortraits = await dbContext.Users
.Where(user => authorIds.Contains(user.Id))
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
List<CommentDto> dtos = comments
.Select(comment => new CommentDto(
comment.Id,
comment.WorkspaceId,
comment.ContentItemId,
comment.ParentCommentId,
comment.AuthorUserId,
comment.AuthorDisplayName,
comment.AuthorEmail,
authorPortraits.GetValueOrDefault(comment.AuthorUserId),
comment.Body,
comment.IsResolved,
comment.CreatedAt,
comment.ResolvedAt))
.ToList();
await SendOkAsync(dtos, ct);
}
}

View File

@@ -0,0 +1,84 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.Comments.Handlers;
public class ResolveCommentHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: EndpointWithoutRequest<CommentDto>
{
public override void Configure()
{
Post("/api/comments/{id}/resolve");
Options(o => o.WithTags("Comments"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
Comment? comment = await dbContext.Comments.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (comment is null)
{
await SendNotFoundAsync(ct);
return;
}
ContentItem? contentItem = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == comment.ContentItemId, ct);
if (contentItem is null)
{
await SendNotFoundAsync(ct);
return;
}
bool canResolve = accessScopeService.CanManageWorkspace(User, comment.WorkspaceId)
|| accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId);
if (!canResolve)
{
await SendForbiddenAsync(ct);
return;
}
comment.IsResolved = true;
comment.ResolvedAt = comment.ResolvedAt ?? DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(ct);
string? authorPortraitUrl = await dbContext.Users
.Where(candidate => candidate.Id == comment.AuthorUserId)
.Select(candidate => candidate.PortraitUrl)
.SingleOrDefaultAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
comment.WorkspaceId,
comment.ContentItemId,
"comment.resolved",
"Comment",
comment.Id,
$"{User.GetAlias() ?? User.GetName()} resolved a comment.",
null,
null,
null),
ct);
CommentDto dto = new(
comment.Id,
comment.WorkspaceId,
comment.ContentItemId,
comment.ParentCommentId,
comment.AuthorUserId,
comment.AuthorDisplayName,
comment.AuthorEmail,
authorPortraitUrl,
comment.Body,
comment.IsResolved,
comment.CreatedAt,
comment.ResolvedAt);
await SendOkAsync(dto, ct);
}
}

View File

@@ -0,0 +1,18 @@
namespace Socialize.Modules.ContentItems.Data;
public class ContentItem
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ClientId { get; set; }
public Guid ProjectId { get; set; }
public required string Title { get; set; }
public required string PublicationMessage { get; set; }
public required string PublicationTargets { get; set; }
public string? Hashtags { get; set; }
public required string Status { get; set; }
public DateTimeOffset? DueDate { get; set; }
public required string CurrentRevisionLabel { get; set; }
public int CurrentRevisionNumber { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,16 @@
namespace Socialize.Modules.ContentItems.Data;
public class ContentItemRevision
{
public Guid Id { get; init; }
public Guid ContentItemId { get; set; }
public int RevisionNumber { get; set; }
public required string RevisionLabel { get; set; }
public required string Title { get; set; }
public required string PublicationMessage { get; set; }
public required string PublicationTargets { get; set; }
public string? Hashtags { get; set; }
public string? ChangeSummary { get; set; }
public Guid? CreatedByUserId { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,12 @@
using Socialize.Modules.ContentItems.Data;
namespace Socialize.Modules.ContentItems;
public static class DependencyInjection
{
public static WebApplicationBuilder AddContentItemsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -0,0 +1,148 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.ContentItems.Handlers;
public record CreateContentItemRequest(
Guid WorkspaceId,
Guid ClientId,
Guid ProjectId,
string Title,
string PublicationMessage,
string PublicationTargets,
string? Hashtags,
DateTimeOffset? DueDate);
public class CreateContentItemRequestValidator
: Validator<CreateContentItemRequest>
{
public CreateContentItemRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ClientId).NotEmpty();
RuleFor(x => x.ProjectId).NotEmpty();
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000);
RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512);
RuleFor(x => x.Hashtags).MaximumLength(1024);
}
}
public class CreateContentItemHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateContentItemRequest, ContentItemDto>
{
public override void Configure()
{
Post("/api/content-items");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(CreateContentItemRequest request, CancellationToken ct)
{
if (!accessScopeService.CanContributeToProject(User, request.WorkspaceId, request.ClientId, request.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
bool workspaceExists = await dbContext.Workspaces
.AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct);
if (!workspaceExists)
{
AddError(request => request.WorkspaceId, "The selected workspace does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
bool clientExists = await dbContext.Clients
.AnyAsync(
client => client.Id == request.ClientId && client.WorkspaceId == request.WorkspaceId,
ct);
if (!clientExists)
{
AddError(request => request.ClientId, "The selected client does not belong to the active workspace.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
bool projectExists = await dbContext.Projects
.AnyAsync(
project => project.Id == request.ProjectId &&
project.WorkspaceId == request.WorkspaceId &&
project.ClientId == request.ClientId,
ct);
if (!projectExists)
{
AddError(request => request.ProjectId, "The selected project does not belong to the selected client.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
ContentItem item = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
ClientId = request.ClientId,
ProjectId = request.ProjectId,
Title = request.Title.Trim(),
PublicationMessage = request.PublicationMessage.Trim(),
PublicationTargets = request.PublicationTargets.Trim(),
Hashtags = string.IsNullOrWhiteSpace(request.Hashtags) ? null : request.Hashtags.Trim(),
Status = "Draft",
DueDate = request.DueDate,
CurrentRevisionLabel = "v1",
CurrentRevisionNumber = 1,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.ContentItems.Add(item);
dbContext.ContentItemRevisions.Add(new ContentItemRevision
{
Id = Guid.NewGuid(),
ContentItemId = item.Id,
RevisionNumber = 1,
RevisionLabel = "v1",
Title = item.Title,
PublicationMessage = item.PublicationMessage,
PublicationTargets = item.PublicationTargets,
Hashtags = item.Hashtags,
CreatedAt = DateTimeOffset.UtcNow,
});
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
item.WorkspaceId,
item.Id,
"content-item.created",
"ContentItem",
item.Id,
$"Content item {item.Title} was created.",
null,
null,
$$"""{"status":"{{item.Status}}","revisionLabel":"{{item.CurrentRevisionLabel}}"}"""),
ct);
ContentItemDto dto = new(
item.Id,
item.WorkspaceId,
item.ClientId,
item.ProjectId,
item.Title,
item.PublicationMessage,
item.PublicationTargets,
item.Hashtags,
item.Status,
item.DueDate,
item.CurrentRevisionLabel,
item.CurrentRevisionNumber);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,120 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.ContentItems.Handlers;
public record CreateContentItemRevisionRequest(
string Title,
string PublicationMessage,
string PublicationTargets,
string? Hashtags,
string? ChangeSummary);
public class CreateContentItemRevisionRequestValidator
: Validator<CreateContentItemRevisionRequest>
{
public CreateContentItemRevisionRequestValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000);
RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512);
RuleFor(x => x.Hashtags).MaximumLength(1024);
RuleFor(x => x.ChangeSummary).MaximumLength(1024);
}
}
public class CreateContentItemRevisionHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateContentItemRevisionRequest, ContentItemRevisionDto>
{
public override void Configure()
{
Post("/api/content-items/{id}/revisions");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(CreateContentItemRevisionRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanContributeToProject(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
int revisionNumber = item.CurrentRevisionNumber + 1;
string revisionLabel = $"v{revisionNumber}";
item.Title = request.Title.Trim();
item.PublicationMessage = request.PublicationMessage.Trim();
item.PublicationTargets = request.PublicationTargets.Trim();
item.Hashtags = string.IsNullOrWhiteSpace(request.Hashtags) ? null : request.Hashtags.Trim();
item.CurrentRevisionNumber = revisionNumber;
item.CurrentRevisionLabel = revisionLabel;
if (item.Status == "Changes requested internally")
{
item.Status = "Internal changes in progress";
}
else if (item.Status == "Changes requested by client")
{
item.Status = "Client changes in progress";
}
ContentItemRevision revision = new()
{
Id = Guid.NewGuid(),
ContentItemId = item.Id,
RevisionNumber = revisionNumber,
RevisionLabel = revisionLabel,
Title = item.Title,
PublicationMessage = item.PublicationMessage,
PublicationTargets = item.PublicationTargets,
Hashtags = item.Hashtags,
ChangeSummary = string.IsNullOrWhiteSpace(request.ChangeSummary) ? null : request.ChangeSummary.Trim(),
CreatedByUserId = User.GetUserId(),
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.ContentItemRevisions.Add(revision);
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
item.WorkspaceId,
item.Id,
"content-item.revision.created",
"ContentItemRevision",
revision.Id,
$"Revision {revisionLabel} was created for {item.Title}.",
User.GetUserId(),
User.GetEmail(),
$$"""{"revisionLabel":"{{revisionLabel}}","status":"{{item.Status}}"}"""),
ct);
ContentItemRevisionDto dto = new(
revision.Id,
revision.ContentItemId,
revision.RevisionNumber,
revision.RevisionLabel,
revision.Title,
revision.PublicationMessage,
revision.PublicationTargets,
revision.Hashtags,
revision.ChangeSummary,
revision.CreatedByUserId,
revision.CreatedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,68 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.ContentItems.Data;
namespace Socialize.Modules.ContentItems.Handlers;
public record ContentItemDetailDto(
Guid Id,
Guid WorkspaceId,
Guid ClientId,
Guid ProjectId,
string Title,
string PublicationMessage,
string PublicationTargets,
string? Hashtags,
string Status,
DateTimeOffset? DueDate,
string CurrentRevisionLabel,
int CurrentRevisionNumber,
DateTimeOffset CreatedAt);
public class GetContentItemHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest<ContentItemDetailDto>
{
public override void Configure()
{
Get("/api/content-items/{id}");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
ContentItemDetailDto? item = await dbContext.ContentItems
.Where(candidate => candidate.Id == id)
.Select(candidate => new ContentItemDetailDto(
candidate.Id,
candidate.WorkspaceId,
candidate.ClientId,
candidate.ProjectId,
candidate.Title,
candidate.PublicationMessage,
candidate.PublicationTargets,
candidate.Hashtags,
candidate.Status,
candidate.DueDate,
candidate.CurrentRevisionLabel,
candidate.CurrentRevisionNumber,
candidate.CreatedAt))
.SingleOrDefaultAsync(ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
await SendOkAsync(item, ct);
}
}

View File

@@ -0,0 +1,64 @@
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.ContentItems.Handlers;
public record ContentItemRevisionDto(
Guid Id,
Guid ContentItemId,
int RevisionNumber,
string RevisionLabel,
string Title,
string PublicationMessage,
string PublicationTargets,
string? Hashtags,
string? ChangeSummary,
Guid? CreatedByUserId,
DateTimeOffset CreatedAt);
public class GetContentItemRevisionsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest<IReadOnlyCollection<ContentItemRevisionDto>>
{
public override void Configure()
{
Get("/api/content-items/{id}/revisions");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
{
await SendForbiddenAsync(ct);
return;
}
List<ContentItemRevisionDto> revisions = await dbContext.ContentItemRevisions
.Where(revision => revision.ContentItemId == id)
.OrderByDescending(revision => revision.RevisionNumber)
.Select(revision => new ContentItemRevisionDto(
revision.Id,
revision.ContentItemId,
revision.RevisionNumber,
revision.RevisionLabel,
revision.Title,
revision.PublicationMessage,
revision.PublicationTargets,
revision.Hashtags,
revision.ChangeSummary,
revision.CreatedByUserId,
revision.CreatedAt))
.ToListAsync(ct);
await SendOkAsync(revisions, ct);
}
}

View File

@@ -0,0 +1,91 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.ContentItems.Data;
namespace Socialize.Modules.ContentItems.Handlers;
public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? ProjectId);
public record ContentItemDto(
Guid Id,
Guid WorkspaceId,
Guid ClientId,
Guid ProjectId,
string Title,
string PublicationMessage,
string PublicationTargets,
string? Hashtags,
string Status,
DateTimeOffset? DueDate,
string CurrentRevisionLabel,
int CurrentRevisionNumber);
public class GetContentItemsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetContentItemsRequest, IReadOnlyCollection<ContentItemDto>>
{
public override void Configure()
{
Get("/api/content-items");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(GetContentItemsRequest request, CancellationToken ct)
{
IQueryable<ContentItem> query = dbContext.ContentItems.AsQueryable();
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> projectScopeIds = User.GetProjectScopeIds();
query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId));
if (clientScopeIds.Count > 0)
{
query = query.Where(item => clientScopeIds.Contains(item.ClientId));
}
if (projectScopeIds.Count > 0)
{
query = query.Where(item => projectScopeIds.Contains(item.ProjectId));
}
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(item => item.WorkspaceId == request.WorkspaceId.Value);
}
if (request.ProjectId.HasValue)
{
query = query.Where(item => item.ProjectId == request.ProjectId.Value);
}
if (request.ClientId.HasValue)
{
query = query.Where(item => item.ClientId == request.ClientId.Value);
}
List<ContentItemDto> items = await query
.OrderBy(item => item.DueDate)
.ThenBy(item => item.Title)
.Select(item => new ContentItemDto(
item.Id,
item.WorkspaceId,
item.ClientId,
item.ProjectId,
item.Title,
item.PublicationMessage,
item.PublicationTargets,
item.Hashtags,
item.Status,
item.DueDate,
item.CurrentRevisionLabel,
item.CurrentRevisionNumber))
.ToListAsync(ct);
await SendOkAsync(items, ct);
}
}

View File

@@ -0,0 +1,105 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.ContentItems.Data;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.ContentItems.Handlers;
public record UpdateContentItemStatusRequest(string Status);
public class UpdateContentItemStatusRequestValidator
: Validator<UpdateContentItemStatusRequest>
{
public UpdateContentItemStatusRequestValidator()
{
RuleFor(x => x.Status).NotEmpty().MaximumLength(64);
}
}
public class UpdateContentItemStatusHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<UpdateContentItemStatusRequest, ContentItemDetailDto>
{
private static readonly HashSet<string> AllowedStatuses =
[
"Draft",
"In internal review",
"Changes requested internally",
"Internal changes in progress",
"Ready for client review",
"In client review",
"Changes requested by client",
"Client changes in progress",
"Approved",
"Rejected",
"Ready to publish",
"Published",
"Archived",
];
public override void Configure()
{
Post("/api/content-items/{id}/status");
Options(o => o.WithTags("Content Items"));
}
public override async Task HandleAsync(UpdateContentItemStatusRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, item.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
string normalizedStatus = request.Status.Trim();
if (!AllowedStatuses.Contains(normalizedStatus))
{
AddError(request => request.Status, "The requested status is not valid.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
item.Status = normalizedStatus;
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
item.WorkspaceId,
item.Id,
"content-item.status.updated",
"ContentItem",
item.Id,
$"Status changed to {item.Status} for {item.Title}.",
User.GetUserId(),
User.GetEmail(),
$$"""{"status":"{{item.Status}}"}"""),
ct);
ContentItemDetailDto dto = new(
item.Id,
item.WorkspaceId,
item.ClientId,
item.ProjectId,
item.Title,
item.PublicationMessage,
item.PublicationTargets,
item.Hashtags,
item.Status,
item.DueDate,
item.CurrentRevisionLabel,
item.CurrentRevisionNumber,
item.CreatedAt);
await SendOkAsync(dto, ct);
}
}

View File

@@ -0,0 +1,14 @@
namespace Socialize.Modules.Identity.Configuration;
public record JwtOptions
{
public const string SectionName = "Authentication:Jwt";
public required TimeSpan Lifetime { get; init; }
public required string Issuer { get; init; }
public required string Audience { get; init; }
public required string Key { get; init; }
public TimeSpan RefreshTokenLifetime { get; init; }
public bool RefreshTokenRequireRotation { get; init; }
}

View File

@@ -0,0 +1,6 @@
namespace Socialize.Modules.Identity.Contracts;
public interface IUserLookup
{
Task<UserReference?> GetUserAsync(Guid userId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Modules.Identity.Contracts;
public static class KnownRoles
{
public const string Administrator = nameof(Administrator);
public const string Manager = nameof(Manager);
public const string Client = nameof(Client);
public const string Provider = nameof(Provider);
public const string WorkspaceMember = nameof(WorkspaceMember);
}

View File

@@ -0,0 +1,6 @@
namespace Socialize.Modules.Identity.Contracts;
public record UserReference(
Guid Id,
string Fullname,
string? PortraitUrl);

View File

@@ -0,0 +1,88 @@
using System.Security.Claims;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Models;
namespace Socialize.Modules.Identity.Data;
public class IdentityService(
UserManager userManager,
IHttpContextAccessor contextAccessor
)
{
public async Task<UserModel?> GetCurrentUserAsync()
{
string? currentUserId = contextAccessor.HttpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(currentUserId))
{
return null;
}
UserModel? ret;
User? user = await userManager.FindByIdAsync(currentUserId);
if (user == null)
{
ret = null;
}
else
{
UserModel userModel = new()
{
Id = user.Id,
Username = user.UserName ?? string.Empty,
PhoneNumber = user.PhoneNumber ?? string.Empty,
Email = user.Email ?? string.Empty,
PortraitUrl = user.PortraitUrl,
Alias = user.Alias,
Firstname = user.Firstname,
Lastname = user.Lastname,
BirthDate = user.BirthDate,
Address = user.Address
};
ret = userModel;
}
return ret;
}
public async Task<IList<string>> GetCurrentUserRolesAsync()
{
UserModel? currentUserModel = await GetCurrentUserAsync();
if (currentUserModel is null)
{
return [];
}
User? currentUser = await userManager.FindByIdAsync(currentUserModel.Id.ToString());
if (currentUser is null)
{
return [];
}
IList<string> userRoles = await userManager.GetRolesAsync(currentUser);
return userRoles;
}
public async Task<IList<Claim>> GetCurrentUserClaimsAsync()
{
UserModel? currentUserModel = await GetCurrentUserAsync();
if (currentUserModel is null)
{
return [];
}
User? currentUser = await userManager.FindByIdAsync(currentUserModel.Id.ToString());
if (currentUser is null)
{
return [];
}
return await userManager.GetClaimsAsync(currentUser);
}
}

View File

@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Data;
public class Role : IdentityRole<Guid>
{
public Role() { }
public Role(string roleName) : base(roleName) { }
}

View File

@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Data;
public class User : IdentityUser<Guid>
{
[MaxLength(256)] public string? Alias { get; set; }
[MaxLength(256)] public string? Firstname { get; set; }
[MaxLength(256)] public string? Lastname { get; set; }
public DateTime? BirthDate { get; set; }
[MaxLength(256)] public string? Address { get; set; }
[MaxLength(2048)] public string? PortraitUrl { get; set; }
[MaxLength(256)] public string? GoogleId { get; set; }
[MaxLength(256)] public string? FacebookId { get; set; }
[MaxLength(44)] public string? RefreshToken { get; set; }
public DateTime RefreshTokenExpiryTime { get; set; }
public string Fullname => $"{Lastname}, {Firstname}";
}

View File

@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Socialize.Modules.Identity.Data;
public sealed class UserManager(
IUserStore<User> store,
IOptions<IdentityOptions> optionsAccessor,
IPasswordHasher<User> passwordHasher,
IEnumerable<IUserValidator<User>> userValidators,
IEnumerable<IPasswordValidator<User>> passwordValidators,
ILookupNormalizer keyNormalizer,
IdentityErrorDescriber errors,
IServiceProvider services,
ILogger<UserManager<User>> logger)
: UserManager<User>(
store,
optionsAccessor,
passwordHasher,
userValidators,
passwordValidators,
keyNormalizer,
errors,
services,
logger)
{
}

View File

@@ -0,0 +1,101 @@
using Socialize.Data;
using Socialize.Modules.Identity.Configuration;
using Socialize.Modules.Identity.Contracts;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Services;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity;
public static class DependencyInjection
{
public static WebApplicationBuilder AddIdentityModule(
this WebApplicationBuilder builder)
{
builder.Services.Configure<JwtOptions>(
builder.Configuration.GetRequiredSection(JwtOptions.SectionName));
builder.Services.AddAuthentication()
.AddBearerToken(IdentityConstants.BearerScheme);
builder.Services.AddAuthorizationBuilder();
builder.Services
.Configure<IdentityOptions>(options =>
{
if (!builder.Environment.IsDevelopment())
{
return;
}
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 3;
options.Password.RequiredUniqueChars = 1;
})
.AddIdentityCore<User>()
.AddUserManager<UserManager>()
.AddRoles<Role>()
.AddEntityFrameworkStores<AppDbContext>()
.AddApiEndpoints()
.AddDefaultTokenProviders();
// Singleton services
builder.Services.AddSingleton(TimeProvider.System);
// Scoped services
builder.Services.AddScoped<IdentityService>();
builder.Services.AddScoped<EmailVerificationService>();
builder.Services.AddScoped<AccessTokenFactory>();
builder.Services.AddScoped<IUserLookup, UserLookup>();
return builder;
}
public static async Task<IApplicationBuilder> UseIdentityModuleAsync(
this IApplicationBuilder app,
CancellationToken cancellationToken = default)
{
IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
using IServiceScope scope = scopeFactory.CreateScope();
RoleManager<Role> roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>();
await TrySeedAsync(roleManager);
return app;
}
private static async Task TrySeedAsync(RoleManager<Role> roleManager)
{
Role administratorRole = new(KnownRoles.Administrator);
if (roleManager.Roles.All(r => r.Name != administratorRole.Name))
{
await roleManager.CreateAsync(administratorRole);
}
Role managerRole = new(KnownRoles.Manager);
if (roleManager.Roles.All(r => r.Name != managerRole.Name))
{
await roleManager.CreateAsync(managerRole);
}
Role clientRole = new(KnownRoles.Client);
if (roleManager.Roles.All(r => r.Name != clientRole.Name))
{
await roleManager.CreateAsync(clientRole);
}
Role providerRole = new(KnownRoles.Provider);
if (roleManager.Roles.All(r => r.Name != providerRole.Name))
{
await roleManager.CreateAsync(providerRole);
}
Role workspaceMemberRole = new(KnownRoles.WorkspaceMember);
if (roleManager.Roles.All(r => r.Name != workspaceMemberRole.Name))
{
await roleManager.CreateAsync(workspaceMemberRole);
}
}
}

View File

@@ -0,0 +1,47 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeAddressRequest(
string? Address);
[PublicAPI]
public class ChangeAddressHandler(
UserManager userManager)
: Endpoint<ChangeAddressRequest>
{
public override void Configure()
{
Post("/api/users/address");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeAddressRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Address = request.Address;
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,47 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeAliasRequest(
string? Alias);
[PublicAPI]
public class ChangeAliasHandler(
UserManager userManager)
: Endpoint<ChangeAliasRequest>
{
public override void Configure()
{
Post("/api/users/alias");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeAliasRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Alias = request.Alias;
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,47 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeBirthDateRequest(
DateTime BirthDate);
[PublicAPI]
public class ChangeBirthDateHandler(
UserManager userManager)
: Endpoint<ChangeBirthDateRequest>
{
public override void Configure()
{
Post("/api/users/birthdate");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeBirthDateRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.BirthDate = request.BirthDate;
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,48 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeEmailRequest(
string? Email);
[PublicAPI]
public class ChangeEmailHandler(
UserManager userManager)
: Endpoint<ChangeEmailRequest>
{
public override void Configure()
{
Post("/api/users/email");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeEmailRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Email = request.Email;
// TODO: check to see if identity resets the `email confirmed` flag - @jonathan
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,49 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ChangeFullnameRequest(
string? Firstname,
string? Lastname);
[PublicAPI]
public class ChangeFullnameHandler(
UserManager userManager)
: Endpoint<ChangeFullnameRequest>
{
public override void Configure()
{
Post("/api/users/fullname");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeFullnameRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Firstname = request.Firstname;
user.Lastname = request.Lastname;
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,48 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ChangePhoneRequest(
string? PhoneNumber);
[PublicAPI]
public class ChangePhoneHandler(
UserManager userManager)
: Endpoint<ChangePhoneRequest>
{
public override void Configure()
{
Post("/api/users/phone");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangePhoneRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.PhoneNumber = request.PhoneNumber;
// TODO: check to see if identity resets the `phone confirmed` flag - @jonathan
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,74 @@
using Socialize.Infrastructure.BlobStorage.Contracts;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ChangePortraitRequest(
IFormFile File);
[PublicAPI]
public record ChangePortraitResponse(
string BlobUrl);
[PublicAPI]
public sealed class ChangePortraitRequestValidator : Validator<ChangePortraitRequest>
{
public ChangePortraitRequestValidator()
{
RuleFor(x => x.File)
.NotNull()
.NotEmpty();
}
}
[PublicAPI]
public class ChangePortraitHandler(
UserManager userManager,
IBlobStorage blobStorage)
: Endpoint<ChangePortraitRequest, ChangePortraitResponse>
{
public override void Configure()
{
Post("/api/users/portrait");
Options(o => o.WithTags("Users"));
AllowFileUploads();
}
public override async Task HandleAsync(
ChangePortraitRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
string blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Users,
$"{user.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
request.File.OpenReadStream(),
request.File.ContentType,
ct);
user.PortraitUrl = blobUrl;
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(
new ChangePortraitResponse(blobUrl),
ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,92 @@
using System.Web;
using Socialize.Infrastructure.Configuration;
using Socialize.Infrastructure.Emailer.Contracts;
using Socialize.Modules.Identity.Data;
using Microsoft.Extensions.Options;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ForgotPasswordRequest(
string Email);
[PublicAPI]
public class ForgotPasswordHandler(
UserManager userManager,
IEmailSender emailSender,
IOptionsSnapshot<WebsiteOptions> options)
: Endpoint<ForgotPasswordRequest>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/forgot-password");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ForgotPasswordRequest request,
CancellationToken ct)
{
// Find user by email
User? user = await userManager.FindByEmailAsync(request.Email);
// Always return OK even if user not found to prevent email enumeration
if (user is null)
{
await SendOkAsync(ct);
return;
}
// Generate password reset token
string token = await userManager.GeneratePasswordResetTokenAsync(user);
// URL encode the token as it may contain characters that are not URL safe
string encodedToken = HttpUtility.UrlEncode(token);
// Build reset link
string resetLink =
$"{options.Value.FrontendBaseUrl}/reset-password?email={HttpUtility.UrlEncode(request.Email)}&token={encodedToken}";
// Create a styled email message
string subject = "Reset your Socialize password";
string message = $"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<h1 style="color: #2c3e50; margin-bottom: 20px;">Reset Your Socialize Password</h1>
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 25px;">
Please click the button below to reset your password:
</p>
<div style="text-align: center; margin: 30px 0;">
<a href='{resetLink}'
style="background-color: #3498db;
color: white;
text-decoration: none;
padding: 12px 24px;
border-radius: 4px;
font-weight: bold;
display: inline-block;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
Reset Password
</a>
</div>
<p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;">
If you did not request a password reset, please ignore this email.
</p>
<p style="font-size: 14px; color: #7f8c8d; margin-top: 20px;">
If the button doesn't work, you can copy and paste this link into your browser:
<br>
<a href='{resetLink}' style="color: #3498db; word-break: break-all;">{resetLink}</a>
</p>
</div>
""";
// Send email
await emailSender.SendEmailAsync(request.Email, subject, message);
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,80 @@
using System.Security.Claims;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Models;
using Socialize.Infrastructure.Security;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public class GetCurrentUserQueryHandler(
IdentityService identityService)
: EndpointWithoutRequest<UserDto>
{
public override void Configure()
{
Get("/api/users/profile");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
CancellationToken cancellationToken)
{
UserModel? userModel = await identityService.GetCurrentUserAsync();
if (userModel is null)
{
await SendNotFoundAsync(cancellationToken);
return;
}
IList<string> roles = await identityService.GetCurrentUserRolesAsync();
IList<Claim> claims = await identityService.GetCurrentUserClaimsAsync();
string? persona = claims
.Where(claim => claim.Type == KnownClaims.Persona)
.Select(claim => claim.Value)
.LastOrDefault();
List<Guid> workspaceIds = claims
.Where(claim => claim.Type == KnownClaims.WorkspaceScope)
.Select(claim => Guid.TryParse(claim.Value, out Guid id) ? id : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
.ToList();
List<Guid> clientIds = claims
.Where(claim => claim.Type == KnownClaims.ClientScope)
.Select(claim => Guid.TryParse(claim.Value, out Guid id) ? id : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
.ToList();
List<Guid> projectIds = claims
.Where(claim => claim.Type == KnownClaims.ProjectScope)
.Select(claim => Guid.TryParse(claim.Value, out Guid id) ? id : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
.ToList();
await SendOkAsync(
new UserDto
{
Id = userModel.Id,
Persona = persona,
AuthorizedWorkspaceIds = workspaceIds,
AuthorizedClientIds = clientIds,
AuthorizedProjectIds = projectIds,
Alias = userModel.Alias,
PortraitUrl = userModel.PortraitUrl,
Firstname = userModel.Firstname,
Lastname = userModel.Lastname,
Username = userModel.Username,
PhoneNumber = userModel.PhoneNumber,
Email = userModel.Email,
BirthDate = userModel.BirthDate,
Address = userModel.Address,
UserRoles = roles
},
cancellationToken);
}
}

View File

@@ -0,0 +1,38 @@
using Socialize.Infrastructure.BlobStorage.Contracts;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Models;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public class GetCurrentUserPortraitHandler(
IdentityService identityService,
IBlobStorage blobStorage
)
: EndpointWithoutRequest<Stream>
{
public override void Configure()
{
Get("/api/users/portrait");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
CancellationToken cancellationToken)
{
UserModel? identityUser = await identityService.GetCurrentUserAsync();
if (identityUser is null)
{
await SendNotFoundAsync(cancellationToken);
return;
}
MemoryStream stream = await blobStorage.DownloadFileAsync(
ContainerNames.Users,
$"{identityUser.Id.ToString()}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
cancellationToken);
await SendOkAsync(stream, cancellationToken);
}
}

View File

@@ -0,0 +1,82 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Configuration;
using Socialize.Modules.Identity.Services;
using Microsoft.Extensions.Options;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record LoginRequest(
string Email,
string Password);
[PublicAPI]
public record LoginResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class LoginHandler(
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions,
AccessTokenFactory accessTokenFactory)
: Endpoint<LoginRequest, LoginResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/login");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
LoginRequest request,
CancellationToken ct)
{
// Find the user by email
User? user = await userManager.FindByEmailAsync(request.Email);
user ??= await userManager.FindByNameAsync(request.Email);
if (user is null)
{
await SendStringAsync(
"Invalid email or password",
401,
cancellation: ct);
return;
}
// Verify password
bool isPasswordValid = await userManager.CheckPasswordAsync(user, request.Password);
if (!isPasswordValid)
{
await SendStringAsync(
"Invalid email or password",
401,
cancellation: ct);
return;
}
// Check if the email is confirmed
if (!user.EmailConfirmed)
{
await SendStringAsync(
"Email not verified. Please check your email for verification instructions.",
401,
cancellation: ct);
return;
}
// Generate a new refresh token
user.RefreshToken = RefreshTokenGenerator.Next();
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
await userManager.UpdateAsync(user);
// Generate JWT token
string accessToken = await accessTokenFactory.CreateAsync(user);
await SendOkAsync(
new LoginResponse(accessToken, user.RefreshToken),
ct);
}
}

View File

@@ -0,0 +1,135 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Configuration;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public class FacebookUserInfo
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("email")] public string? Email { get; init; } // Email might be null if not granted
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("picture")] public required FacebookPictureData Picture { get; init; }
}
[PublicAPI]
public class FacebookPictureData
{
[JsonPropertyName("data")] public required FacebookPicture Picture { get; init; }
}
[PublicAPI]
public class FacebookPicture
{
[JsonPropertyName("url")] public required string Url { get; init; }
}
[PublicAPI]
public record LoginWithFacebookRequest(
string Token);
[PublicAPI]
public record LoginWithFacebookResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class LoginWithFacebookHandler(
IHttpClientFactory httpClientFactory,
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions,
AccessTokenFactory accessTokenFactory)
: Endpoint<LoginWithFacebookRequest, LoginWithFacebookResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/login-with-facebook");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
LoginWithFacebookRequest request,
CancellationToken ct)
{
// Verify the token with Facebook
using HttpClient httpClient = httpClientFactory.CreateClient();
using HttpResponseMessage response = await httpClient.GetAsync(
$"https://graph.facebook.com/me?access_token={request.Token}&fields=id,name,email,picture.width(200).height(200)",
ct);
if (!response.IsSuccessStatusCode)
{
await SendStringAsync(
"The token is not valid",
400,
cancellation: ct);
return;
}
// Extract the user info (email, name, profile picture)
string content = await response.Content.ReadAsStringAsync(ct);
FacebookUserInfo? userInfo = JsonSerializer.Deserialize<FacebookUserInfo>(content);
if (userInfo is null || string.IsNullOrEmpty(userInfo.Id))
{
await SendStringAsync(
"Failed to retrieve user information from Facebook",
400,
cancellation: ct);
return;
}
// Check if user exists or create a new one
User? user = await userManager.FindByEmailAsync(userInfo.Email!);
if (user is null)
{
string generatedPassword = PasswordGenerator.Next();
User generatedUser = new()
{
UserName = userInfo.Email ?? $"fb_{userInfo.Id}",
Email = userInfo.Email,
EmailConfirmed = true,
Firstname = userInfo.Name.Split(' ').FirstOrDefault() ?? "",
Lastname = userInfo.Name.Split(' ').Skip(1).FirstOrDefault() ?? "",
Alias = userInfo.Name,
PortraitUrl = userInfo.Picture.Picture.Url,
FacebookId = userInfo.Id // Storing Facebook ID
};
IdentityResult result = await userManager.CreateAsync(
generatedUser,
generatedPassword);
if (!result.Succeeded)
{
await SendStringAsync(
result.Errors.First().Description,
400,
cancellation: ct);
return;
}
user = generatedUser;
}
// Generate refresh token
string refreshToken = RefreshTokenGenerator.Next();
// Store refresh token in user's properties
user.RefreshToken = refreshToken;
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
await userManager.UpdateAsync(user);
string accessToken = await accessTokenFactory.CreateAsync(user);
await SendOkAsync(
new LoginWithFacebookResponse(accessToken, refreshToken),
ct);
}
}

View File

@@ -0,0 +1,139 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Configuration;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Socialize.Modules.Identity.Handlers;
internal class GoogleToken
{
[JsonPropertyName("access_token")] public required string AccessToken { get; init; }
[JsonPropertyName("token_type")] public required string TokenType { get; init; }
[JsonPropertyName("expires_in")] public required int ExpiresIn { get; init; }
[JsonPropertyName("scope")] public required string Scope { get; init; }
[JsonPropertyName("authuser")] public required string AuthUser { get; init; }
[JsonPropertyName("prompt")] public required string Prompt { get; init; }
}
public class GoogleUserInfo
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("email")] public required string Email { get; init; }
[JsonPropertyName("verified_email")] public required bool VerifiedEmail { get; init; }
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("given_name")] public required string GivenName { get; init; }
[JsonPropertyName("family_name")] public string FamilyName { get; init; } = string.Empty;
[JsonPropertyName("picture")] public required string Picture { get; init; }
}
[PublicAPI]
public record LoginWithGoogleRequest(
string Token);
[PublicAPI]
public record LoginWithGoogleResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class LoginWithGoogleHandler(
IHttpClientFactory httpClientFactory,
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions,
AccessTokenFactory accessTokenFactory)
: Endpoint<LoginWithGoogleRequest, LoginWithGoogleResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/login-with-google");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
LoginWithGoogleRequest request,
CancellationToken ct)
{
GoogleToken googleToken = JsonSerializer.Deserialize<GoogleToken>(request.Token)!;
// Verify the token with Google
using HttpClient httpClient = httpClientFactory.CreateClient();
using HttpResponseMessage response = await httpClient.GetAsync(
$"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}",
ct);
if (!response.IsSuccessStatusCode)
{
await SendStringAsync(
"The token is not valid",
400,
cancellation: ct);
return;
}
// Extract the user info (email, name, etc.).
string content = await response.Content.ReadAsStringAsync(ct);
GoogleUserInfo? userInfo = JsonSerializer.Deserialize<GoogleUserInfo>(content);
if (userInfo is null
|| !userInfo.VerifiedEmail
|| string.IsNullOrEmpty(userInfo.Email))
{
await SendStringAsync(
"The token does not contain an email",
400,
cancellation: ct);
return;
}
// Check if the user exists or create a new one
User? user = await userManager.FindByEmailAsync(userInfo.Email);
if (user is null)
{
string generatedPassword = PasswordGenerator.Next();
string refreshToken = RefreshTokenGenerator.Next();
User generatedUser = new()
{
UserName = userInfo.Email,
Email = userInfo.Email,
EmailConfirmed = true,
Firstname = userInfo.GivenName,
Lastname = userInfo.FamilyName,
Alias = userInfo.Name,
PortraitUrl = userInfo.Picture,
GoogleId = userInfo.Id,
RefreshToken = refreshToken,
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime)
};
IdentityResult result = await userManager.CreateAsync(
generatedUser,
generatedPassword);
if (!result.Succeeded)
{
await SendStringAsync(
result.Errors.First().Description,
400,
cancellation: ct);
return;
}
user = generatedUser;
}
// Generate the new refresh token
user.RefreshToken = RefreshTokenGenerator.Next();
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
await userManager.UpdateAsync(user);
string accessToken = await accessTokenFactory.CreateAsync(user);
await SendOkAsync(
new LoginWithGoogleResponse(accessToken, user.RefreshToken),
ct);
}
}

View File

@@ -0,0 +1,63 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Configuration;
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Services;
using Microsoft.Extensions.Options;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record RefreshTokenRequest(
string RefreshToken);
[PublicAPI]
public record RefreshTokenResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class RefreshTokenHandler(
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions,
AccessTokenFactory accessTokenFactory)
: Endpoint<RefreshTokenRequest, RefreshTokenResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/refresh");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
RefreshTokenRequest request,
CancellationToken ct)
{
// Find the user using the refresh token
User? user = await userManager.Users
.FirstOrDefaultAsync(u => u.RefreshToken == request.RefreshToken, ct);
if (user == null || user.RefreshTokenExpiryTime <= DateTime.UtcNow)
{
await SendUnauthorizedAsync(ct);
return;
}
// Generate a new refresh token if rotation is required
if (jwtOptions.Value.RefreshTokenRequireRotation || user.RefreshToken is null)
{
user.RefreshToken = RefreshTokenGenerator.Next();
}
// Update refresh token expiry time
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
await userManager.UpdateAsync(user);
// Generate a new access token
string accessToken = await accessTokenFactory.CreateAsync(user);
await SendOkAsync(
new RefreshTokenResponse(accessToken, user.RefreshToken),
ct);
}
}

View File

@@ -0,0 +1,79 @@
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Services;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record RegisterRequest(
string Email,
string Password,
string Name);
[PublicAPI]
public record RegisterResponse(
string Message);
[PublicAPI]
public class RegisterHandler(
UserManager userManager,
EmailVerificationService emailVerificationService)
: Endpoint<RegisterRequest, RegisterResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/register");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
RegisterRequest request,
CancellationToken ct)
{
// Check if the user already exists
User? existingUser = await userManager.FindByEmailAsync(request.Email);
if (existingUser is not null)
{
await SendStringAsync(
"A user with this email already exists",
400,
cancellation: ct);
return;
}
// Split the name into firstname and lastname (if provided)
string[] nameParts = request.Name.Split(' ', 2);
string firstname = nameParts[0];
string lastname = nameParts.Length > 1 ? nameParts[1] : string.Empty;
// Create a new user
User user = new()
{
UserName = request.Email,
Email = request.Email,
Firstname = firstname,
Lastname = lastname,
Alias = request.Name
};
IdentityResult result = await userManager.CreateAsync(
user,
request.Password);
if (!result.Succeeded)
{
await SendStringAsync(
result.Errors.First().Description,
400,
cancellation: ct);
return;
}
await emailVerificationService.SendVerificationEmailAsync(user);
await SendOkAsync(
new RegisterResponse("Registration successful! Please check your email to verify your account."),
ct);
}
}

View File

@@ -0,0 +1,58 @@
using Socialize.Modules.Identity.Data;
using Socialize.Modules.Identity.Services;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ResendVerificationRequest(
string Email);
[PublicAPI]
public record ResendVerificationResponse(
string Message);
[PublicAPI]
public class ResendVerificationHandler(
EmailVerificationService emailWriter,
UserManager userManager)
: Endpoint<ResendVerificationRequest, ResendVerificationResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/resend-verification");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ResendVerificationRequest request,
CancellationToken ct)
{
// Find a user by email
User? user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
{
// Don't reveal that the user doesn't exist
await SendOkAsync(
new ResendVerificationResponse(
"If your email exists in our system, a verification link has been sent."),
ct);
return;
}
// Check if the email is already confirmed
if (user.EmailConfirmed)
{
await SendOkAsync(
new ResendVerificationResponse("Your email is already verified. You can log in."),
ct);
return;
}
await emailWriter.SendVerificationEmailAsync(user);
await SendOkAsync(
new ResendVerificationResponse("If your email exists in our system, a verification link has been sent."),
ct);
}
}

View File

@@ -0,0 +1,56 @@
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record ResetPasswordRequest(
string Email,
string Token,
string NewPassword);
[PublicAPI]
public class ResetPasswordHandler(
UserManager userManager)
: Endpoint<ResetPasswordRequest>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/reset-password");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ResetPasswordRequest request,
CancellationToken ct)
{
// Find user by email
User? user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
{
await SendStringAsync(
"Invalid request",
400,
cancellation: ct);
return;
}
// Reset password with token
IdentityResult result = await userManager.ResetPasswordAsync(
user,
request.Token,
request.NewPassword);
if (!result.Succeeded)
{
await SendStringAsync(
"Invalid or expired token",
400,
cancellation: ct);
return;
}
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,51 @@
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record SetPasswordRequest(
string NewPassword);
[PublicAPI]
public class SetPasswordHandler(
UserManager userManager)
: Endpoint<SetPasswordRequest>
{
public override void Configure()
{
Post("/api/users/set-password");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
SetPasswordRequest request,
CancellationToken ct)
{
// Get current user id from claims
string userId = User.GetUserId().ToString();
// Get user from database
User? user = await userManager.FindByIdAsync(userId);
if (user is null)
{
await SendForbiddenAsync(ct);
return;
}
string resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
IdentityResult result = await userManager.ResetPasswordAsync(user, resetToken, request.NewPassword);
if (!result.Succeeded)
{
await SendStringAsync(
result.Errors.First().Description,
400,
cancellation: ct);
return;
}
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,60 @@
using System.Web;
using Socialize.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity.Handlers;
[PublicAPI]
public record VerifyEmailRequest(
string UserId,
string Token);
[PublicAPI]
public record VerifyEmailResponse(
string Message);
[PublicAPI]
public class VerifyEmailHandler(
UserManager userManager)
: Endpoint<VerifyEmailRequest, VerifyEmailResponse>
{
public override void Configure()
{
AllowAnonymous();
Get("/api/users/verify-email");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
VerifyEmailRequest request,
CancellationToken ct)
{
// Find user by ID
User? user = await userManager.FindByIdAsync(request.UserId);
if (user is null)
{
await SendStringAsync(
"Invalid verification link",
400,
cancellation: ct);
return;
}
// Verify the token and confirm email
string decoded = HttpUtility.UrlDecode(request.Token);
string decodedWithPlus = request.Token.Replace(" ", "+");
IdentityResult result = await userManager.ConfirmEmailAsync(user, decodedWithPlus);
if (!result.Succeeded)
{
await SendStringAsync(
"Invalid verification link or the link has expired",
400,
cancellation: ct);
return;
}
await SendOkAsync(
new VerifyEmailResponse("Email verification successful! You can now log in."),
ct);
}
}

View File

@@ -0,0 +1,14 @@
using Socialize.Modules.Identity.Models;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Modules.Identity;
public static class IdentityResultExtensions
{
public static Result ToApplicationResult(this IdentityResult result)
{
return result.Succeeded
? Result.Success()
: Result.Failure(result.Errors.Select(e => e.Description));
}
}

View File

@@ -0,0 +1,49 @@
namespace Socialize.Modules.Identity.Models;
public class Result(
bool succeeded,
IEnumerable<string> errors)
{
public bool Succeeded { get; init; } = succeeded;
public string[] Errors { get; init; } = errors.ToArray();
public static Result Success()
{
return new Result(true, Array.Empty<string>());
}
public static Result Failure(IEnumerable<string> errors)
{
return new Result(false, errors);
}
}
public class Result<T>(
T? value,
bool succeeded,
IEnumerable<string> errors)
{
public bool Succeeded { get; init; } = succeeded;
public string[] Errors { get; init; } = errors.ToArray();
public T? Value { get; set; } = value;
public T GetValueOrDefault()
{
return Value ?? default(T)!;
}
public string GetErrorsAsString()
{
return Errors.Length == 0 ? string.Empty : string.Join(", ", Errors);
}
public static Result<T> Success(T value)
{
return new Result<T>(value, true, Array.Empty<string>());
}
public static Result<T> Failure(T value, IEnumerable<string> errors)
{
return new Result<T>(value, false, errors);
}
}

View File

@@ -0,0 +1,7 @@
namespace Socialize.Modules.Identity.Models;
public class RoleModel
{
public Guid Id { get; set; }
public string? Name { get; set; }
}

View File

@@ -0,0 +1,20 @@
namespace Socialize.Modules.Identity.Models;
public class UserDto
{
public Guid Id { get; init; }
public IList<string> UserRoles { get; init; } = [];
public string? Persona { get; init; }
public IList<Guid> AuthorizedWorkspaceIds { get; init; } = [];
public IList<Guid> AuthorizedClientIds { get; init; } = [];
public IList<Guid> AuthorizedProjectIds { get; init; } = [];
public string Username { get; init; } = null!;
public string? Alias { get; init; }
public string? PortraitUrl { get; init; }
public string? Firstname { get; init; }
public string? Lastname { get; init; }
public string? Email { get; init; }
public string? PhoneNumber { get; init; }
public DateTime? BirthDate { get; init; }
public string? Address { get; init; }
}

View File

@@ -0,0 +1,15 @@
namespace Socialize.Modules.Identity.Models;
public class UserModel
{
public Guid Id { get; set; }
public string Username { get; init; } = null!;
public string? Alias { get; init; }
public string? PortraitUrl { get; init; }
public string? Firstname { get; init; }
public string? Lastname { get; init; }
public string? Email { get; init; }
public string? PhoneNumber { get; init; }
public DateTime? BirthDate { get; init; }
public string? Address { get; init; }
}

View File

@@ -0,0 +1,43 @@
using System.Security.Claims;
using Socialize.Infrastructure.Security;
using Socialize.Modules.Identity.Configuration;
using Socialize.Modules.Identity.Contracts;
using Socialize.Modules.Identity.Data;
using Microsoft.Extensions.Options;
namespace Socialize.Modules.Identity.Services;
public sealed class AccessTokenFactory(
UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions)
{
public async Task<string> CreateAsync(User user)
{
IList<string> roles = await userManager.GetRolesAsync(user);
IList<Claim> claims = await userManager.GetClaimsAsync(user);
string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal)
? KnownRoles.Manager
: roles.Contains(KnownRoles.Client, StringComparer.Ordinal)
? KnownRoles.Client
: roles.Contains(KnownRoles.Provider, StringComparer.Ordinal)
? KnownRoles.Provider
: KnownRoles.WorkspaceMember;
List<Claim> tokenClaims = [.. claims, new Claim(KnownClaims.Persona, persona)];
return JwtTokenHelper.GenerateJwtToken(
jwtOptions.Value.Lifetime,
jwtOptions.Value.Issuer,
jwtOptions.Value.Audience,
jwtOptions.Value.Key,
user.Id.ToString(),
user.Email ?? string.Empty,
user.Alias,
user.Firstname ?? string.Empty,
user.Lastname ?? string.Empty,
user.PortraitUrl,
roles,
tokenClaims);
}
}

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