chore: moving towards agentic development
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user