feat: pivot to social media workflow app
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-04-24 12:58:35 -04:00
parent 0f4acc1b6d
commit df3e602015
349 changed files with 18685 additions and 16010 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Hutopy.Infrastructure.BlobStorage.Contracts;
using Socialize.Infrastructure.BlobStorage.Contracts;
namespace Hutopy.Infrastructure.BlobStorage.Services;
namespace Socialize.Infrastructure.BlobStorage.Services;
public class AzureBlobStorage : IBlobStorage
{

View File

@@ -1,8 +1,8 @@
namespace Hutopy.Infrastructure.Configuration;
namespace Socialize.Infrastructure.Configuration;
public class WebsiteOptions
{
public const string SectionName = "Website";
public string FrontendBaseUrl { get; set; } = "https://localhost:5173";
public string FrontendBaseUrl { get; set; } = "http://localhost:5173";
}

View File

@@ -1,15 +1,12 @@
using Hutopy.Infrastructure.BlobStorage.Contracts;
using Hutopy.Infrastructure.BlobStorage.Services;
using Hutopy.Infrastructure.Configuration;
using Hutopy.Infrastructure.Emailer.Configuration;
using Hutopy.Infrastructure.Emailer.Contracts;
using Hutopy.Infrastructure.Emailer.Services;
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
using Hutopy.Infrastructure.Payments.Stripe.Services;
using Hutopy.Modules.Memberships.Contracts;
using Hutopy.Modules.Tipping.Contracts;
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 Hutopy.Infrastructure;
namespace Socialize.Infrastructure;
public static class DependencyInjection
{
@@ -20,11 +17,6 @@ public static class DependencyInjection
builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName));
builder.Services.AddTransient<IBlobStorage, AzureBlobStorage>();
builder.Services.AddTransient<ITipProcessor, StripeTipProcessor>();
builder.Services.AddTransient<IMembershipPaymentProcessor, MembershipPaymentProcessor>();
builder.Services.AddTransient<IMembershipCancellationProcessor, MembershipCancellationProcessor>();
builder.Services.AddTransient<IMembershipTierProcessor, MembershipTierProcessor>();
builder.Services.Configure<StripeOptions>(
builder.Configuration.GetSection(StripeOptions.ConfigurationSection));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Infrastructure.Payments.Stripe.Configuration;
namespace Socialize.Infrastructure.Payments.Stripe.Configuration;
public class StripeOptions
{
@@ -10,5 +10,5 @@ public class StripeOptions
[Required] public required string WebhookSecret { get; init; }
[Required] [Range(0, 1)] public required decimal HutopyRate { get; init; }
[Required] [Range(0, 1)] public required decimal SocializeRate { get; init; }
}

View File

@@ -1,28 +0,0 @@
using Hutopy.Modules.Memberships.Contracts;
using Stripe;
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
public sealed class MembershipCancellationProcessor
: IMembershipCancellationProcessor
{
public async Task<DateTimeOffset?> CancelAsync(
string subscriptionId,
CancellationToken ct = default)
{
SubscriptionService subscriptionService = new();
// Stripe - Cancel Subscription immediately
// var subscription = await subscriptionService.CancelAsync(
// subscriptionId,
// cancellationToken: ct);
// Stripe - Cancel Subscription AtPeriodEnd
Subscription? subscription = await subscriptionService.UpdateAsync(
subscriptionId,
new SubscriptionUpdateOptions { CancelAtPeriodEnd = true },
cancellationToken: ct);
return subscription.CancelAt ?? subscription.CanceledAt;
}
}

View File

@@ -1,68 +0,0 @@
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
using Hutopy.Modules.Creators.Contracts;
using Hutopy.Modules.Memberships.Contracts;
using Microsoft.Extensions.Options;
using Stripe;
using Stripe.Checkout;
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
public class MembershipPaymentProcessor(
IOptions<StripeOptions> stripeOptions)
: IMembershipPaymentProcessor
{
public async Task<MembershipCheckoutSession> CreateCheckoutSessionAsync(
Guid userId,
CreatorReference creatorReference,
Guid tierId,
string priceId,
string successUrl,
string cancelUrl)
{
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
// Create Stripe customer for the user if not already created
CustomerService customerService = new();
Customer? customer = await customerService.CreateAsync(
new CustomerCreateOptions
{
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } }
});
// Create Checkout Session for the subscription
SessionService sessionService = new();
Session? session = await sessionService.CreateAsync(
new SessionCreateOptions
{
Customer = customer.Id,
PaymentMethodTypes = ["card"],
LineItems =
[
new SessionLineItemOptions { Price = priceId, Quantity = 1 }
],
Mode = "subscription",
SubscriptionData = new SessionSubscriptionDataOptions
{
ApplicationFeePercent = stripeOptions.Value.HutopyRate,
TransferData =
new SessionSubscriptionDataTransferDataOptions
{
Destination = creatorReference.StripeAccountId
}
},
SuccessUrl = successUrl, // Redirect after successful payment
CancelUrl = cancelUrl, // Redirect after canceled payment
Metadata = new Dictionary<string, string>
{
{ "userId", userId.ToString() },
{ "creatorId", creatorReference.Id.ToString() },
{ "creatorName", creatorReference.Name },
{ "tierId", tierId.ToString() }
}
});
return new MembershipCheckoutSession(
session.Id,
session.Url);
}
}

View File

@@ -1,43 +0,0 @@
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
using Hutopy.Modules.Memberships.Contracts;
using Microsoft.Extensions.Options;
using Stripe;
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
public sealed class MembershipTierProcessor(
IOptions<StripeOptions> stripeOptions)
: IMembershipTierProcessor
{
public async Task<string> CreateAsync(
Guid creatorId,
Guid tierId,
string productName,
string currencyCode,
decimal amount)
{
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
// Create the product
ProductService productService = new();
Product? product = await productService.CreateAsync(
new ProductCreateOptions
{
Name = productName,
Metadata = { { "creatorId", creatorId.ToString() }, { "tierId", tierId.ToString() } }
});
// Create the price for the product
PriceService priceService = new();
await priceService.CreateAsync(
new PriceCreateOptions
{
Product = product.Id,
UnitAmountDecimal = amount * 100, // Convert amount to cents
Currency = currencyCode,
Recurring = new PriceRecurringOptions { Interval = "month" }
});
return product.Id;
}
}

View File

@@ -1,70 +0,0 @@
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
using Hutopy.Modules.Creators.Contracts;
using Hutopy.Modules.Tipping.Contracts;
using Microsoft.Extensions.Options;
using Stripe;
using Stripe.Checkout;
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
internal class StripeTipProcessor(
IOptions<StripeOptions> stripeOptions)
: ITipProcessor
{
public async Task<TipCheckoutSession> CreateCheckoutSessionAsync(
Guid tipId,
CreatorReference creator,
decimal amount,
string currency,
string message,
Uri successUrl,
Uri cancelUrl,
CancellationToken ct = default)
{
var applicationFeeAmount = Convert.ToInt64(amount * stripeOptions.Value.HutopyRate);
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
var sessionService = new SessionService();
var options = new SessionCreateOptions
{
ClientReferenceId = tipId.ToString(),
Mode = "payment",
LineItems =
[
new SessionLineItemOptions
{
PriceData = new SessionLineItemPriceDataOptions
{
Currency = currency,
UnitAmountDecimal = amount, // Amount in cents
ProductData = new SessionLineItemPriceDataProductDataOptions
{
Name = $"Tip for {creator.Name}",
Metadata = new Dictionary<string, string> { { "creatorId", creator.Id.ToString() } }
}
},
Quantity = 1
}
],
PaymentIntentData = new SessionPaymentIntentDataOptions { ApplicationFeeAmount = applicationFeeAmount },
Metadata = new Dictionary<string, string>
{
{ "creatorId", creator.Id.ToString() }, { "creatorName", creator.Name }, { "message", message }
},
SuccessUrl = successUrl.ToString(), // Redirect after successful payment
CancelUrl = cancelUrl.ToString(), // Redirect after canceled payment
};
var requestOptions = new RequestOptions { StripeAccount = creator.StripeAccountId };
var session = await sessionService.CreateAsync(
options,
requestOptions,
cancellationToken: ct)
.ConfigureAwait(false);
return new TipCheckoutSession(session.Id, session.Url);
}
}

View File

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

View File

@@ -1,9 +1,38 @@
using System.Security.Claims;
namespace Hutopy.Infrastructure.Security;
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);

View File

@@ -3,7 +3,7 @@ using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace Hutopy.Infrastructure.Security;
namespace Socialize.Infrastructure.Security;
public static class JwtTokenHelper
{
@@ -17,7 +17,9 @@ public static class JwtTokenHelper
string? alias,
string firstname,
string lastname,
string? portraitUrl)
string? portraitUrl,
IEnumerable<string> roles,
IEnumerable<Claim> additionalClaims)
{
SymmetricSecurityKey securityKey = new(Encoding.UTF8.GetBytes(key));
SigningCredentials credentials = new(securityKey, SecurityAlgorithms.HmacSha256);
@@ -40,6 +42,18 @@ public static class JwtTokenHelper
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,

View File

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

View File

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

View File

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

View File

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

View File

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