Add 'backend/' from commit '040cfd7a75423d4e6136e58a67b40579af4ee966'

git-subtree-dir: backend
git-subtree-mainline: ab911955ed
git-subtree-split: 040cfd7a75
This commit is contained in:
2025-01-15 15:24:30 -05:00
179 changed files with 14349 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.3",
"commands": [
"dotnet-ef"
]
}
}
}

View File

@@ -0,0 +1,147 @@
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
namespace Hutopy.Web.Common.BlobStorage;
public class AzureBlobStorage
{
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;
var 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
var 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
var blobClient = containerClient.GetBlobClient(blobName);
// Define the BlobHttpHeaders to include the content type
var blobHttpHeaders = new BlobHttpHeaders { ContentType = contentType };
// Upload the file
var response = await blobClient.UploadAsync(
stream,
new BlobUploadOptions { HttpHeaders = blobHttpHeaders },
ct);
var 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
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
// Get a reference to a blob
var 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,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,7 @@
namespace Hutopy.Web.Common.BlobStorage;
public static class CommonFileNames
{
public static string ProfilePicture = "profilePicture";
public static string BannerPicture = "bannerPicture";
}

View File

@@ -0,0 +1,7 @@
namespace Hutopy.Web.Common.BlobStorage;
public static class ContainerNames
{
public const string Users = "users";
public const string Creators = "creators";
}

View File

@@ -0,0 +1,49 @@
using System.Text;
namespace Hutopy.Web.Common.BlobStorage;
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);
if (content.Contains("<!DOCTYPE html>"))
{
return true;
}
return false;
}
}

View File

@@ -0,0 +1,7 @@
namespace Hutopy.Web.Common.BlobStorage;
public static class SubDirectoryNames
{
public static string Profile = "profile";
public static string Contents = "contents";
}

View File

@@ -0,0 +1,94 @@
namespace Hutopy.Web.Common;
/// <summary>
/// Adapted from https://raw.githubusercontent.com/uuidjs/uuid/main/src/v7.ts.
/// to match the uuid v7 generated on the client
/// </summary>
public static class GuidHelper
{
private class V7State
{
public long Msecs { get; set; } = long.MinValue;
public int Seq { get; set; }
}
private static readonly V7State State = new();
private static readonly Random Random = new();
public static Guid GenerateUuidV7()
{
byte[] randomValues = new byte[16];
Random.NextBytes(randomValues);
UpdateV7State(
State,
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
randomValues);
var values = V7Bytes(randomValues, State.Msecs, State.Seq);
return new Guid(values);
}
private static void UpdateV7State(V7State state, long now, byte[] randomBytes)
{
if (now > state.Msecs)
{
state.Seq = (randomBytes[6] << 23) | (randomBytes[7] << 16) | (randomBytes[8] << 8) | randomBytes[9];
state.Msecs = now;
}
else
{
state.Seq = (state.Seq + 1) | 0;
if (state.Seq == 0)
{
state.Msecs++;
}
}
}
private static byte[] V7Bytes(byte[] randomBytes, long? msecs = null, int? seq = null, byte[]? buf = null, int offset = 0)
{
if (buf == null)
{
buf = new byte[16];
offset = 0;
}
// Defaults
msecs ??= DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
seq ??= ((randomBytes[6] & 0x7f) << 24) | (randomBytes[7] << 16) | (randomBytes[8] << 8) | randomBytes[9];
// byte 0-5: timestamp (48 bits)
buf[offset++] = (byte)((msecs.Value / 0x10000000000) & 0xff);
buf[offset++] = (byte)((msecs.Value / 0x100000000) & 0xff);
buf[offset++] = (byte)((msecs.Value / 0x1000000) & 0xff);
buf[offset++] = (byte)((msecs.Value / 0x10000) & 0xff);
buf[offset++] = (byte)((msecs.Value / 0x100) & 0xff);
buf[offset++] = (byte)(msecs.Value & 0xff);
// byte 6: `version` (4 bits) | sequence bits 28-31 (4 bits)
buf[offset++] = (byte)(0x70 | ((seq.Value >> 28) & 0x0f));
// byte 7: sequence bits 20-27 (8 bits)
buf[offset++] = (byte)((seq.Value >> 20) & 0xff);
// byte 8: `variant` (2 bits) | sequence bits 14-19 (6 bits)
buf[offset++] = (byte)(0x80 | ((seq.Value >> 14) & 0x3f));
// byte 9: sequence bits 6-13 (8 bits)
buf[offset++] = (byte)((seq.Value >> 6) & 0xff);
// byte 10: sequence bits 0-5 (6 bits) | random (2 bits)
buf[offset++] = (byte)(((seq.Value << 2) & 0xff) | (randomBytes[10] & 0x03));
// bytes 11-15: random (40 bits)
buf[offset++] = randomBytes[11];
buf[offset++] = randomBytes[12];
buf[offset++] = randomBytes[13];
buf[offset++] = randomBytes[14];
buf[offset] = randomBytes[15];
return buf;
}
}

View File

@@ -0,0 +1,63 @@
using System.Security.Claims;
namespace Hutopy.Web.Common.Security;
public static class ClaimsPrincipalExtensions
{
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)
{
var claim = claims.FindFirst(key);
if (claim is null) return default;
return claims.GetRequiredClaim<TValue>(key);
}
private static object GetRequiredClaim<TValue>(this ClaimsPrincipal claims, string key)
{
var claim = claims.FindFirst(key);
if (claim is null) throw new MissingClaimException(key);
if (typeof(TValue) == typeof(Guid))
{
return Guid.Parse(claim.Value);
}
return Convert.ChangeType(claim.Value, typeof(TValue));
}
}

View File

@@ -0,0 +1,53 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace Hutopy.Web.Common.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)
{
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>(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(KnownClaims.Alias, alias));
}
if (portraitUrl is not null)
{
claims.Add(new(KnownClaims.PortraitUrl, portraitUrl));
}
var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
expires: DateTime.Now.Add(expiresIn),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}

View File

@@ -0,0 +1,7 @@
namespace Hutopy.Web.Common.Security;
public static class KnownClaims
{
public const string Alias = "alias";
public const string PortraitUrl = "portraitUrl";
}

View File

@@ -0,0 +1,5 @@
namespace Hutopy.Web.Common.Security;
public class MissingClaimException(
string claimName)
: Exception;

View File

@@ -0,0 +1,70 @@
using System.Text;
namespace Hutopy.Web.Common.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 GeneratePassword(
int minLength,
int maxLength,
bool requireNumber = true,
bool requireCapital = true,
bool requireSpecialCharacter = true)
{
// Create pools based on the requirements
var characterPool = new StringBuilder(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
int length = Random.Next(minLength, maxLength + 1);
var password = new char[length];
// Ensure at least one character from each required category is included
int index = 0;
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 of the password
for (int i = index; i < length; i++)
{
password[i] = characterPool[Random.Next(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,60 @@
using System.Security.Claims;
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Users;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Facebook;
using Microsoft.AspNetCore.Mvc;
namespace Hutopy.Web.Controllers;
public class FacebookController(
IdentityService identityService)
: Controller
{
[Microsoft.AspNetCore.Mvc.HttpGet("/api/facebook/sign-in")]
public async Task SignIn()
{
await HttpContext.ChallengeAsync(FacebookDefaults.AuthenticationScheme,
new AuthenticationProperties { RedirectUri = Url.Action("Authorize") });
}
public async Task<IActionResult> Authorize()
{
var authenticateResult = await HttpContext.AuthenticateAsync(FacebookDefaults.AuthenticationScheme);
if (!authenticateResult.Succeeded) return BadRequest();
var claims = authenticateResult.Principal.Claims.ToList();
var name = claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value ?? "";
var email = claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value ?? "";
var givenName = claims.FirstOrDefault(c => c.Type == ClaimTypes.GivenName)?.Value ?? "";
var familyName = claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value ?? "";
var claimsIdentity = new ClaimsIdentity(
new List<Claim>
{
new(ClaimTypes.Name, name),
new(ClaimTypes.Email, email),
new(ClaimTypes.GivenName, givenName),
new(ClaimTypes.Surname, familyName)
},
CookieAuthenticationDefaults.AuthenticationScheme);
if (await identityService.FindUserByEmailAsync(email) != null)
{
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity));
return Redirect("/");
}
await identityService.CreateUserAsync(email, givenName, givenName, familyName,
PasswordGenerator.GeneratePassword(8, 10));
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity));
return Redirect("/");
}
}

View File

@@ -0,0 +1,110 @@
using System.Text;
using Azure.Identity;
using Hutopy.Web.Features.Users.Data;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Facebook;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
namespace Hutopy.Web;
public static class DependencyInjection
{
public static IServiceCollection AddWebServices(this IServiceCollection services)
{
services.AddDatabaseDeveloperPageExceptionFilter();
services.AddHttpContextAccessor();
services.AddHealthChecks()
.AddDbContextCheck<IdentityDbContext>();
services.AddRazorPages();
services.AddHttpClient();
// Customise default API behaviour
services.Configure<ApiBehaviorOptions>(options =>
options.SuppressModelStateInvalidFilter = true);
services.AddEndpointsApiExplorer();
return services;
}
public static IServiceCollection AddKeyVaultIfConfigured(this IServiceCollection services,
ConfigurationManager configuration)
{
var keyVaultUri = configuration["KeyVaultUri"];
if (!string.IsNullOrWhiteSpace(keyVaultUri))
{
configuration.AddAzureKeyVault(
new Uri(keyVaultUri),
new DefaultAzureCredential());
}
return services;
}
public static IServiceCollection AddAuthorizationAndAuthentication(this IServiceCollection services,
ConfigurationManager configuration)
{
var authenticationBuilder = services
.AddAuthentication(options =>
{
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie("Identity.Application", options =>
{
options.LoginPath = "/api/Users/login";
});
var authJwt = configuration.GetSection("Authentication:Jwt");
if (authJwt.Exists())
{
authenticationBuilder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtBearerOptions =>
{
jwtBearerOptions.Authority = "https://hutopy.com";
jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = authJwt["Issuer"],
ValidateAudience = true,
ValidAudience = authJwt["Audience"],
ValidateLifetime = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authJwt["Key"] ??
throw new ArgumentNullException("The Jwt Key is missing.")))
};
});
}
var authGoogle = configuration.GetSection("Authentication:Google");
if (authGoogle.Exists())
{
authenticationBuilder.AddGoogle(GoogleDefaults.AuthenticationScheme, options =>
{
options.ClientId = authGoogle["ClientId"] ??
throw new ArgumentNullException("The Google ClientId is missing.");
options.ClientSecret = authGoogle["ClientSecret"] ??
throw new ArgumentNullException("The Google ClientSecret is missing.");
});
}
var authFacebook = configuration.GetSection("Authentication:Facebook");
if (authFacebook.Exists())
{
authenticationBuilder.AddFacebook(FacebookDefaults.AuthenticationScheme, options =>
{
options.ClientId = authFacebook["ClientId"] ??
throw new ArgumentNullException("The Facebook ClientId is missing.");
options.ClientSecret = authFacebook["ClientSecret"] ??
throw new ArgumentNullException("The Facebook ClientSecret is missing.");
});
}
return services;
}
}

View File

@@ -0,0 +1,34 @@
namespace Hutopy.Web.Extensions;
public static class EnumExtensions
{
/// <summary>
/// Converts a string to the specified enum type.
/// </summary>
/// <typeparam name="TEnum">The type of the enum to convert to. Must be an enum.</typeparam>
/// <param name="value">The string value to convert.</param>
/// <param name="ignoreCase">Specifies whether the string comparison should ignore case. Default is true.</param>
/// <returns>
/// The corresponding enum value if the conversion is successful; otherwise, null if the string
/// cannot be converted to the specified enum type.
/// </returns>
public static TEnum? ToEnum<TEnum>(this string value, bool ignoreCase = true) where TEnum : struct
{
if (Enum.TryParse(value, ignoreCase, out TEnum result))
{
return result;
}
return null;
}
/// <summary>
/// Converts an enum value to its string representation.
/// </summary>
/// <typeparam name="TEnum">The type of the enum.</typeparam>
/// <param name="enumValue">The enum value to convert.</param>
/// <returns>The string representation of the enum value.</returns>
public static string FromEnum<TEnum>(this TEnum enumValue) where TEnum : struct, Enum
{
return enumValue.ToString();
}
}

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Web.Features.Contents.Data;
public class Content
{
public Guid Id { get; init; }
public Guid CreatedBy { get; init; }
public Creator? Creator { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public Guid? DeletedBy { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
[MaxLength(128)] public required string Title { get; set; }
[MaxLength(512)] public string? ThumbnailUrl { get; set; } = "";
[MaxLength(2048)] public string Description { get; set; } = "";
[MaxLength(2048)] public string? HtmlFileUrl { get; set; } = "";
public IList<ContentReaction> Reactions { get; set; } = new List<ContentReaction>();
public string[]? Urls { get; init; }
}

View File

@@ -0,0 +1,68 @@
namespace Hutopy.Web.Features.Contents.Data;
public class ContentDbContext(
DbContextOptions<ContentDbContext> options)
: DbContext(options)
{
public const string SchemaName = "Content";
public DbSet<Content> Contents => Set<Content>();
public DbSet<Creator> Creators => Set<Creator>();
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);
modelBuilder
.Entity<Content>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
modelBuilder
.Entity<Content>()
.HasOne(c => c.Creator)
.WithMany()
.HasForeignKey(c => c.CreatedBy);
modelBuilder
.Entity<Content>()
.OwnsMany(c => c.Reactions)
.ToTable("Reactions");
modelBuilder
.Entity<Content>()
.Property(c => c.ThumbnailUrl);
modelBuilder
.Entity<Creator>()
.Property(x => x.NormalizedName)
.HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", stored: true);
modelBuilder
.Entity<Creator>()
.HasIndex(x => x.NormalizedName)
.IsUnique();
modelBuilder
.Entity<Creator>()
.OwnsOne<Socials>(x => x.Socials)
.ToTable(nameof(Socials));
modelBuilder
.Entity<Creator>()
.OwnsOne<Colors>(x => x.Colors)
.ToTable(nameof(Colors));
modelBuilder
.Entity<Creator>()
.OwnsOne<Images>(x => x.Images)
.ToTable(nameof(Images));
modelBuilder
.Entity<Creator>()
.OwnsOne<PresentationInfos>(x => x.PresentationInfos)
.ToTable(nameof(PresentationInfos));
}
}

View File

@@ -0,0 +1,32 @@
namespace Hutopy.Web.Features.Contents.Data;
public static class InitializerExtensions
{
public static async Task InitialiseContentDbContextAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var initializer = scope.ServiceProvider.GetRequiredService<ContentDbContextInitializer>();
await initializer.InitialiseAsync();
}
}
public class ContentDbContextInitializer(
ILogger<ContentDbContextInitializer> logger,
ContentDbContext context
)
{
public async Task InitialiseAsync()
{
try
{
await context.Database.MigrateAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while initialising the content database.");
throw;
}
}
}

View File

@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
using Hutopy.Web.Features.Contents.Data.Enums;
namespace Hutopy.Web.Features.Contents.Data;
public class ContentReaction
{
public required Reaction Reaction { get; set; }
public required Guid UserId { get; set; }
[MaxLength(128)] public required string UserName { get; set; }
}

View File

@@ -0,0 +1,72 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Web.Features.Contents.Data;
public class Creator
{
public Guid Id { get; set; }
public Guid CreatedBy { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public bool AcceptDonation { get; set; }
public bool Verified { get; set; }
[MaxLength(255)] public string Name { get; set; } = null!;
[MaxLength(255)] public string NormalizedName { get; set; } = null!;
[MaxLength(255)] public string? Title { get; set; }
public Socials Socials { get; set; } = new();
public Colors Colors { get; set; } = new();
public Images Images { get; set; } = new();
public PresentationInfos PresentationInfos { get; set; } = new();
}
public class Colors
{
[MaxLength(9)] public string Primary { get; set; } = null!;
[MaxLength(9)] public string Secondary { get; set; } = null!;
[MaxLength(9)] public string Background { get; set; } = null!;
[MaxLength(9)] public string Surface { get; set; } = null!;
[MaxLength(9)] public string Error { get; set; } = null!;
[MaxLength(9)] public string OnPrimary { get; set; } = null!;
[MaxLength(9)] public string OnSecondary { get; set; } = null!;
[MaxLength(9)] public string OnBackground { get; set; } = null!;
[MaxLength(9)] public string OnSurface { get; set; } = null!;
[MaxLength(9)] public string OnError { get; set; } = null!;
}
public class Socials
{
[MaxLength(255)] public string? FacebookUrl { get; set; }
[MaxLength(255)] public string? InstagramUrl { get; set; }
[MaxLength(255)] public string? XUrl { get; set; }
[MaxLength(255)] public string? LinkedInUrl { get; set; }
[MaxLength(255)] public string? TikTokUrl { get; set; }
[MaxLength(255)] public string? YoutubeUrl { get; set; }
[MaxLength(255)] public string? RedditUrl { get; set; }
[MaxLength(255)] public string? WebsiteUrl { get; set; }
}
public class Images
{
[MaxLength(255)] public string? Banner { get; set; }
[MaxLength(255)] public string? Logo { get; set; }
}
public class PresentationInfos
{
[MaxLength(255)] public string PhoneNumber { get; set; } = string.Empty;
[MaxLength(255)] public string Email { get; set; } = string.Empty;
[MaxLength(2000)] public string Title { get; set; } = string.Empty;
[MaxLength(2000)] public string MainImageUrl { get; set; } = string.Empty;
[MaxLength(10000)] public string MainImageText { get; set; } = string.Empty;
[MaxLength(10000)] public string MainVideoText { get; set; } = string.Empty;
[MaxLength(2000)] public string ImagesSubtitle { get; set; } = string.Empty;
[MaxLength(2000)] public string Image1Url { get; set; } = string.Empty;
[MaxLength(2000)] public string Image2Url { get; set; } = string.Empty;
[MaxLength(2000)] public string Image3Url { get; set; } = string.Empty;
[MaxLength(2000)] public string Image4Url { get; set; } = string.Empty;
[MaxLength(10000)] public string ImagesText { get; set; } = string.Empty;
[MaxLength(2000)] public string VideoSubtitle { get; set; } = string.Empty;
[MaxLength(2000)] public string VideoSubtitleMain { get; set; } = string.Empty;
[MaxLength(2000)] public string VideoUrlMain { get; set; } = string.Empty;
[MaxLength(2000)] public string VideoUrl { get; set; } = string.Empty;
[MaxLength(10000)] public string VideoText { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,13 @@
namespace Hutopy.Web.Features.Contents.Data.Enums;
public enum Reaction
{
None = 0,
Like = 1,
Dislike = 2,
Love = 3,
Haha = 4,
Wow = 5,
Sad = 6,
Angry = 7
}

View File

@@ -0,0 +1,285 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Contents.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
[DbContext(typeof(ContentDbContext))]
[Migration("20241020202641_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Content")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Creators", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 =>
{
b1.Property<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("UserName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b1.HasKey("ContentId", "Id");
b1.ToTable("Reactions", "Content");
b1.WithOwner()
.HasForeignKey("ContentId");
});
b.Navigation("Creator");
b.Navigation("Reactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Surface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.HasKey("CreatorId");
b1.ToTable("Colors", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Logo")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Images", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("YoutubeUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Socials", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.Navigation("Colors")
.IsRequired();
b.Navigation("Images")
.IsRequired();
b.Navigation("Socials")
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,197 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "Content");
migrationBuilder.CreateTable(
name: "Creators",
schema: "Content",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
Name = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Creators", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Colors",
schema: "Content",
columns: table => new
{
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
Primary = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
Secondary = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
Background = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
Surface = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
Error = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
OnPrimary = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
OnSecondary = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
OnBackground = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
OnSurface = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false),
OnError = table.Column<string>(type: "character varying(9)", maxLength: 9, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Colors", x => x.CreatorId);
table.ForeignKey(
name: "FK_Colors_Creators_CreatorId",
column: x => x.CreatorId,
principalSchema: "Content",
principalTable: "Creators",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Contents",
schema: "Content",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
Title = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Description = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
HtmlFileUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
Urls = table.Column<string[]>(type: "text[]", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Contents", x => x.Id);
table.ForeignKey(
name: "FK_Contents_Creators_CreatedBy",
column: x => x.CreatedBy,
principalSchema: "Content",
principalTable: "Creators",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Images",
schema: "Content",
columns: table => new
{
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
Banner = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
Logo = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Images", x => x.CreatorId);
table.ForeignKey(
name: "FK_Images_Creators_CreatorId",
column: x => x.CreatorId,
principalSchema: "Content",
principalTable: "Creators",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Socials",
schema: "Content",
columns: table => new
{
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
FacebookUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
InstagramUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
XUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
LinkedInUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
TikTokUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
YoutubeUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
RedditUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
WebsiteUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Socials", x => x.CreatorId);
table.ForeignKey(
name: "FK_Socials_Creators_CreatorId",
column: x => x.CreatorId,
principalSchema: "Content",
principalTable: "Creators",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Reactions",
schema: "Content",
columns: table => new
{
ContentId = table.Column<Guid>(type: "uuid", nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Reaction = table.Column<int>(type: "integer", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
UserName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Reactions", x => new { x.ContentId, x.Id });
table.ForeignKey(
name: "FK_Reactions_Contents_ContentId",
column: x => x.ContentId,
principalSchema: "Content",
principalTable: "Contents",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Contents_CreatedBy",
schema: "Content",
table: "Contents",
column: "CreatedBy");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Colors",
schema: "Content");
migrationBuilder.DropTable(
name: "Images",
schema: "Content");
migrationBuilder.DropTable(
name: "Reactions",
schema: "Content");
migrationBuilder.DropTable(
name: "Socials",
schema: "Content");
migrationBuilder.DropTable(
name: "Contents",
schema: "Content");
migrationBuilder.DropTable(
name: "Creators",
schema: "Content");
}
}
}

View File

@@ -0,0 +1,289 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Contents.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
[DbContext(typeof(ContentDbContext))]
[Migration("20241201173048_AddThumbnailUrl")]
partial class AddThumbnailUrl
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Content")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Creators", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 =>
{
b1.Property<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("UserName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b1.HasKey("ContentId", "Id");
b1.ToTable("Reactions", "Content");
b1.WithOwner()
.HasForeignKey("ContentId");
});
b.Navigation("Creator");
b.Navigation("Reactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Surface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.HasKey("CreatorId");
b1.ToTable("Colors", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Logo")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Images", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("YoutubeUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Socials", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.Navigation("Colors")
.IsRequired();
b.Navigation("Images")
.IsRequired();
b.Navigation("Socials")
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class AddThumbnailUrl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ThumbnailUrl",
schema: "Content",
table: "Contents",
type: "character varying(512)",
maxLength: 512,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ThumbnailUrl",
schema: "Content",
table: "Contents");
}
}
}

View File

@@ -0,0 +1,390 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Contents.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
[DbContext(typeof(ContentDbContext))]
[Migration("20241201182352_AddPresentationInfos")]
partial class AddPresentationInfos
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Content")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Creators", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 =>
{
b1.Property<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("UserName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b1.HasKey("ContentId", "Id");
b1.ToTable("Reactions", "Content");
b1.WithOwner()
.HasForeignKey("ContentId");
});
b.Navigation("Creator");
b.Navigation("Reactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Surface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.HasKey("CreatorId");
b1.ToTable("Colors", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Logo")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Images", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.PresentationInfos", "PresentationInfos", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image1Url")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image2Url")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image3Url")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image4Url")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("ImagesSubtitle")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("ImagesText")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("MainImageText")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("MainImageUrl")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("MainVideoText")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("PhoneNumber")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("VideoSubtitle")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("VideoSubtitleMain")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("VideoText")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("VideoUrl")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("VideoUrlMain")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("PresentationInfos", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("YoutubeUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Socials", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.Navigation("Colors")
.IsRequired();
b.Navigation("Images")
.IsRequired();
b.Navigation("PresentationInfos")
.IsRequired();
b.Navigation("Socials")
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class AddPresentationInfos : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PresentationInfos",
schema: "Content",
columns: table => new
{
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
PhoneNumber = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Email = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
MainImageUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
MainImageText = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
MainVideoText = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
ImagesSubtitle = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Image1Url = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Image2Url = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Image3Url = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Image4Url = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
ImagesText = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
VideoSubtitle = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
VideoSubtitleMain = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
VideoUrlMain = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
VideoUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
VideoText = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PresentationInfos", x => x.CreatorId);
table.ForeignKey(
name: "FK_PresentationInfos_Creators_CreatorId",
column: x => x.CreatorId,
principalSchema: "Content",
principalTable: "Creators",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PresentationInfos",
schema: "Content");
}
}
}

View File

@@ -0,0 +1,390 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Contents.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
[DbContext(typeof(ContentDbContext))]
[Migration("20241202131957_LongerStringPresentationInfos")]
partial class LongerStringPresentationInfos
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Content")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Creators", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 =>
{
b1.Property<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("UserName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b1.HasKey("ContentId", "Id");
b1.ToTable("Reactions", "Content");
b1.WithOwner()
.HasForeignKey("ContentId");
});
b.Navigation("Creator");
b.Navigation("Reactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Surface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.HasKey("CreatorId");
b1.ToTable("Colors", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Logo")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Images", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.PresentationInfos", "PresentationInfos", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image1Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image2Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image3Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image4Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("MainVideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("PhoneNumber")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitleMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("VideoUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoUrlMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.HasKey("CreatorId");
b1.ToTable("PresentationInfos", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("YoutubeUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Socials", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.Navigation("Colors")
.IsRequired();
b.Navigation("Images")
.IsRequired();
b.Navigation("PresentationInfos")
.IsRequired();
b.Navigation("Socials")
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,348 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class LongerStringPresentationInfos : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "VideoUrlMain",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "VideoUrl",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "VideoText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(10000)",
maxLength: 10000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "VideoSubtitleMain",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "VideoSubtitle",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "Title",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "MainVideoText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(10000)",
maxLength: 10000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "MainImageUrl",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "MainImageText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(10000)",
maxLength: 10000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "ImagesText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(10000)",
maxLength: 10000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "ImagesSubtitle",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "Image4Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "Image3Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "Image2Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
migrationBuilder.AlterColumn<string>(
name: "Image1Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(2000)",
maxLength: 2000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(255)",
oldMaxLength: 255);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "VideoUrlMain",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "VideoUrl",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "VideoText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(10000)",
oldMaxLength: 10000);
migrationBuilder.AlterColumn<string>(
name: "VideoSubtitleMain",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "VideoSubtitle",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "Title",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "MainVideoText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(10000)",
oldMaxLength: 10000);
migrationBuilder.AlterColumn<string>(
name: "MainImageUrl",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "MainImageText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(10000)",
oldMaxLength: 10000);
migrationBuilder.AlterColumn<string>(
name: "ImagesText",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(10000)",
oldMaxLength: 10000);
migrationBuilder.AlterColumn<string>(
name: "ImagesSubtitle",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "Image4Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "Image3Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "Image2Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
migrationBuilder.AlterColumn<string>(
name: "Image1Url",
schema: "Content",
table: "PresentationInfos",
type: "character varying(255)",
maxLength: 255,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2000)",
oldMaxLength: 2000);
}
}
}

View File

@@ -0,0 +1,400 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Contents.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
[DbContext(typeof(ContentDbContext))]
[Migration("20250108022601_AddComputedColumnAndIndex_CreatorName")]
partial class AddComputedColumnAndIndex_CreatorName
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Content")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("NormalizedName")
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", true);
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique();
b.ToTable("Creators", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 =>
{
b1.Property<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("UserName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b1.HasKey("ContentId", "Id");
b1.ToTable("Reactions", "Content");
b1.WithOwner()
.HasForeignKey("ContentId");
});
b.Navigation("Creator");
b.Navigation("Reactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Surface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.HasKey("CreatorId");
b1.ToTable("Colors", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Logo")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Images", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.PresentationInfos", "PresentationInfos", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image1Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image2Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image3Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image4Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("MainVideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("PhoneNumber")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitleMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("VideoUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoUrlMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.HasKey("CreatorId");
b1.ToTable("PresentationInfos", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("YoutubeUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Socials", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.Navigation("Colors")
.IsRequired();
b.Navigation("Images")
.IsRequired();
b.Navigation("PresentationInfos")
.IsRequired();
b.Navigation("Socials")
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class AddComputedColumnAndIndex_CreatorName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "NormalizedName",
schema: "Content",
table: "Creators",
type: "character varying(255)",
maxLength: 255,
nullable: false,
computedColumnSql: "LOWER( \"Content\".\"Creators\".\"Name\")",
stored: true);
migrationBuilder.CreateIndex(
name: "IX_Creators_NormalizedName",
schema: "Content",
table: "Creators",
column: "NormalizedName",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Creators_NormalizedName",
schema: "Content",
table: "Creators");
migrationBuilder.DropColumn(
name: "NormalizedName",
schema: "Content",
table: "Creators");
}
}
}

View File

@@ -0,0 +1,403 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Contents.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
[DbContext(typeof(ContentDbContext))]
[Migration("20250108210552_Add_Verified_Creator")]
partial class Add_Verified_Creator
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Content")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("NormalizedName")
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", true);
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("Verified")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique();
b.ToTable("Creators", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 =>
{
b1.Property<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("UserName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b1.HasKey("ContentId", "Id");
b1.ToTable("Reactions", "Content");
b1.WithOwner()
.HasForeignKey("ContentId");
});
b.Navigation("Creator");
b.Navigation("Reactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Surface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.HasKey("CreatorId");
b1.ToTable("Colors", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Logo")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Images", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.PresentationInfos", "PresentationInfos", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image1Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image2Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image3Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image4Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("MainVideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("PhoneNumber")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitleMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("VideoUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoUrlMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.HasKey("CreatorId");
b1.ToTable("PresentationInfos", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("YoutubeUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Socials", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.Navigation("Colors")
.IsRequired();
b.Navigation("Images")
.IsRequired();
b.Navigation("PresentationInfos")
.IsRequired();
b.Navigation("Socials")
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class Add_Verified_Creator : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Verified",
schema: "Content",
table: "Creators",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Verified",
schema: "Content",
table: "Creators");
}
}
}

View File

@@ -0,0 +1,406 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Contents.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
[DbContext(typeof(ContentDbContext))]
[Migration("20250109015556_Adds_AcceptDonation_Creator")]
partial class Adds_AcceptDonation_Creator
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Content")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("AcceptDonation")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("NormalizedName")
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", true);
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("Verified")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique();
b.ToTable("Creators", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 =>
{
b1.Property<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("UserName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b1.HasKey("ContentId", "Id");
b1.ToTable("Reactions", "Content");
b1.WithOwner()
.HasForeignKey("ContentId");
});
b.Navigation("Creator");
b.Navigation("Reactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Surface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.HasKey("CreatorId");
b1.ToTable("Colors", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Logo")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Images", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.PresentationInfos", "PresentationInfos", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image1Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image2Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image3Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image4Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("MainVideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("PhoneNumber")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitleMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("VideoUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoUrlMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.HasKey("CreatorId");
b1.ToTable("PresentationInfos", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("YoutubeUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Socials", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.Navigation("Colors")
.IsRequired();
b.Navigation("Images")
.IsRequired();
b.Navigation("PresentationInfos")
.IsRequired();
b.Navigation("Socials")
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
/// <inheritdoc />
public partial class Adds_AcceptDonation_Creator : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AcceptDonation",
schema: "Content",
table: "Creators",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AcceptDonation",
schema: "Content",
table: "Creators");
}
}
}

View File

@@ -0,0 +1,403 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Contents.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Contents.Data.Migrations
{
[DbContext(typeof(ContentDbContext))]
partial class ContentDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Content")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("AcceptDonation")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("NormalizedName")
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", true);
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("Verified")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique();
b.ToTable("Creators", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 =>
{
b1.Property<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("UserName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b1.HasKey("ContentId", "Id");
b1.ToTable("Reactions", "Content");
b1.WithOwner()
.HasForeignKey("ContentId");
});
b.Navigation("Creator");
b.Navigation("Reactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Surface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.HasKey("CreatorId");
b1.ToTable("Colors", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Logo")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Images", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.PresentationInfos", "PresentationInfos", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Image1Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image2Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image3Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("Image4Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("ImagesText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("MainImageUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("MainVideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("PhoneNumber")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitle")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoSubtitleMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoText")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)");
b1.Property<string>("VideoUrl")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.Property<string>("VideoUrlMain")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b1.HasKey("CreatorId");
b1.ToTable("PresentationInfos", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
{
b1.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("YoutubeUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.HasKey("CreatorId");
b1.ToTable("Socials", "Content");
b1.WithOwner()
.HasForeignKey("CreatorId");
});
b.Navigation("Colors")
.IsRequired();
b.Navigation("Images")
.IsRequired();
b.Navigation("PresentationInfos")
.IsRequired();
b.Navigation("Socials")
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,16 @@
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents;
public static class DependencyInjection
{
public static WebApplicationBuilder AddContentModule(
this WebApplicationBuilder builder,
Action<DbContextOptionsBuilder>? configureAction = null)
{
builder.Services.AddDbContext<ContentDbContext>(configureAction);
builder.Services.AddScoped<ContentDbContextInitializer>();
return builder;
}
}

View File

@@ -0,0 +1,43 @@
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Memberships.Events;
namespace Hutopy.Web.Features.Contents.EventHandlers;
[UsedImplicitly]
public class StripeAccountConfiguredHandler(
ILogger<StripeAccountConfiguredHandler> logger,
IServiceScopeFactory scopeFactory)
: IEventHandler<StripeAccountConfigured>
{
public async Task HandleAsync(
StripeAccountConfigured eventModel,
CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
await using var dbContext = scope.ServiceProvider.GetRequiredService<ContentDbContext>();
var creator = await dbContext.FindAsync<Creator>(
[eventModel.CreatorId],
cancellationToken: ct);
if (creator is null)
{
logger.LogError(
"Creator with id {CreatorId} was not found.",
eventModel.CreatorId);
return;
}
creator.AcceptDonation = true;
var rows = await dbContext.SaveChangesAsync(ct);
if (rows is 0 or > 1)
{
logger.LogError(
"An error occured while updating Creator with id {CreatorId}: rows:{Rows}",
eventModel.CreatorId,
rows);
}
}
}

View File

@@ -0,0 +1,83 @@
using Hutopy.Web.Extensions;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Data.Enums;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public sealed class AddReactionRequest
{
public required Guid ContentId { get; set; }
public required string Reaction { get; set; }
public required Guid UserId { get; set; }
public required string UserName { get; set; }
}
[PublicAPI]
internal sealed class AddReactionRequestValidator
: Validator<AddReactionRequest>
{
public AddReactionRequestValidator()
{
RuleFor(r => r.Reaction)
.NotNull()
.Must(BeAValidReaction)
.WithMessage("'{PropertyValue}' is not a valid reaction.");
}
private bool BeAValidReaction(string reaction)
{
return Enum.TryParse(typeof(Reaction), reaction, true, out _);
}
}
[PublicAPI]
public class AddReaction(
ContentDbContext context)
: Endpoint<AddReactionRequest>
{
public override void Configure()
{
Post("/api/content/reaction");
Options(o => o.WithTags("Contents"));
}
public override async Task HandleAsync(
AddReactionRequest req,
CancellationToken ct)
{
var content = await context.Contents.SingleAsync(x => x.Id == req.ContentId, ct);
var reactionEnum = req.Reaction.ToEnum<Reaction>();
var currentReaction = content.Reactions.SingleOrDefault(x => x.UserId == req.UserId);
// Already reacted or reaction didn't change, do nothing
if (currentReaction != null && currentReaction.Reaction == reactionEnum)
{
return;
}
// User has already reacted, remove the existing reaction
if (currentReaction != null)
{
content.Reactions.Remove(currentReaction);
}
// If the new reaction is valid, add or update the reaction
if (reactionEnum.HasValue)
{
var reaction = new ContentReaction
{
Reaction = reactionEnum.Value,
UserId = req.UserId,
UserName = req.UserName
};
content.Reactions.Add(reaction);
}
await context.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,60 @@
using Hutopy.Web.Common.BlobStorage;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record ChangeBannerRequest(
Guid CreatorId,
IFormFile File);
[PublicAPI]
public record ChangeBannerResponse(
string BlobUrl);
[PublicAPI]
public class ChangeBannerHandler(
ContentDbContext context,
AzureBlobStorage blobStorage)
: Endpoint<ChangeBannerRequest, ChangeBannerResponse>
{
public override void Configure()
{
Post("/api/creators/{CreatorId}/banner");
Options(o => o.WithTags("Creators"));
AllowFileUploads();
}
public override async Task HandleAsync(
ChangeBannerRequest request,
CancellationToken ct)
{
var creator = await context
.Creators
.Include(c => c.Images)
.SingleOrDefaultAsync(
c => c.Id == request.CreatorId,
cancellationToken: ct);
if (creator is null)
{
await SendNotFoundAsync(ct);
return;
}
var blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Creators,
$"{request.CreatorId}/{SubDirectoryNames.Profile}/{CommonFileNames.BannerPicture}",
request.File.OpenReadStream(),
request.File.ContentType,
ct);
creator.Images.Banner = blobUrl;
await context.SaveChangesAsync(ct);
await SendOkAsync(
new ChangeBannerResponse(blobUrl),
ct);
}
}

View File

@@ -0,0 +1,113 @@
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record ChangeColorsRequest(
Guid CreatorId,
string Primary,
string Secondary,
string Background,
string Surface,
string Error,
string OnPrimary,
string OnSecondary,
string OnBackground,
string OnSurface,
string OnError);
[PublicAPI]
public sealed class ChangeColorsRequestValidator
: Validator<ChangeColorsRequest>
{
public ChangeColorsRequestValidator()
{
RuleFor(x => x.Primary)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.Secondary)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.Background)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.Surface)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.Error)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.OnPrimary)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.OnSecondary)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.OnBackground)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.OnSurface)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
RuleFor(x => x.OnError)
.MinimumLength(4).WithMessage("The minimum value should be in the format #444")
.MaximumLength(9).WithMessage("The maximum value should be in the format #11223344")
.Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #");
}
}
public class ChangeColorsHandler(
ContentDbContext context)
: Endpoint<ChangeColorsRequest>
{
public override void Configure()
{
Post("/api/creators/{CreatorId}/colors");
Options(o => o.WithTags("Creators"));
}
public override async Task HandleAsync(
ChangeColorsRequest request,
CancellationToken ct)
{
var creator = await context
.Creators
.Include(c => c.Colors)
.SingleAsync(
c => c.Id == request.CreatorId,
cancellationToken: ct);
creator.Colors.Primary = request.Primary;
creator.Colors.Secondary = request.Secondary;
creator.Colors.Background = request.Background;
creator.Colors.Surface = request.Surface;
creator.Colors.Error = request.Error;
creator.Colors.OnPrimary = request.OnPrimary;
creator.Colors.OnSecondary = request.OnSecondary;
creator.Colors.OnBackground = request.OnBackground;
creator.Colors.OnSurface = request.OnSurface;
creator.Colors.OnError = request.OnError;
await context.SaveChangesAsync(ct);
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,70 @@
using Hutopy.Web.Common.BlobStorage;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record ChangeLogoRequest(
Guid CreatorId,
IFormFile File);
[PublicAPI]
public sealed class ChangeLogoRequestValidator : Validator<ChangeLogoRequest>
{
public ChangeLogoRequestValidator()
{
RuleFor(x => x.CreatorId)
.NotNull()
.NotEmpty();
RuleFor(x => x.File)
.NotNull()
.NotEmpty();
}
}
[PublicAPI]
public class ChangeLogoHandler(
ContentDbContext context,
AzureBlobStorage blobStorage)
: Endpoint<ChangeLogoRequest>
{
public override void Configure()
{
Post("/api/creators/{CreatorId}/logo");
Options(o => o.WithTags("Creators"));
AllowFileUploads();
}
public override async Task HandleAsync(
ChangeLogoRequest request,
CancellationToken ct)
{
var creator = await context
.Creators
.Include(c => c.Images)
.SingleOrDefaultAsync(
c => c.Id == request.CreatorId,
cancellationToken: ct);
if (creator is null)
{
await SendNotFoundAsync(ct);
return;
}
// TODO: this upload should be done to the Creators container
var blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Creators,
$"{request.CreatorId}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
request.File.OpenReadStream(),
request.File.ContentType,
ct);
creator.Images.Logo = blobUrl;
await context.SaveChangesAsync(ct);
await SendOkAsync(blobUrl, ct);
}
}

View File

@@ -0,0 +1,115 @@
using Hutopy.Web.Common.BlobStorage;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record ChangePresentationInfosRequest(
Guid CreatorId,
string? PhoneNumber,
string? Email,
string? Title,
string? MainImageText,
string? MainVideoText,
string? ImagesSubtitle,
string? ImagesText,
string? VideoSubtitle,
string? VideoSubtitleMain,
string? VideoUrlMain,
string? VideoUrl,
string? VideoText,
string? MainImageUrl,
string? Image1Url,
string? Image2Url,
string? Image3Url,
string? Image4Url,
IFormFile? MainImage,
IFormFile? Image1,
IFormFile? Image2,
IFormFile? Image3,
IFormFile? Image4);
[PublicAPI]
public class ChangePresentationInfosHandler(
ContentDbContext context,
AzureBlobStorage blobStorage)
: Endpoint<ChangePresentationInfosRequest>
{
public override void Configure()
{
Post("/api/creators/{CreatorId}/presentation-infos");
Options(o => o.WithTags("Creators"));
AllowFileUploads();
}
public override async Task HandleAsync(
ChangePresentationInfosRequest request,
CancellationToken ct)
{
var creator = await context
.Creators
.Include(c => c.PresentationInfos)
.SingleOrDefaultAsync(
c => c.Id == request.CreatorId,
cancellationToken: ct);
if (creator is null)
{
await SendNotFoundAsync(ct);
return;
}
async Task<string> UploadFileOrDefaultAsync(
IFormFile? file,
string subDirectory,
string fileName,
string? newUrl)
{
if (newUrl == "")
return "";
if (file != null)
{
return await blobStorage.UploadFileAsync(
ContainerNames.Creators,
$"{request.CreatorId}/{subDirectory}/{fileName}",
file.OpenReadStream(),
file.ContentType,
ct);
}
return newUrl?.Trim() ?? "";
}
creator.PresentationInfos.MainImageUrl = await UploadFileOrDefaultAsync(
request.MainImage, "Profile", "MainImage", request.MainImageUrl);
creator.PresentationInfos.Image1Url = await UploadFileOrDefaultAsync(
request.Image1, "Profile", "Image1", request.Image1Url);
creator.PresentationInfos.Image2Url = await UploadFileOrDefaultAsync(
request.Image2, "Profile", "Image2", request.Image2Url);
creator.PresentationInfos.Image3Url = await UploadFileOrDefaultAsync(
request.Image3, "Profile", "Image3", request.Image3Url);
creator.PresentationInfos.Image4Url = await UploadFileOrDefaultAsync(
request.Image4, "Profile", "Image4", request.Image4Url);
creator.PresentationInfos.PhoneNumber = request.PhoneNumber?.Trim() ?? "";
creator.PresentationInfos.Email = request.Email?.Trim() ?? "";
creator.PresentationInfos.Title = request.Title?.Trim() ?? "";
creator.PresentationInfos.MainImageText = request.MainImageText?.Trim() ?? "";
creator.PresentationInfos.MainVideoText = request.MainVideoText?.Trim() ?? "";
creator.PresentationInfos.ImagesSubtitle = request.ImagesSubtitle?.Trim() ?? "";
creator.PresentationInfos.ImagesText = request.ImagesText?.Trim() ?? "";
creator.PresentationInfos.VideoSubtitle = request.VideoSubtitle?.Trim() ?? "";
creator.PresentationInfos.VideoSubtitleMain = request.VideoSubtitleMain?.Trim() ?? "";
creator.PresentationInfos.VideoUrlMain = request.VideoUrlMain?.Trim() ?? "";
creator.PresentationInfos.VideoUrl = request.VideoUrl?.Trim() ?? "";
creator.PresentationInfos.VideoText = request.VideoText?.Trim() ?? "";
await context.SaveChangesAsync(ct);
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,50 @@
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record ChangeSocialsRequest(
Guid CreatorId,
string? FacebookUrl,
string? InstagramUrl,
string? XUrl,
string? LinkedInUrl,
string? TikTokUrl,
string? YoutubeUrl,
string? RedditUrl,
string? WebsiteUrl);
[PublicAPI]
public class ChangeSocialsHandler(
ContentDbContext context)
: Endpoint<ChangeSocialsRequest>
{
public override void Configure()
{
Post("/api/creators/{CreatorId}/socials");
Options(o => o.WithTags("Creators"));
}
public override async Task HandleAsync(ChangeSocialsRequest request, CancellationToken ct)
{
var creator = await context
.Creators
.Include(c => c.Socials)
.SingleAsync(
c => c.Id == request.CreatorId,
cancellationToken: ct);
creator.Socials.FacebookUrl = request.FacebookUrl;
creator.Socials.InstagramUrl = request.InstagramUrl;
creator.Socials.XUrl = request.XUrl;
creator.Socials.LinkedInUrl = request.LinkedInUrl;
creator.Socials.TikTokUrl = request.TikTokUrl;
creator.Socials.YoutubeUrl = request.YoutubeUrl;
creator.Socials.RedditUrl = request.RedditUrl;
creator.Socials.WebsiteUrl = request.WebsiteUrl;
await context.SaveChangesAsync(ct);
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,37 @@
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record ChangeTitleRequest(
Guid CreatorId,
string? Title);
[PublicAPI]
public class ChangeTitleHandler(
ContentDbContext context)
: Endpoint<ChangeTitleRequest>
{
public override void Configure()
{
Post("/api/creators/{CreatorId}/title");
Options(o => o.WithTags("Creators"));
}
public override async Task HandleAsync(
ChangeTitleRequest request,
CancellationToken ct)
{
var creator = await context
.Creators
.SingleAsync(
c => c.Id == request.CreatorId,
cancellationToken: ct);
creator.Title = request.Title;
await context.SaveChangesAsync(ct);
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,156 @@
using System.Collections.Concurrent;
using Hutopy.Web.Common.BlobStorage;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record PostContentRequest(
Guid Id,
Guid CreatorId,
string Title,
string Description,
IFormFileCollection? Files,
IFormFile? Thumbnail,
string[]? ExternalUrls);
[PublicAPI]
public sealed class PostContentRequestValidator : Validator<PostContentRequest>
{
public PostContentRequestValidator()
{
RuleFor(r => r.Id)
.NotNull().WithMessage("You should specify the Id")
.NotEmpty().WithMessage("You should specify a valid/not empty Id");
RuleFor(r => r.CreatorId)
.NotNull().WithMessage("You should specify the CreatorId")
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
RuleFor(r => r.Title)
.NotNull().WithMessage("You should specify the Title")
.NotEmpty().WithMessage("You should specify a valid/not empty Title");
RuleFor(r => r.Description)
.NotNull().WithMessage("You should specify the Description")
.NotEmpty().WithMessage("You should specify a valid/not empty Description");
RuleForEach(r => r.ExternalUrls)
.Must(url => Uri.IsWellFormedUriString(url, UriKind.Absolute) &&
(url.StartsWith("http://") || url.StartsWith("https://")))
.WithMessage("External URL must be a valid HTTP/HTTPS URL");
RuleFor(r => r.Thumbnail)
.Must(file => file == null || file.ContentType.StartsWith("image/"))
.WithMessage("Thumbnail must be an image");
}
}
public sealed class PostContent(
AzureBlobStorage blobStorage,
ContentDbContext context)
: Endpoint<PostContentRequest>
{
public override void Configure()
{
Post("/api/contents");
Options(o => o.WithTags("Contents"));
AllowFileUploads();
}
public override async Task HandleAsync(PostContentRequest req, CancellationToken ct)
{
var urls = new ConcurrentBag<string>();
string? thumbnailUrl = null;
await using var transaction = await context.Database.BeginTransactionAsync(ct);
try
{
if (req.Files is not null)
{
await Parallel.ForEachAsync(req.Files, ct, async (file, ict) =>
{
try
{
var contentUrl = await SaveFileAsync(req.CreatorId, req.Id, file, ict);
urls.Add(contentUrl);
}
catch (Exception ex)
{
Logger.LogError("Failed to upload file {FileName}: {Message}", file.FileName, ex.Message);
}
});
}
if (req.ExternalUrls is not null)
{
foreach (var externalUrl in req.ExternalUrls.Where(url => !string.IsNullOrWhiteSpace(url)))
{
urls.Add(externalUrl);
}
}
if (req.Thumbnail is not null)
{
try
{
thumbnailUrl = await SaveFileAsync(req.CreatorId, req.Id, req.Thumbnail, ct, isThumbnail: true);
}
catch (Exception ex)
{
Logger.LogError("Error uploading thumbnail: {Message}", ex.Message);
}
}
await context.Contents.AddAsync(new Content
{
Id = req.Id,
CreatedBy = User.GetUserId(),
Title = req.Title,
Description = req.Description,
Urls = urls.IsEmpty ? null : urls.ToArray(),
ThumbnailUrl = thumbnailUrl,
}, ct);
await context.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
await SendOkAsync(new { Message = "Content published successfully!" }, ct);
}
catch (Exception ex)
{
await transaction.RollbackAsync(ct);
Logger.LogError("Transaction failed: {Message}", ex.Message);
throw;
}
}
private async Task<string> SaveFileAsync(
Guid creatorId,
Guid contentId,
IFormFile file,
CancellationToken ct = default,
bool isThumbnail = false)
{
var blobName = isThumbnail
? $"{creatorId}/{SubDirectoryNames.Contents}/{contentId}/thumbnail-{file.FileName}"
: $"{creatorId}/{SubDirectoryNames.Contents}/{contentId}/{file.FileName}";
return await blobStorage.UploadFileAsync(
ContainerNames.Creators,
blobName,
file.OpenReadStream(),
file.ContentType,
ct: ct);
}
}

View File

@@ -0,0 +1,110 @@
using System.Text;
using Hutopy.Web.Common.BlobStorage;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Handlers.Models;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record PostContentFromHtmlRequest(
Guid Id,
Guid CreatorId,
string Title,
string HtmlContent
);
[PublicAPI]
public sealed class PostContentFromHtmlRequestValidator : Validator<PostContentFromHtmlRequest>
{
public PostContentFromHtmlRequestValidator()
{
RuleFor(r => r.Id)
.NotNull().WithMessage("You should specify the Id")
.NotEmpty().WithMessage("You should specify a valid/not empty Id");
RuleFor(r => r.CreatorId)
.NotNull().WithMessage("You should specify the CreatorId")
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
RuleFor(r => r.Title)
.NotNull().WithMessage("You should specify the Title")
.NotEmpty().WithMessage("You should specify a valid/not empty Title");
}
}
public sealed class PostContentHtml(
AzureBlobStorage blobStorage,
ContentDbContext context)
: Endpoint<PostContentFromHtmlRequest>
{
public override void Configure()
{
Post("/api/contents/html");
Options(o => o.WithTags("Contents"));
AllowFileUploads();
}
public override async Task HandleAsync(
PostContentFromHtmlRequest req,
CancellationToken ct)
{
var htmlFileUrl = await SaveHtmlContentAsHtmlFileAsync(
req.CreatorId,
req.Id,
req.HtmlContent,
ct);
await context.Contents.AddAsync(
new Content { Id = req.Id, CreatedBy = User.GetUserId(), Title = req.Title, HtmlFileUrl = htmlFileUrl },
ct);
await context.SaveChangesAsync(ct);
var content = await context
.Contents
.Select(c => new ContentModel
{
Id = c.Id,
CreatedBy = c.CreatedBy,
CreatedByName = c.Creator!.Name,
CreatedByPortraitUrl = c.Creator.Images.Logo,
CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt,
Title = c.Title,
Description = c.Description,
Urls = c.Urls,
ThumbnailUrl = c.ThumbnailUrl,
HtmlFileUrl = htmlFileUrl
})
.SingleOrDefaultAsync(
c => c.Id == req.Id,
cancellationToken: ct);
await SendOkAsync(content, ct);
}
private async Task<string> SaveHtmlContentAsHtmlFileAsync(
Guid creatorId,
Guid contentId,
string htmlContent,
CancellationToken ct = default)
{
var fileName = $"{contentId}.html";
var filePath = $"{creatorId}/{SubDirectoryNames.Contents}/{fileName}";
// Convert the HTML string into a stream
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(htmlContent));
// Upload the stream as an HTML file
var url = await blobStorage.UploadFileAsync(
ContainerNames.Creators,
filePath,
stream,
"text/html",
ct: ct);
return url;
}
}

View File

@@ -0,0 +1,91 @@
using System.Net;
using FluentValidation.Results;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
using Npgsql;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record CreateCreatorRequest(
Guid CreatorId,
string Name);
[UsedImplicitly]
public sealed class CreateCreatorRequestValidator : Validator<CreateCreatorRequest>
{
public CreateCreatorRequestValidator()
{
RuleFor(r => r.CreatorId)
.NotNull().WithMessage("You should specify the CreatorId")
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
RuleFor(r => r.Name)
.NotNull().WithMessage("You should specify the Name")
.NotEmpty().WithMessage("You should specify a valid/not empty Name");
}
}
[PublicAPI]
public sealed class CreateCreatorHandler(
ContentDbContext context)
: Endpoint<CreateCreatorRequest>
{
public override void Configure()
{
Post("/api/creators");
Options(o => o.WithTags("Creators"));
}
public override async Task HandleAsync(
CreateCreatorRequest req,
CancellationToken ct)
{
try
{
await context.Creators.AddAsync(
new Creator
{
Id = req.CreatorId,
CreatedBy = User.GetUserId(),
Name = req.Name,
Colors =
{
Primary = "#6200EE",
OnPrimary = "#FFFFFF",
Secondary = "#03DAC6",
OnSecondary = "#000000",
Surface = "#FFFFFF",
OnSurface = "#000000",
Error = "#B00020",
OnError = "#FFFFFF",
Background = "#FFFFFF",
OnBackground = "#000000",
}
},
ct);
await context.SaveChangesAsync(ct);
await SendOkAsync(ct);
}
catch (Exception e)
{
if (e.InnerException is PostgresException innerException)
{
if (innerException.ConstraintName == "IX_Creators_NormalizedName")
{
await SendResultAsync(new ProblemDetails(
[new ValidationFailure(nameof(Creator.Name), "The name is already taken.")],
(int)HttpStatusCode.Conflict));
}
}
else
{
await SendResultAsync(new ProblemDetails(
[new ValidationFailure(nameof(Creator.Name), e.Message)],
(int)HttpStatusCode.Conflict));
}
}
}
}

View File

@@ -0,0 +1,60 @@
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record DeleteContentRequest(
Guid ContentId);
[PublicAPI]
public sealed class DeleteContentRequestValidator : Validator<DeleteContentRequest>
{
public DeleteContentRequestValidator()
{
RuleFor(r => r.ContentId)
.NotNull().WithMessage("You should specify the ContentId")
.NotEmpty().WithMessage("You should specify a valid/not empty ContentId");
}
}
public sealed class DeleteContent(
ContentDbContext context)
: Endpoint<DeleteContentRequest>
{
public override void Configure()
{
Delete("/api/contents/{ContentId}");
Options(o => o.WithTags("Contents"));
}
public override async Task HandleAsync(
DeleteContentRequest req,
CancellationToken ct)
{
var content = await context.Contents.FindAsync(
[req.ContentId],
ct);
if (content is null)
{
await SendNotFoundAsync(ct);
return;
}
var userId = HttpContext.User.GetUserId();
if (content.CreatedBy != userId)
{
await SendForbiddenAsync(ct);
return;
}
content.DeletedAt = DateTimeOffset.UtcNow;
content.DeletedBy = userId;
await context.SaveChangesAsync(ct);
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,59 @@
using Hutopy.Web.Extensions;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Handlers.Models;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public sealed class GetContentRequest
{
public Guid ContentId { get; set; }
}
[PublicAPI]
public class GetContent(
ContentDbContext context)
: Endpoint<GetContentRequest, ContentModel>
{
public override void Configure()
{
Get("/api/contents/{ContentId:guid}");
Options(o => o.WithTags("Contents"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetContentRequest req,
CancellationToken ct)
{
var content = await context
.Contents
.Select(c => new ContentModel
{
Id = c.Id,
CreatedBy = c.CreatedBy,
CreatedByName = c.Creator!.Name,
CreatedByPortraitUrl = c.Creator.Images.Logo,
CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt,
Title = c.Title,
Description = c.Description,
Urls = c.Urls,
ThumbnailUrl = c.ThumbnailUrl,
HtmlFileUrl = c.HtmlFileUrl ?? "",
Reactions = c.Reactions.Select(x => new ReactionModel
{
Reaction = x.Reaction.FromEnum(), UserId = x.UserId, UserName = x.UserName
}).ToList()
})
.SingleOrDefaultAsync(
c => c.Id == req.ContentId,
cancellationToken: ct);
if (content is null)
await SendNotFoundAsync(cancellation: ct);
else
await SendAsync(content, cancellation: ct);
}
}

View File

@@ -0,0 +1,68 @@
using Hutopy.Web.Extensions;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Handlers.Models;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public sealed class GetContentsByCreatorRequest
{
public Guid CreatorId { get; set; }
[BindFrom("page_size")] public int PageSize { get; set; } = 10;
[BindFrom("last_id")] public Guid? LastId { get; set; }
}
[PublicAPI]
public class GetContentsByCreatorHandler(
ContentDbContext context)
: Endpoint<GetContentsByCreatorRequest, List<ContentModel>>
{
public override void Configure()
{
Get("/api/contents/creator/{CreatorId:guid}");
Options(o => o.WithTags("Contents"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetContentsByCreatorRequest req,
CancellationToken ct)
{
var query = context.Contents
.Where(c => c.CreatedBy == req.CreatorId && c.DeletedAt == null)
.OrderByDescending(c => c.CreatedAt);
if (req.LastId.HasValue)
{
query = query.Where(c => c.Id > req.LastId.Value)
.OrderByDescending(c => c.CreatedAt);
}
var content = await query
.Select(c => new ContentModel
{
Id = c.Id,
CreatedBy = c.CreatedBy,
CreatedByName = c.Creator!.Name,
CreatedByPortraitUrl = c.Creator.Images.Logo,
CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt,
Title = c.Title,
Description = c.Description,
Urls = c.Urls,
ThumbnailUrl = c.ThumbnailUrl,
HtmlFileUrl = c.HtmlFileUrl ?? "",
Reactions = c.Reactions.Select(x => new ReactionModel
{
Reaction = x.Reaction.FromEnum(),
UserId = x.UserId,
UserName = x.UserName
}).ToList()
})
.Take(req.PageSize)
.ToListAsync(ct);
await SendAsync(content, cancellation: ct);
}
}

View File

@@ -0,0 +1,83 @@
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Handlers.Models;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public sealed class GetCreatorByAliasRequest
{
public required string Name { get; set; }
}
[PublicAPI]
public record struct GetCreatorByAliasResponse(
Guid Id,
Guid CreatedBy,
DateTimeOffset CreatedAt,
bool Verified,
bool AcceptDonation,
string Name,
string? Title,
Socials Socials,
Colors Colors,
PresentationInfos PresentationInfos,
Images Images);
[UsedImplicitly]
public sealed class GetCreatorByAliasRequestValidator
: Validator<GetCreatorByAliasRequest>
{
public GetCreatorByAliasRequestValidator()
{
RuleFor(r => r.Name)
.NotNull().WithMessage("You should specify the Name")
.NotEmpty().WithMessage("You should specify a valid/not empty Name");
}
}
[PublicAPI]
public class GetCreatorByAliasHandler(
ContentDbContext context)
: Endpoint<GetCreatorByAliasRequest, GetCreatorByAliasResponse>
{
public override void Configure()
{
Get("/api/creators/@{Name}");
Options((o => o.WithTags("Creators")));
AllowAnonymous();
}
public override async Task HandleAsync(
GetCreatorByAliasRequest req,
CancellationToken ct)
{
var creatorName = req.Name.ToLower();
var creator = await context
.Creators
.Where(c => EF.Functions.ILike(c.Name, creatorName))
.FirstOrDefaultAsync(ct);
if (creator is null)
{
await SendNotFoundAsync(ct);
}
else
{
var model = new GetCreatorByAliasResponse(
creator.Id,
creator.CreatedBy,
creator.CreatedAt,
creator.Verified,
creator.AcceptDonation,
creator.Name,
creator.Title,
creator.Socials,
creator.Colors,
creator.PresentationInfos,
creator.Images);
await SendAsync(model, cancellation: ct);
}
}
}

View File

@@ -0,0 +1,48 @@
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public sealed class GetCreatorByIdRequest
{
public required Guid CreatorId { get; set; }
}
[UsedImplicitly]
public sealed class GetCreatorByIdRequestValidator
: Validator<GetCreatorByIdRequest>
{
public GetCreatorByIdRequestValidator()
{
RuleFor(r => r.CreatorId)
.NotNull().WithMessage("You should specify the CreatorId")
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
}
}
[PublicAPI]
public class GetCreatorByIdHandler(
ContentDbContext context)
: Endpoint<GetCreatorByIdRequest, Creator>
{
public override void Configure()
{
Get("/api/creators/{CreatorId}");
Options((o => o.WithTags("Creators")));
AllowAnonymous();
}
public override async Task HandleAsync(
GetCreatorByIdRequest req,
CancellationToken ct)
{
var creator = await context
.Creators
.FindAsync(
[req.CreatorId],
cancellationToken: ct);
if (creator is null) await SendNotFoundAsync(ct);
else await SendAsync(creator, cancellation: ct);
}
}

View File

@@ -0,0 +1,31 @@
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public class GetCreatorProfileHandler(
ContentDbContext context)
: EndpointWithoutRequest<Creator>
{
public override void Configure()
{
Get("/api/creators/profile");
Options((o => o.WithTags("Creators")));
AllowAnonymous();
}
public override async Task HandleAsync(
CancellationToken ct)
{
var creator = await context
.Creators
.FindAsync(
[HttpContext.User.GetUserId()],
cancellationToken: ct);
if (creator is null) await SendNotFoundAsync(ct);
else await SendAsync(creator, cancellation: ct);
}
}

View File

@@ -0,0 +1,66 @@
using Hutopy.Web.Extensions;
using Hutopy.Web.Features.Contents.Data;
using Hutopy.Web.Features.Contents.Handlers.Models;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public sealed class GetFeaturedContentsRequest
{
[BindFrom("page_size")] public int PageSize { get; set; } = 10;
[BindFrom("last_id")] public Guid? LastId { get; set; }
}
[PublicAPI]
public class GetFeaturedContentsHandler(
ContentDbContext context)
: Endpoint<GetFeaturedContentsRequest, List<ContentModel>>
{
public override void Configure()
{
Get("/api/contents/featured");
Options(o => o.WithTags("Contents"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetFeaturedContentsRequest req,
CancellationToken ct)
{
var query = context.Contents
.Where(c => c.DeletedAt == null);
if (req.LastId.HasValue)
{
query = query.Where(c => c.Id > req.LastId.Value);
}
query = query.OrderByDescending(x => x.Reactions.Count);
var content = await query
.Select(c => new ContentModel
{
Id = c.Id,
CreatedBy = c.CreatedBy,
CreatedByName = c.Creator!.Name,
CreatedByPortraitUrl = c.Creator.Images.Logo,
CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt,
Title = c.Title,
Description = c.Description,
Urls = c.Urls,
ThumbnailUrl = c.ThumbnailUrl,
Reactions = c.Reactions.Select(x => new ReactionModel
{
Reaction = x.Reaction.FromEnum(),
UserId = x.UserId,
UserName = x.UserName
}).ToList()
})
.Take(req.PageSize)
.ToListAsync(ct);
await SendAsync(content, cancellation: ct);
}
}

View File

@@ -0,0 +1,86 @@
using Hutopy.Web.Common.BlobStorage;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record InsertImagesRequest(
Guid Id,
Guid CreatorId,
IFormFileCollection? Files
);
[PublicAPI]
public sealed class InsertImagesRequestValidator : Validator<InsertImagesRequest>
{
public InsertImagesRequestValidator()
{
RuleFor(r => r.Id)
.NotNull().WithMessage("You should specify the Id")
.NotEmpty().WithMessage("You should specify a valid/not empty Id");
RuleFor(r => r.CreatorId)
.NotNull().WithMessage("You should specify the CreatorId")
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
}
}
public sealed class InsertImages(
AzureBlobStorage blobStorage)
: Endpoint<InsertImagesRequest>
{
public override void Configure()
{
Post("/api/content/insert-image/");
Options(o => o.WithTags("Contents"));
AllowFileUploads();
}
public override async Task HandleAsync(
InsertImagesRequest req,
CancellationToken ct)
{
var urls = new List<string>();
if (req.Files is not null)
{
await Parallel.ForEachAsync(
req.Files,
ct,
async (
file,
ict) =>
{
try
{
var contentUrl = await SaveFileAsync(
req.CreatorId,
req.Id,
file,
ict);
urls.Add(contentUrl);
}
catch (Exception ex)
{
Logger.LogError("{ErrorMessage}", ex.Message);
}
});
}
await SendOkAsync(urls, ct);
}
private async Task<string> SaveFileAsync(
Guid creatorId,
Guid contentId,
IFormFile file,
CancellationToken ct = default)
{
var url = await blobStorage.UploadFileAsync(
ContainerNames.Creators,
$"{creatorId}/{SubDirectoryNames.Contents}/{contentId}/{file.FileName}",
file.OpenReadStream(),
file.ContentType,
ct: ct);
return url;
}
}

View File

@@ -0,0 +1,19 @@
namespace Hutopy.Web.Features.Contents.Handlers.Models;
[PublicAPI]
public class ContentModel
{
public required Guid Id { get; init; }
public required Guid CreatedBy { get; init; }
public required string CreatedByName { get; init; }
public required string? CreatedByPortraitUrl { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public Guid? DeletedBy { get; init; }
public DateTimeOffset? DeletedAt { get; init; }
public required string Title { get; init; }
public required string Description { get; init; }
public string HtmlFileUrl { get; init; } = "";
public required string[]? Urls { get; init; }
public string? ThumbnailUrl { get; init; }
public IList<ReactionModel>? Reactions { get; set; } = new List<ReactionModel>();
}

View File

@@ -0,0 +1,7 @@
namespace Hutopy.Web.Features.Contents.Handlers.Models;
[PublicAPI]
public record FollowModel(
Guid CreatorId,
string CreatorName,
string? CreatorPortraitUrl);

View File

@@ -0,0 +1,8 @@
namespace Hutopy.Web.Features.Contents.Handlers.Models;
public class ReactionModel
{
public required string Reaction { get; set; }
public required Guid UserId { get; set; }
public required string UserName { get; set; }
}

View File

@@ -0,0 +1,36 @@
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public sealed class RemoveReactionRequest
{
public required Guid ContentId { get; set; }
public required Guid UserId { get; set; }
}
[PublicAPI]
public class RemoveReaction(
ContentDbContext context)
: Endpoint<RemoveReactionRequest>
{
public override void Configure()
{
Post("/api/content/reaction/remove");
Options(o => o.WithTags("Contents"));
}
public override async Task HandleAsync(
RemoveReactionRequest req,
CancellationToken ct)
{
var content = await context.Contents
.SingleAsync(x => x.Id == req.ContentId, ct);
var reaction = content.Reactions.Single(x => x.UserId == req.UserId);
content.Reactions.Remove(reaction);
await context.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,9 @@
namespace Hutopy.Web.Features.Memberships.Data;
public class Creator
{
public Guid Id { get; set; }
public string Name { get; set; }
public string? StripeAccountId { get; set; }
public string PortraitUrl { get; set; }
}

View File

@@ -0,0 +1,58 @@
namespace Hutopy.Web.Features.Memberships.Data;
public sealed class MembershipDbContext(
DbContextOptions<MembershipDbContext> options)
: DbContext(options)
{
public const string SchemaName = "Membership";
public DbSet<Creator> Creators => Set<Creator>();
public DbSet<Subscription> Subscriptions => Set<Subscription>();
public DbSet<Tier> Tiers => Set<Tier>();
public DbSet<Tip> Tips => Set<Tip>();
public DbSet<Transaction> Transactions => Set<Transaction>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);
modelBuilder.Entity<Creator>();
modelBuilder
.Entity<Subscription>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
modelBuilder
.Entity<Subscription>()
.HasOne(c => c.Creator)
.WithMany()
.HasForeignKey(c => c.CreatorId);
modelBuilder
.Entity<Tier>()
.HasOne(c => c.Creator)
.WithMany()
.HasForeignKey(c => c.CreatorId);
modelBuilder
.Entity<Tier>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
modelBuilder
.Entity<Tip>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
modelBuilder
.Entity<Transaction>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
}
}

View File

@@ -0,0 +1,34 @@
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Memberships.Data;
public static class InitializerExtensions
{
public static async Task InitialiseMembershipDbContextAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var initializer = scope.ServiceProvider.GetRequiredService<MembershipDbContextInitializer>();
await initializer.InitialiseAsync();
}
}
public class MembershipDbContextInitializer(
ILogger<MembershipDbContextInitializer> logger,
MembershipDbContext context
)
{
public async Task InitialiseAsync()
{
try
{
await context.Database.MigrateAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while initialising the membership database.");
throw;
}
}
}

View File

@@ -0,0 +1,288 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Memberships.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Memberships.Data.Migrations
{
[DbContext(typeof(MembershipDbContext))]
[Migration("20241022191000_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Membership")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeAccountId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Creators", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("StripeSessionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeSubscriptionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("TierId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.HasIndex("TierId");
b.ToTable("Subscriptions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Price")
.HasColumnType("numeric");
b.Property<string>("StripePriceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.ToTable("Tiers", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CreatorName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeSessionId")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TipperId")
.HasColumnType("uuid");
b.Property<string>("TipperName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TransactionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TransactionId");
b.ToTable("Tips", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeInvoiceUrl")
.HasColumnType("text");
b.Property<Guid?>("SubscriptionId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("SubscriptionId");
b.ToTable("Transactions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier")
.WithMany("Subscriptions")
.HasForeignKey("TierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Tier");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Transaction", "Transaction")
.WithMany()
.HasForeignKey("TransactionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Transaction");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Subscription", null)
.WithMany("Transactions")
.HasForeignKey("SubscriptionId");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Navigation("Transactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,201 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Memberships.Data.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "Membership");
migrationBuilder.CreateTable(
name: "Creators",
schema: "Membership",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
StripeAccountId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Creators", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Tiers",
schema: "Membership",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
Price = table.Column<decimal>(type: "numeric", nullable: false),
CurrencyCode = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
StripeProductId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
StripePriceId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tiers", x => x.Id);
table.ForeignKey(
name: "FK_Tiers_Creators_CreatorId",
column: x => x.CreatorId,
principalSchema: "Membership",
principalTable: "Creators",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Subscriptions",
schema: "Membership",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
TierId = table.Column<Guid>(type: "uuid", nullable: false),
StartDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
EndDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
StripeSessionId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
StripeSubscriptionId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Subscriptions", x => x.Id);
table.ForeignKey(
name: "FK_Subscriptions_Creators_CreatorId",
column: x => x.CreatorId,
principalSchema: "Membership",
principalTable: "Creators",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Subscriptions_Tiers_TierId",
column: x => x.TierId,
principalSchema: "Membership",
principalTable: "Tiers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Transactions",
schema: "Membership",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Currency = table.Column<string>(type: "text", nullable: false),
Type = table.Column<string>(type: "text", nullable: false),
Timestamp = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
StripeInvoiceUrl = table.Column<string>(type: "text", nullable: true),
SubscriptionId = table.Column<Guid>(type: "uuid", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Transactions", x => x.Id);
table.ForeignKey(
name: "FK_Transactions_Subscriptions_SubscriptionId",
column: x => x.SubscriptionId,
principalSchema: "Membership",
principalTable: "Subscriptions",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Tips",
schema: "Membership",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
StripeSessionId = table.Column<string>(type: "text", nullable: false),
TipperId = table.Column<Guid>(type: "uuid", nullable: false),
TipperName = table.Column<string>(type: "text", nullable: false),
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
CreatorName = table.Column<string>(type: "text", nullable: false),
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Currency = table.Column<string>(type: "text", nullable: false),
Message = table.Column<string>(type: "text", nullable: false),
TransactionId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tips", x => x.Id);
table.ForeignKey(
name: "FK_Tips_Transactions_TransactionId",
column: x => x.TransactionId,
principalSchema: "Membership",
principalTable: "Transactions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Subscriptions_CreatorId",
schema: "Membership",
table: "Subscriptions",
column: "CreatorId");
migrationBuilder.CreateIndex(
name: "IX_Subscriptions_TierId",
schema: "Membership",
table: "Subscriptions",
column: "TierId");
migrationBuilder.CreateIndex(
name: "IX_Tiers_CreatorId",
schema: "Membership",
table: "Tiers",
column: "CreatorId");
migrationBuilder.CreateIndex(
name: "IX_Tips_TransactionId",
schema: "Membership",
table: "Tips",
column: "TransactionId");
migrationBuilder.CreateIndex(
name: "IX_Transactions_SubscriptionId",
schema: "Membership",
table: "Transactions",
column: "SubscriptionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Tips",
schema: "Membership");
migrationBuilder.DropTable(
name: "Transactions",
schema: "Membership");
migrationBuilder.DropTable(
name: "Subscriptions",
schema: "Membership");
migrationBuilder.DropTable(
name: "Tiers",
schema: "Membership");
migrationBuilder.DropTable(
name: "Creators",
schema: "Membership");
}
}
}

View File

@@ -0,0 +1,292 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Memberships.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Memberships.Data.Migrations
{
[DbContext(typeof(MembershipDbContext))]
[Migration("20241022203207_PortraitUrlToCreator")]
partial class PortraitUrlToCreator
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Membership")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PortraitUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeAccountId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Creators", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("StripeSessionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeSubscriptionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("TierId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.HasIndex("TierId");
b.ToTable("Subscriptions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Price")
.HasColumnType("numeric");
b.Property<string>("StripePriceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.ToTable("Tiers", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CreatorName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeSessionId")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TipperId")
.HasColumnType("uuid");
b.Property<string>("TipperName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TransactionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TransactionId");
b.ToTable("Tips", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeInvoiceUrl")
.HasColumnType("text");
b.Property<Guid?>("SubscriptionId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("SubscriptionId");
b.ToTable("Transactions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier")
.WithMany("Subscriptions")
.HasForeignKey("TierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Tier");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Transaction", "Transaction")
.WithMany()
.HasForeignKey("TransactionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Transaction");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Subscription", null)
.WithMany("Transactions")
.HasForeignKey("SubscriptionId");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Navigation("Transactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Memberships.Data.Migrations
{
/// <inheritdoc />
public partial class PortraitUrlToCreator : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PortraitUrl",
schema: "Membership",
table: "Creators",
type: "text",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PortraitUrl",
schema: "Membership",
table: "Creators");
}
}
}

View File

@@ -0,0 +1,292 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Memberships.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Memberships.Data.Migrations
{
[DbContext(typeof(MembershipDbContext))]
[Migration("20241216215210_UpdateSeedData")]
partial class UpdateSeedData
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Membership")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PortraitUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeAccountId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Creators", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("StripeSessionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeSubscriptionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("TierId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.HasIndex("TierId");
b.ToTable("Subscriptions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Price")
.HasColumnType("numeric");
b.Property<string>("StripePriceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.ToTable("Tiers", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CreatorName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeSessionId")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TipperId")
.HasColumnType("uuid");
b.Property<string>("TipperName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TransactionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TransactionId");
b.ToTable("Tips", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeInvoiceUrl")
.HasColumnType("text");
b.Property<Guid?>("SubscriptionId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("SubscriptionId");
b.ToTable("Transactions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier")
.WithMany("Subscriptions")
.HasForeignKey("TierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Tier");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Transaction", "Transaction")
.WithMany()
.HasForeignKey("TransactionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Transaction");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Subscription", null)
.WithMany("Transactions")
.HasForeignKey("SubscriptionId");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Navigation("Transactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Memberships.Data.Migrations
{
/// <inheritdoc />
public partial class UpdateSeedData : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -0,0 +1,289 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Memberships.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Memberships.Data.Migrations
{
[DbContext(typeof(MembershipDbContext))]
partial class MembershipDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Membership")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PortraitUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeAccountId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Creators", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("StripeSessionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeSubscriptionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("TierId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.HasIndex("TierId");
b.ToTable("Subscriptions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Price")
.HasColumnType("numeric");
b.Property<string>("StripePriceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.ToTable("Tiers", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CreatorName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeSessionId")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TipperId")
.HasColumnType("uuid");
b.Property<string>("TipperName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TransactionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TransactionId");
b.ToTable("Tips", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeInvoiceUrl")
.HasColumnType("text");
b.Property<Guid?>("SubscriptionId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("SubscriptionId");
b.ToTable("Transactions", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier")
.WithMany("Subscriptions")
.HasForeignKey("TierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Tier");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Transaction", "Transaction")
.WithMany()
.HasForeignKey("TransactionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Transaction");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{
b.HasOne("Hutopy.Web.Features.Memberships.Data.Subscription", null)
.WithMany("Transactions")
.HasForeignKey("SubscriptionId");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{
b.Navigation("Transactions");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Web.Features.Memberships.Data;
public class Subscription
{
public Guid Id { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public Guid UserId { get; set; }
public Guid CreatorId { get; set; }
public Creator Creator { get; set; }
public Guid TierId { get; set; }
public Tier Tier { get; set; }
public DateTimeOffset StartDate { get; set; }
public DateTimeOffset? EndDate { get; set; }
public bool IsActive => EndDate == null || EndDate > DateTimeOffset.UtcNow;
[MaxLength(255)]public string? StripeSessionId { get; set; }
[MaxLength(255)]public string? StripeSubscriptionId { get; set; }
public ICollection<Transaction> Transactions { get; set; } = [];
}

View File

@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Web.Features.Memberships.Data;
public class Tier
{
public Guid Id { get; set; }
public DateTime CreatedAt { get; set; }
public Guid CreatorId { get; set; }
public Creator Creator { get; set; } = null!;
[MaxLength(128)] public string Name { get; set; } = null!;
[MaxLength(4096)] public string Description { get; set; } = null!;
public decimal Price { get; set; }
[MaxLength(128)] public string CurrencyCode { get; set; } = null!;
[MaxLength(128)] public string StripeProductId { get; set; } = null!;
[MaxLength(128)] public string StripePriceId { get; set; } = null!;
public ICollection<Subscription> Subscriptions { get; set; } = [];
}

View File

@@ -0,0 +1,18 @@
namespace Hutopy.Web.Features.Memberships.Data;
public class Tip
{
public Guid Id { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string StripeSessionId { get; set; }
public Guid TipperId { get; set; }
public string TipperName { get; set; }
public Guid CreatorId { get; set; }
public string CreatorName { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
public string Message { get; set; }
public Guid TransactionId { get; set; }
public Transaction Transaction { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace Hutopy.Web.Features.Memberships.Data;
public class Transaction
{
public Guid Id { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
public string Type { get; set; } // Subscription, Tip
public DateTime Timestamp { get; set; }
public string? StripeInvoiceUrl { get; set; }
}

View File

@@ -0,0 +1,27 @@
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships;
public static class DependencyInjection
{
public static WebApplicationBuilder AddMembershipModule(
this WebApplicationBuilder builder,
Action<DbContextOptionsBuilder>? configureAction = null)
{
builder.Services.AddSingleton<PushNotificationService>();
builder.Services.AddDbContext<MembershipDbContext>(configureAction);
builder.Services.AddScoped<MembershipDbContextInitializer>();
builder.Services.AddScoped<StripeService>();
builder.Services
.AddOptions<StripeOptions>()
.Bind(builder.Configuration.GetSection("Stripe"))
.ValidateDataAnnotations()
.ValidateOnStart();
return builder;
}
}

View File

@@ -0,0 +1,5 @@
namespace Hutopy.Web.Features.Memberships.Events;
public record StripeAccountConfigured(
Guid CreatorId,
string StripeAccountId);

View File

@@ -0,0 +1,7 @@
namespace Hutopy.Web.Features.Memberships.Events;
public record struct SubscriptionPaid(
Guid CreatorId,
string CreatorName,
string Tier,
DateTimeOffset Since);

View File

@@ -0,0 +1,8 @@
namespace Hutopy.Web.Features.Memberships.Events;
public record struct TipPaid(
Guid CreatorId,
string CreatorName,
decimal Amount,
string Currency,
string Message);

View File

@@ -0,0 +1,48 @@
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public class CancelSubscriptionRequest
{
public Guid SubscriptionId { get; set; }
}
public class CancelSubscriptionHandler(
MembershipDbContext dbContext,
StripeService stripeService)
: Endpoint<CancelSubscriptionRequest>
{
public override void Configure()
{
Post("/api/membership/unsubscribe");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
CancelSubscriptionRequest req,
CancellationToken ct)
{
var subscription = await dbContext
.Subscriptions
.FindAsync(
[req.SubscriptionId],
cancellationToken: ct);
if (subscription is not { EndDate: null })
{
await SendNotFoundAsync(ct);
return;
}
// Cancel Stripe subscription
await stripeService.CancelSubscription(subscription.Id);
// Update subscription in the system
subscription.EndDate = DateTime.UtcNow;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(subscription.Id, ct);
}
}

View File

@@ -0,0 +1,55 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Events;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public record struct ChangeStripeIdRequest(
string StripeAccountId);
public class ChangeStripeIdHandler(
MembershipDbContext dbContext)
: Endpoint<ChangeStripeIdRequest>
{
public override void Configure()
{
Post("/api/membership/stripe-account");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
ChangeStripeIdRequest req,
CancellationToken ct)
{
var creatorId = HttpContext.User.GetUserId();
var creator = await dbContext
.Creators
.FindAsync(
[creatorId],
cancellationToken: ct);
if (creator is null)
{
creator = new Creator
{
Id = creatorId,
Name = HttpContext.User.GetAlias() ?? creatorId.ToString(),
PortraitUrl = HttpContext.User.GetPortraitUrl() ?? string.Empty
};
await dbContext.AddAsync(creator, ct);
}
creator.StripeAccountId = req.StripeAccountId;
await dbContext.SaveChangesAsync(ct);
await PublishAsync(
new StripeAccountConfigured(creator.Id, creator.StripeAccountId),
cancellation: ct);
await SendOkAsync(creator.Id, ct);
}
}

View File

@@ -0,0 +1,56 @@
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public record struct CreateMembershipTierRequest(
Guid CreatorId,
string Name,
string Description,
decimal Price,
string Currency = "CAD");
[PublicAPI]
public class CreateMembershipTierEndpoint(
MembershipDbContext dbContext,
StripeService stripe)
: Endpoint<CreateMembershipTierRequest>
{
public override void Configure()
{
Post("/api/membership/tiers");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
CreateMembershipTierRequest req,
CancellationToken ct)
{
var tierId = Guid.NewGuid();
var productId = await stripe.CreateProductAsync(
req.CreatorId,
tierId,
req.Name,
req.Currency,
req.Price);
// Record the new Tier
var tier = new Tier
{
Id = tierId,
CreatorId = req.CreatorId,
Price = req.Price,
Name = req.Name,
Description = req.Description,
StripeProductId = productId,
};
dbContext.Tiers.Add(tier);
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(tier, ct);
}
}

View File

@@ -0,0 +1,44 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public record struct GetActiveSubscriptionsResponse(
Guid Id,
Guid CreatorId,
string CreatorName,
string CreatorPortraitUrl,
DateTimeOffset StartDate,
DateTimeOffset? EndDate);
[PublicAPI]
public class GetActiveSubscriptionsHandler(
MembershipDbContext dbContext)
: EndpointWithoutRequest<List<GetActiveSubscriptionsResponse>>
{
public override void Configure()
{
Get("/api/membership/active");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
CancellationToken ct)
{
var subscriptions = await dbContext
.Subscriptions
.Where(subscription => subscription.UserId == User.GetUserId())
.Where(subscription => subscription.EndDate == null || subscription.EndDate > DateTimeOffset.UtcNow)
.Select(subscription => new GetActiveSubscriptionsResponse(
subscription.Id,
subscription.Creator.Id,
subscription.Creator.Name,
subscription.Creator.PortraitUrl,
subscription.StartDate,
subscription.EndDate))
.ToListAsync(ct);
await SendOkAsync(subscriptions, ct);
}
}

View File

@@ -0,0 +1,52 @@
using Hutopy.Web.Features.Memberships.Data;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public record GetMembershipTierRequest
{
public Guid CreatorId { get; set; }
}
[PublicAPI]
public record struct TierModel(
Guid Id,
DateTime CreatedAt,
string Name,
string Description,
decimal Price,
string CurrencyCode,
string StripeProductId);
[PublicAPI]
public class GetMembershipTierEndpoint(
MembershipDbContext dbContext)
: Endpoint<GetMembershipTierRequest, List<TierModel>>
{
public override void Configure()
{
Get("/api/membership/tiers/{CreatorId:guid}");
Options(o => o.WithTags("Memberships"));
AllowAnonymous();
}
public override async Task HandleAsync(
GetMembershipTierRequest req,
CancellationToken ct)
{
var tiers = await dbContext
.Tiers
.Where(tier => tier.CreatorId == req.CreatorId)
.Select(tier => new TierModel(
tier.Id,
tier.CreatedAt,
tier.Name,
tier.Description,
tier.Price,
tier.CurrencyCode,
tier.StripeProductId))
.ToListAsync(ct);
await SendOkAsync(tiers, ct);
}
}

View File

@@ -0,0 +1,46 @@
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public record struct TipReceivedModel(
Guid Id,
DateTimeOffset CreatedAt,
Guid TipperId,
string TipperName,
decimal Amount,
string Currency,
string Message);
[PublicAPI]
public class GetReceivedTipsHandler(
MembershipDbContext dbContext)
: EndpointWithoutRequest<List<TipReceivedModel>>
{
public override void Configure()
{
Get("/api/tips/received");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
CancellationToken ct)
{
var tipsReceived = await dbContext
.Tips
.Where(tip => tip.CreatorId == User.GetUserId())
.Select(tip => new TipReceivedModel(
tip.Id,
tip.CreatedAt,
tip.TipperId,
tip.TipperName,
tip.Amount,
tip.Currency,
tip.Message))
.ToListAsync(ct);
await SendOkAsync(tipsReceived, ct);
}
}

View File

@@ -0,0 +1,46 @@
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public record struct TipSentModel(
Guid Id,
DateTimeOffset CreatedAt,
Guid CreatorId,
string CreatorName,
decimal Amount,
string Currency,
string Message);
[PublicAPI]
public class GetSentTipsHandler(
MembershipDbContext dbContext)
: EndpointWithoutRequest<List<TipSentModel>>
{
public override void Configure()
{
Get("/api/tips/sent");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
CancellationToken ct)
{
var tips = await dbContext
.Tips
.Where(t => t.TipperId == User.GetUserId())
.Select(tip => new TipSentModel(
tip.Id,
tip.CreatedAt,
tip.CreatorId,
tip.CreatorName,
tip.Amount,
tip.Currency,
tip.Message))
.ToListAsync(ct);
await SendOkAsync(tips, ct);
}
}

View File

@@ -0,0 +1,51 @@
using Hutopy.Web.Features.Memberships.Infrastructure;
using Microsoft.Extensions.Options;
using Stripe;
namespace Hutopy.Web.Features.Memberships.Handlers;
public class StripeWebhookEndpoint(
StripeService stripeService,
IOptions<StripeOptions> options)
: EndpointWithoutRequest
{
public override void Configure()
{
Post("/api/stripe");
AllowAnonymous();
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(CancellationToken ct)
{
using var streamReader = new StreamReader(HttpContext.Request.Body);
var json = await streamReader.ReadToEndAsync(ct);
var signatureHeader = HttpContext.Request.Headers["Stripe-Signature"];
var stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.WebhookSecret);
switch (stripeEvent.Type)
{
case "checkout.session.completed":
await stripeService.HandleCheckoutSessionCompleted(stripeEvent, ct);
break;
case "invoice.payment_succeeded":
await stripeService.HandleInvoicePaymentSucceeded(stripeEvent, ct);
break;
case "invoice.payment_failed":
await stripeService.HandleInvoicePaymentFailed(stripeEvent, ct);
break;
case "customer.subscription.created":
await stripeService.HandleCustomerSubscriptionCreated(stripeEvent, ct);
break;
case "customer.subscription.updated":
await stripeService.HandleCustomerSubscriptionUpdated(stripeEvent, ct);
break;
case "customer.subscription.deleted":
await stripeService.HandleCustomerSubscriptionDeleted(stripeEvent, ct);
break;
}
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,87 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public record SendTipRequest(
Guid CreatorId,
decimal Amount,
string Currency,
string Message,
string CheckoutSuccessUrl,
string CheckoutCancelledUrl);
[PublicAPI]
public record SendTipResponse(
string Status,
string StripeCheckoutUrl);
[PublicAPI]
public class SendTipRequestValidator : Validator<SendTipRequest>
{
public SendTipRequestValidator()
{
RuleFor(x => x.Amount)
.GreaterThan(0)
.WithMessage("Tip amount must be greater than 0");
RuleFor(x => x.CreatorId)
.NotEmpty()
.WithMessage("Creator ID is required");
RuleFor(x => x.CheckoutSuccessUrl)
.NotEmpty()
.WithMessage("CheckoutSuccessUrl is required");
RuleFor(x => x.CheckoutCancelledUrl)
.NotEmpty()
.WithMessage("CheckoutCancelledUrl is required");
}
}
[PublicAPI]
public class SendTipHandler(
MembershipDbContext dbContext,
StripeService stripeService)
: Endpoint<SendTipRequest, SendTipResponse>
{
public override void Configure()
{
Post("/api/tips");
Options(o => o.WithTags("Memberships"));
AllowAnonymous();
}
public override async Task HandleAsync(
SendTipRequest req,
CancellationToken ct)
{
var creator = await dbContext.Creators.FindAsync(
[req.CreatorId],
cancellationToken: ct);
if (creator == null)
{
await SendNotFoundAsync(ct);
return;
}
var checkoutSession = await stripeService.CreateTipCheckoutSessionAsync(
creator.Id,
creator.Name,
req.Amount,
req.Currency,
req.Message,
creator.StripeAccountId,
req.CheckoutSuccessUrl,
req.CheckoutCancelledUrl
);
await SendAsync(
new SendTipResponse("Pending", checkoutSession.Url),
cancellation: ct);
}
}

View File

@@ -0,0 +1,72 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public class SubscribeRequest
{
public Guid CreatorId { get; set; }
public Guid TierId { get; set; }
public required string CheckoutSuccessUrl { get; init; }
public required string CheckoutCancelledUrl { get; init; }
}
[PublicAPI]
public record struct SubscriptionResponse(
string StripeCheckoutUrl);
[PublicAPI]
public class SubscribeValidator : Validator<SubscribeRequest>
{
public SubscribeValidator()
{
RuleFor(x => x.TierId).NotEmpty();
}
}
[PublicAPI]
public class SubscribeHandler(
MembershipDbContext dbContext,
StripeService stripeService)
: Endpoint<SubscribeRequest, SubscriptionResponse>
{
public override void Configure()
{
Post("/api/membership/subscribe");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
SubscribeRequest req,
CancellationToken ct)
{
var tier = await dbContext
.Tiers
.Include(tier => tier.Creator) // Include the related table
.Where(tier => tier.Id == req.TierId)
.FirstOrDefaultAsync(ct);
if (tier == null)
{
await SendNotFoundAsync(ct);
return;
}
// Process Stripe subscription
var checkoutSession = await stripeService.CreateSubscriptionCheckoutSession(
User.GetUserId(),
tier.Creator.Id,
tier.Creator.Name,
tier.Creator.StripeAccountId,
tier.Id,
tier.StripePriceId,
req.CheckoutSuccessUrl,
req.CheckoutCancelledUrl);
await SendOkAsync(
new SubscriptionResponse { StripeCheckoutUrl = checkoutSession.Url },
cancellation: ct);
}
}

View File

@@ -0,0 +1,13 @@
namespace Hutopy.Web.Features.Memberships.Infrastructure;
public sealed class PushNotificationService(
ILogger<PushNotificationService> logger)
{
public void NotifyCreator<TEvent>(
Guid tipCreatorId,
TEvent notification)
where TEvent : struct
{
logger.LogInformation("Notifying creator {CreatorId}, {Notification}", tipCreatorId, notification);
}
}

View File

@@ -0,0 +1,428 @@
using System.ComponentModel.DataAnnotations;
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Events;
using Microsoft.Extensions.Options;
using Stripe;
using Stripe.Checkout;
using Subscription = Stripe.Subscription;
namespace Hutopy.Web.Features.Memberships.Infrastructure;
public class StripeOptions
{
[Required] public required string SecretKey { get; init; }
[Required] public required string WebhookSecret { get; init; }
[Required] [Range(0, 1)] public required decimal HutopyRate { get; init; }
}
public sealed class StripeService(
IOptions<StripeOptions> paymentOptions,
MembershipDbContext dbContext,
PushNotificationService notificationService)
{
public async Task<string> CreateProductAsync(
Guid creatorId,
Guid tierId,
string productName,
string currencyCode,
decimal amount)
{
StripeConfiguration.ApiKey = paymentOptions.Value.SecretKey;
// Create the product
var productService = new ProductService();
var product = await productService.CreateAsync(
new ProductCreateOptions
{
Name = productName,
Metadata = { { "creatorId", creatorId.ToString() }, { "tierId", tierId.ToString() } }
});
// Create the price for the product
var priceService = new PriceService();
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;
}
public async Task<Session> CreateTipCheckoutSessionAsync(
Guid creatorId,
string creatorName,
decimal amount,
string currencyCode,
string message,
string creatorAccountId,
string successUrl,
string cancelUrl,
CancellationToken ct = default)
{
StripeConfiguration.ApiKey = paymentOptions.Value.SecretKey;
// Create Stripe customer for the user if not already created
var customerService = new CustomerService();
var customer = await customerService.CreateAsync(
new CustomerCreateOptions{},
cancellationToken: ct);
// Create paymentIntent for the user
var sessionService = new SessionService();
return await sessionService.CreateAsync(
new SessionCreateOptions
{
Customer = customer.Id,
PaymentMethodTypes = ["card"],
LineItems =
[
new SessionLineItemOptions
{
PriceData = new SessionLineItemPriceDataOptions
{
Currency = currencyCode,
UnitAmountDecimal = amount, // Amount in cents
ProductData = new SessionLineItemPriceDataProductDataOptions
{
Name = $"Tip for {creatorName}", // or any descriptive name for the tip
Metadata = new Dictionary<string, string> { { "creatorId", creatorId.ToString() } }
}
},
Quantity = 1
}
],
Mode = "payment",
PaymentIntentData = new SessionPaymentIntentDataOptions
{
ApplicationFeeAmount =
Convert.ToInt64(amount * 100 * paymentOptions.Value.HutopyRate), // Platform fee
TransferData = new SessionPaymentIntentDataTransferDataOptions
{
Destination = creatorAccountId // Creator's Stripe account ID
}
},
SuccessUrl = successUrl, // Redirect after successful payment
CancelUrl = cancelUrl, // Redirect after canceled payment
Metadata = new Dictionary<string, string>
{
{ "creatorId", creatorId.ToString() },
{ "creatorName", creatorName },
{ "message", message },
}
},
cancellationToken: ct);
}
public async Task<Session> CreateSubscriptionCheckoutSession(
Guid userId,
Guid creatorId,
string creatorName,
string creatorAccountId,
Guid tierId,
string priceId,
string successUrl,
string cancelUrl)
{
StripeConfiguration.ApiKey = paymentOptions.Value.SecretKey;
// Create Stripe customer for the user if not already created
var customerService = new CustomerService();
var customer = await customerService.CreateAsync(
new CustomerCreateOptions
{
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } }
});
// Create Checkout Session for the subscription
var sessionService = new SessionService();
return await sessionService.CreateAsync(
new SessionCreateOptions
{
Customer = customer.Id,
PaymentMethodTypes = ["card"],
LineItems =
[
new SessionLineItemOptions { Price = priceId, Quantity = 1 }
],
Mode = "subscription",
SubscriptionData = new SessionSubscriptionDataOptions
{
ApplicationFeePercent = paymentOptions.Value.HutopyRate,
TransferData = new SessionSubscriptionDataTransferDataOptions { Destination = creatorAccountId }
},
SuccessUrl = successUrl, // Redirect after successful payment
CancelUrl = cancelUrl, // Redirect after canceled payment
Metadata = new Dictionary<string, string>
{
{ "userId", userId.ToString() },
{ "creatorId", creatorId.ToString() },
{ "creatorName", creatorName },
{ "tierId", tierId.ToString() }
}
});
}
public async Task CancelSubscription(
Guid subscriptionId)
{
var subscriptionService = new SubscriptionService();
await subscriptionService.CancelAsync(subscriptionId.ToString());
}
public async Task HandleInvoicePaymentSucceeded(
Event stripeEvent,
CancellationToken ct = default)
{
// Ensure we have an invoice related to a Subscription
if (stripeEvent.Data.Object is not Invoice { Subscription: not null } invoice)
{
return;
}
var subscription = await dbContext
.Subscriptions
.FirstOrDefaultAsync(
subscription => subscription.StripeSubscriptionId == invoice.Subscription.Id,
cancellationToken: ct);
if (subscription == null)
{
return;
}
// Record the Transaction
var transaction = new Transaction
{
Id = Guid.NewGuid(),
CreatedAt = DateTimeOffset.UtcNow,
Amount = invoice.AmountPaid / 100m, // Convert amount from cents to dollars
Currency = invoice.Currency,
Type = "Subscription",
Timestamp = DateTime.UtcNow,
StripeInvoiceUrl = invoice.HostedInvoiceUrl
};
dbContext.Transactions.Add(transaction);
// Link the Transaction to the Subscription
subscription.Transactions.Add(transaction);
await dbContext.SaveChangesAsync(ct);
}
public async Task HandleInvoicePaymentFailed(
Event stripeEvent,
CancellationToken ct = default)
{
if (stripeEvent.Data.Object is not Invoice { Subscription: not null } invoice)
{
return;
}
var subscription = await dbContext
.Subscriptions
.SingleOrDefaultAsync(
subscription => subscription.StripeSubscriptionId == invoice.SubscriptionId,
cancellationToken: ct);
if (subscription != null)
{
subscription.EndDate = DateTimeOffset.UtcNow; // Mark as expired or failed
await dbContext.SaveChangesAsync(ct);
}
}
private async Task HandleTipPayment(
Session session,
CancellationToken ct)
{
// Record the Tip
var tip = new Tip
{
Id = Guid.NewGuid(),
CreatedAt = DateTimeOffset.UtcNow,
StripeSessionId = session.Id,
TipperId = Guid.Parse(session.Metadata["tipperId"]),
TipperName = session.Metadata["tipperName"],
CreatorId = Guid.Parse(session.Metadata["creatorId"]),
CreatorName = session.Metadata["creatorName"],
Amount = session.AmountTotal!.Value / 100m,
Currency = session.Currency,
Message = session.Metadata["message"]
};
dbContext.Tips.Add(tip);
// Record the Transaction
var transaction = new Transaction
{
Id = Guid.NewGuid(),
CreatedAt = DateTimeOffset.UtcNow,
Amount = tip.Amount,
Currency = tip.Currency,
Type = "Tip",
Timestamp = DateTime.UtcNow,
// TODO: __StripeInvoiceUrl = session.Invoice.HostedInvoiceUrl__ How come nor Invoice or InvoiceId are set.
};
dbContext.Transactions.Add(transaction);
// Link the Transaction to the Tip
tip.TransactionId = transaction.Id;
// Save the changes
await dbContext.SaveChangesAsync(ct);
// Notify the Creator
notificationService.NotifyCreator(
tip.CreatorId,
new TipPaid(
tip.CreatorId,
tip.CreatorName,
tip.Amount,
tip.Currency,
tip.Message)
);
}
private async Task HandleSubscriptionPayment(
Session session,
CancellationToken ct)
{
// Record the Subscription
var subscription = new Data.Subscription
{
Id = Guid.NewGuid(),
CreatedAt = DateTimeOffset.UtcNow,
UserId = Guid.Parse(session.Metadata["userId"]),
CreatorId = Guid.Parse(session.Metadata["creatorId"]),
TierId = Guid.Parse(session.Metadata["tierId"]),
StartDate = DateTimeOffset.UtcNow,
StripeSessionId = session.Id,
StripeSubscriptionId = session.SubscriptionId
};
dbContext.Subscriptions.Add(subscription);
// Record the Transaction
var transaction = new Transaction
{
Id = Guid.NewGuid(),
CreatedAt = DateTimeOffset.UtcNow,
Amount = session.AmountTotal!.Value / 100m, // Convert amount from cents to dollars
Currency = session.Currency,
Type = "Subscription",
Timestamp = DateTime.UtcNow,
// TODO: __StripeInvoiceUrl = session.Invoice.HostedInvoiceUrl__ How come nor Invoice or InvoiceId are set.
};
dbContext.Transactions.Add(transaction);
// Link the Transaction to the Subscription
subscription.Transactions.Add(transaction);
// Save the changes
await dbContext.SaveChangesAsync(ct);
// Notify the Creator
notificationService.NotifyCreator(
subscription.CreatorId,
new SubscriptionPaid(
subscription.CreatorId,
session.Metadata["creatorName"],
subscription.TierId.ToString(),
subscription.StartDate)
);
}
public async Task HandleCheckoutSessionCompleted(
Event stripeEvent,
CancellationToken ct = default)
{
if (stripeEvent.Data.Object is not Session session)
{
return;
}
switch (session.Mode)
{
// Check if this is a one-time tip
case "payment" when session.PaymentIntentId != null:
await HandleTipPayment(session, ct);
break;
// Check if this is a subscription
case "subscription" when session.SubscriptionId != null:
await HandleSubscriptionPayment(session, ct);
break;
}
}
public async Task HandleCustomerSubscriptionCreated(
Event stripeEvent,
CancellationToken ct)
{
if (stripeEvent.Data.Object is not Subscription stripeSubscription)
return;
var subscription = await dbContext
.Subscriptions
.SingleOrDefaultAsync(
subscription => subscription.StripeSubscriptionId == stripeSubscription.Id,
cancellationToken: ct);
if (subscription != null)
{
subscription.StartDate = stripeSubscription.CurrentPeriodStart;
subscription.EndDate = null; // Active subscription
await dbContext.SaveChangesAsync(ct);
}
}
public async Task HandleCustomerSubscriptionUpdated(
Event stripeEvent,
CancellationToken ct)
{
if (stripeEvent.Data.Object is Subscription stripeSubscription)
{
var subscription = await dbContext
.Subscriptions
.SingleOrDefaultAsync(
s => s.StripeSubscriptionId == stripeSubscription.Id,
cancellationToken: ct);
if (subscription != null)
{
subscription.StartDate = stripeSubscription.CurrentPeriodStart;
subscription.EndDate = null; // Active subscription
await dbContext.SaveChangesAsync(ct);
}
}
}
public async Task HandleCustomerSubscriptionDeleted(
Event stripeEvent,
CancellationToken ct)
{
var subscription = stripeEvent.Data.Object as Subscription;
var existingSubscription = await dbContext
.Subscriptions
.FirstOrDefaultAsync(x => x.StripeSubscriptionId == subscription!.Id, ct);
if (existingSubscription != null)
{
var today = DateTime.Today;
int lastDay = DateTime.DaysInMonth(today.Year, today.Month);
var lastDayOfMonth = new DateTime(today.Year, today.Month, lastDay);
existingSubscription.EndDate = new DateTimeOffset(lastDayOfMonth);
await dbContext.SaveChangesAsync(ct);
}
}
}

View File

@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Web.Features.Messages.Data;
public class Message
{
public Guid Id { get; set; }
public Guid SubjectId { get; set; }
public Guid CreatedBy { get; set; }
[MaxLength(255)] public required string CreatedByName { get; set; }
[MaxLength(255)] public string? CreatedByPortraitUrl { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public Guid? ParentId { get; set; }
[MaxLength(2048)] public required string Value { get; set; }
}

View File

@@ -0,0 +1,76 @@
using Hutopy.Web.Features.Messages.Handlers.Models;
namespace Hutopy.Web.Features.Messages.Data;
public class MessagingDbContext(
DbContextOptions<MessagingDbContext> options)
: DbContext(options)
{
public const string SchemaName = "Messaging";
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);
modelBuilder
.Entity<Message>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
}
public DbSet<Message> Messages { get; set; }
public async Task<List<MessageDto>> GetMessagesAsync(
Guid subjectId,
Guid? parentId,
Guid? lastId,
int pageSize,
CancellationToken ct = default)
{
var query = Messages
.Where(c => c.SubjectId == subjectId)
.Where(c => c.ParentId == parentId);
if (lastId.HasValue)
{
var lastMessage = await Messages
.Where(c => c.Id == lastId.Value)
.Select(c => new { c.CreatedAt, c.Id })
.FirstOrDefaultAsync(cancellationToken: ct);
if (lastMessage != null)
{
query = query
.Where(c => c.CreatedAt < lastMessage.CreatedAt
|| (c.CreatedAt == lastMessage.CreatedAt && c.Id < lastMessage.Id));
}
}
var messages = await query
.OrderByDescending(c => c.CreatedAt)
.ThenByDescending(c => c.Id)
.Take(pageSize)
.Select(message => message.ToDto())
.ToListAsync(cancellationToken: ct);
return messages;
}
public async Task<int> GetMessageCountAsync(
Guid subjectId,
Guid? parentId,
int pageSize,
CancellationToken ct = default)
{
var query = Messages
.Where(c => c.SubjectId == subjectId)
.Where(c => c.ParentId == parentId);
var messageCount = await query
.Take(pageSize)
.CountAsync(ct);
return messageCount;
}
}

View File

@@ -0,0 +1,32 @@
namespace Hutopy.Web.Features.Messages.Data;
public static class InitializerExtensions
{
public static async Task InitialiseMessagingDbContextAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var initializer = scope.ServiceProvider.GetRequiredService<MessagingDbContextInitializer>();
await initializer.InitialiseAsync();
}
}
public class MessagingDbContextInitializer(
ILogger<MessagingDbContextInitializer> logger,
MessagingDbContext context
)
{
public async Task InitialiseAsync()
{
try
{
await context.Database.MigrateAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while initialising the messaging database.");
throw;
}
}
}

View File

@@ -0,0 +1,69 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Messages.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Messages.Migrations
{
[DbContext(typeof(MessagingDbContext))]
[Migration("20240805012343_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Messaging")
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Messages.Data.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("CreatedByName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("CreatedByPortraitUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<Guid>("SubjectId")
.HasColumnType("uuid");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "Messaging");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,45 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Messages.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "Messaging");
migrationBuilder.CreateTable(
name: "Messages",
schema: "Messaging",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SubjectId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedByName = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
CreatedByPortraitUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
ParentId = table.Column<Guid>(type: "uuid", nullable: true),
Value = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Messages", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Messages",
schema: "Messaging");
}
}
}

View File

@@ -0,0 +1,70 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Messages.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Messages.Migrations
{
[DbContext(typeof(MessagingDbContext))]
[Migration("20241217225954_ChangeStripeId")]
partial class ChangeStripeId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Messaging")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Messages.Data.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("CreatedByName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("CreatedByPortraitUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<Guid>("SubjectId")
.HasColumnType("uuid");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Messages", "Messaging");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Messages.Migrations
{
/// <inheritdoc />
public partial class ChangeStripeId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Value",
schema: "Messaging",
table: "Messages",
type: "character varying(2048)",
maxLength: 2048,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Value",
schema: "Messaging",
table: "Messages",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2048)",
oldMaxLength: 2048);
}
}
}

View File

@@ -0,0 +1,67 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Messages.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Messages.Migrations
{
[DbContext(typeof(MessagingDbContext))]
partial class MessagingDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Messaging")
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Messages.Data.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("CreatedByName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("CreatedByPortraitUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<Guid>("SubjectId")
.HasColumnType("uuid");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Messages", "Messaging");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,16 @@
using Hutopy.Web.Features.Messages.Data;
namespace Hutopy.Web.Features.Messages;
public static class DependencyInjection
{
public static WebApplicationBuilder AddMessagingModule(
this WebApplicationBuilder builder,
Action<DbContextOptionsBuilder>? configureAction = null)
{
builder.Services.AddDbContext<MessagingDbContext>(configureAction);
builder.Services.AddScoped<MessagingDbContextInitializer>();
return builder;
}
}

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