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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
namespace Socialize.Infrastructure.BlobStorage.Contracts;
public interface IBlobStorage
{
/// <summary>
/// Upload a file to blob storage.
/// </summary>
/// <param name="containerName">The name of the container where the file is stored.</param>
/// <param name="blobName">The blob name (path within the container, include the file name).</param>
/// <param name="stream"></param>
/// <param name="contentType">The content type.</param>
/// <param name="ct">The cancellation token</param>
/// <returns></returns>
Task<string> UploadFileAsync(
string containerName,
string blobName,
Stream stream,
string contentType,
CancellationToken ct = default);
/// <summary>
/// Download a file to blob storage.
/// </summary>
/// <param name="blobName">The blob name (path within the container).</param>
/// <param name="containerName">The name of the container where the file is stored. (users)</param>
/// <param name="ct">The cancellation token for the request</param>
/// <returns></returns>
Task<MemoryStream> DownloadFileAsync(
string containerName,
string blobName,
CancellationToken ct = default);
}

View File

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

View File

@@ -0,0 +1,154 @@
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Socialize.Infrastructure.BlobStorage.Contracts;
namespace Socialize.Infrastructure.BlobStorage.Services;
public class AzureBlobStorage : IBlobStorage
{
private const long MaxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes
private readonly BlobServiceClient _blobServiceClient;
private readonly ILogger<AzureBlobStorage> _logger;
public AzureBlobStorage(IConfiguration configuration, ILogger<AzureBlobStorage> logger)
{
_logger = logger;
string? connectionString = configuration.GetConnectionString("AzureBlob");
_blobServiceClient = new BlobServiceClient(connectionString);
}
/// <summary>
/// Upload a file to microsoft azure blob storage.
/// </summary>
/// <param name="containerName">The name of the container where the file is stored.</param>
/// <param name="blobName">The blob name (path within the container, include the file name).</param>
/// <param name="stream"></param>
/// <param name="contentType">The content type.</param>
/// <param name="ct">The cancellation token</param>
/// <returns></returns>
public async Task<string> UploadFileAsync(
string containerName,
string blobName,
Stream stream,
string contentType,
CancellationToken ct = default)
{
// Read the file stream into a memory stream to determine the length
// WATCH FOR MEMORY USAGE USING THE MEMORY STREAM.
stream.Position = 0;
// Check if the file size exceeds the maximum upload size
if (stream.Length > MaxUploadSize)
{
_logger.LogError(
$"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
throw new InvalidOperationException(
$"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
}
// Validate content type
if (!ContentTypes.IsAllowed(contentType, stream))
{
_logger.LogError(
$"Blob storage: Unsupported file type {contentType}.");
throw new InvalidOperationException("Unsupported file type.");
}
try
{
// Get a reference to a container
BlobContainerClient? containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
// Create the container if it does not exist
await containerClient.CreateIfNotExistsAsync(
PublicAccessType.Blob,
cancellationToken: ct);
// Get a reference to a blob
BlobClient? blobClient = containerClient.GetBlobClient(blobName);
// Define the BlobHttpHeaders to include the content type
BlobHttpHeaders blobHttpHeaders = new() { ContentType = contentType };
// Upload the file
Response<BlobContentInfo>? response = await blobClient.UploadAsync(
stream,
new BlobUploadOptions { HttpHeaders = blobHttpHeaders },
ct);
string fileUri = blobClient.Uri.ToString();
_logger.LogInformation(
"""
Blob storage: Status [ {ResponseStatus} ]
Uploaded [ {BlobName} ] to the container [ {ContainerName} ]
with contentType [ {ContentType} ]
with a length of [ {StreamLength} bytes ]
with the uri [ {FileUri} ]
""",
response.GetRawResponse().Status.ToString(),
blobName,
containerName,
contentType,
stream.Length,
fileUri
);
// Return the URI of the uploaded blob
return fileUri;
}
catch (RequestFailedException ex)
{
_logger.LogError($"Blob storage: Azure Storage request failed: {ex.Message}");
throw;
}
catch (Exception ex)
{
_logger.LogError($"Blob storage: An error occurred: {ex.Message}");
throw;
}
}
/// <summary>
/// Download a file to microsoft's azure blob storage.
/// </summary>
/// <param name="blobName">The blob name (path within the container).</param>
/// <param name="containerName">The name of the container where the file is stored. (users)</param>
/// <param name="ct">The cancellation token for the request</param>
/// <returns></returns>
public async Task<MemoryStream> DownloadFileAsync(
string containerName,
string blobName,
CancellationToken ct = default)
{
try
{
// Get a reference to a container
BlobContainerClient? containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
// Get a reference to a blob
BlobClient? blobClient = containerClient.GetBlobClient(blobName);
// Download the blob to a stream
BlobDownloadInfo download = await blobClient.DownloadAsync(ct);
MemoryStream memoryStream = new();
await download.Content.CopyToAsync(memoryStream, ct);
memoryStream.Position = 0; // Ensure the stream is at the beginning
return memoryStream;
}
catch (RequestFailedException ex)
{
_logger.LogError($"Azure Storage request failed: {ex.Message}");
throw;
}
catch (Exception ex)
{
_logger.LogError($"An error occurred: {ex.Message}");
throw;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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