chore: moving towards agentic development
This commit is contained in:
10
backend/src/Socialize.Api/Common/Domain/Entity.cs
Normal file
10
backend/src/Socialize.Api/Common/Domain/Entity.cs
Normal 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; }
|
||||
}
|
||||
226
backend/src/Socialize.Api/Data/AppDbContext.cs
Normal file
226
backend/src/Socialize.Api/Data/AppDbContext.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
112
backend/src/Socialize.Api/DependencyInjection.cs
Normal file
112
backend/src/Socialize.Api/DependencyInjection.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
25
backend/src/Socialize.Api/Dockerfile
Normal file
25
backend/src/Socialize.Api/Dockerfile
Normal 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"]
|
||||
5
backend/src/Socialize.Api/Folder.DotSettings
Normal file
5
backend/src/Socialize.Api/Folder.DotSettings
Normal 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>
|
||||
14
backend/src/Socialize.Api/GlobalUsings.cs
Normal file
14
backend/src/Socialize.Api/GlobalUsings.cs
Normal 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;
|
||||
@@ -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
|
||||
│ │ └── ...
|
||||
│
|
||||
└── ...
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Socialize.Infrastructure.Configuration;
|
||||
|
||||
public class WebsiteOptions
|
||||
{
|
||||
public const string SectionName = "Website";
|
||||
|
||||
public string FrontendBaseUrl { get; set; } = "http://localhost:5173";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Socialize.Infrastructure.Development;
|
||||
|
||||
public record DevelopmentSeedOptions
|
||||
{
|
||||
public const string SectionName = "DevelopmentSeed";
|
||||
|
||||
public bool Enabled { get; init; } = true;
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Socialize.Infrastructure.Emailer.Contracts;
|
||||
|
||||
public interface IEmailSender
|
||||
{
|
||||
Task SendEmailAsync(string email, string subject, string message);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Socialize.Infrastructure.Security;
|
||||
|
||||
public class MissingClaimException(
|
||||
string claimName)
|
||||
: Exception($"Claim '{claimName}' is missing.");
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
942
backend/src/Socialize.Api/Migrations/20260423061407_Initial.Designer.cs
generated
Normal file
942
backend/src/Socialize.Api/Migrations/20260423061407_Initial.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
657
backend/src/Socialize.Api/Migrations/20260423061407_Initial.cs
Normal file
657
backend/src/Socialize.Api/Migrations/20260423061407_Initial.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
16
backend/src/Socialize.Api/Modules/Assets/Data/Asset.cs
Normal file
16
backend/src/Socialize.Api/Modules/Assets/Data/Asset.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
14
backend/src/Socialize.Api/Modules/Clients/Data/Client.cs
Normal file
14
backend/src/Socialize.Api/Modules/Clients/Data/Client.cs
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
16
backend/src/Socialize.Api/Modules/Comments/Data/Comment.cs
Normal file
16
backend/src/Socialize.Api/Modules/Comments/Data/Comment.cs
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Socialize.Modules.Identity.Contracts;
|
||||
|
||||
public interface IUserLookup
|
||||
{
|
||||
Task<UserReference?> GetUserAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Socialize.Modules.Identity.Contracts;
|
||||
|
||||
public record UserReference(
|
||||
Guid Id,
|
||||
string Fullname,
|
||||
string? PortraitUrl);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
9
backend/src/Socialize.Api/Modules/Identity/Data/Role.cs
Normal file
9
backend/src/Socialize.Api/Modules/Identity/Data/Role.cs
Normal 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) { }
|
||||
}
|
||||
19
backend/src/Socialize.Api/Modules/Identity/Data/User.cs
Normal file
19
backend/src/Socialize.Api/Modules/Identity/Data/User.cs
Normal 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}";
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
82
backend/src/Socialize.Api/Modules/Identity/Handlers/Login.cs
Normal file
82
backend/src/Socialize.Api/Modules/Identity/Handlers/Login.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
49
backend/src/Socialize.Api/Modules/Identity/Models/Result.cs
Normal file
49
backend/src/Socialize.Api/Modules/Identity/Models/Result.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Socialize.Modules.Identity.Models;
|
||||
|
||||
public class RoleModel
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
20
backend/src/Socialize.Api/Modules/Identity/Models/UserDto.cs
Normal file
20
backend/src/Socialize.Api/Modules/Identity/Models/UserDto.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user