+ Tips
+ Memberships - DDD - FutureCreator - UserTransactions
This commit is contained in:
@@ -46,7 +46,7 @@
|
|||||||
<PackageVersion Include="NUnit.Analyzers" Version="3.9.0" />
|
<PackageVersion Include="NUnit.Analyzers" Version="3.9.0" />
|
||||||
<PackageVersion Include="NUnit3TestAdapter" Version="4.5.0" />
|
<PackageVersion Include="NUnit3TestAdapter" Version="4.5.0" />
|
||||||
<PackageVersion Include="Respawn" Version="6.1.0" />
|
<PackageVersion Include="Respawn" Version="6.1.0" />
|
||||||
<PackageVersion Include="Stripe.net" Version="44.5.0" />
|
<PackageVersion Include="Stripe.net" Version="46.1.0" />
|
||||||
<PackageVersion Include="Testcontainers.MsSql" Version="3.6.0" />
|
<PackageVersion Include="Testcontainers.MsSql" Version="3.6.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -13,8 +13,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{6ED356A7-8B4
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{664D406C-2F83-48F0-BFC3-408D5CB53C65}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{664D406C-2F83-48F0-BFC3-408D5CB53C65}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application.UnitTests", "tests\Application.UnitTests\Application.UnitTests.csproj", "{DEFF4009-1FAB-4392-80B6-707E2DC5C00B}"
|
|
||||||
EndProject
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain.UnitTests", "tests\Domain.UnitTests\Domain.UnitTests.csproj", "{DC37FD87-552C-4613-9F16-1537CA522898}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain.UnitTests", "tests\Domain.UnitTests\Domain.UnitTests.csproj", "{DC37FD87-552C-4613-9F16-1537CA522898}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E2DA20AA-28D1-455C-BF50-C49A8F831633}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E2DA20AA-28D1-455C-BF50-C49A8F831633}"
|
||||||
@@ -54,10 +52,6 @@ Global
|
|||||||
{117DA02F-5274-4565-ACC6-DA9B6E568B09}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{117DA02F-5274-4565-ACC6-DA9B6E568B09}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{117DA02F-5274-4565-ACC6-DA9B6E568B09}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{117DA02F-5274-4565-ACC6-DA9B6E568B09}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{117DA02F-5274-4565-ACC6-DA9B6E568B09}.Release|Any CPU.Build.0 = Release|Any CPU
|
{117DA02F-5274-4565-ACC6-DA9B6E568B09}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{DEFF4009-1FAB-4392-80B6-707E2DC5C00B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{DEFF4009-1FAB-4392-80B6-707E2DC5C00B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{DEFF4009-1FAB-4392-80B6-707E2DC5C00B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{DEFF4009-1FAB-4392-80B6-707E2DC5C00B}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{DC37FD87-552C-4613-9F16-1537CA522898}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{DC37FD87-552C-4613-9F16-1537CA522898}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{DC37FD87-552C-4613-9F16-1537CA522898}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{DC37FD87-552C-4613-9F16-1537CA522898}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{DC37FD87-552C-4613-9F16-1537CA522898}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{DC37FD87-552C-4613-9F16-1537CA522898}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
@@ -82,7 +76,6 @@ Global
|
|||||||
{C7E89A3E-A631-4760-8D61-BD1EAB1C4E69} = {6ED356A7-8B47-4613-AD01-C85CF28491BD}
|
{C7E89A3E-A631-4760-8D61-BD1EAB1C4E69} = {6ED356A7-8B47-4613-AD01-C85CF28491BD}
|
||||||
{34C0FACD-F3D9-400C-8945-554DD6B0819A} = {6ED356A7-8B47-4613-AD01-C85CF28491BD}
|
{34C0FACD-F3D9-400C-8945-554DD6B0819A} = {6ED356A7-8B47-4613-AD01-C85CF28491BD}
|
||||||
{117DA02F-5274-4565-ACC6-DA9B6E568B09} = {6ED356A7-8B47-4613-AD01-C85CF28491BD}
|
{117DA02F-5274-4565-ACC6-DA9B6E568B09} = {6ED356A7-8B47-4613-AD01-C85CF28491BD}
|
||||||
{DEFF4009-1FAB-4392-80B6-707E2DC5C00B} = {664D406C-2F83-48F0-BFC3-408D5CB53C65}
|
|
||||||
{DC37FD87-552C-4613-9F16-1537CA522898} = {664D406C-2F83-48F0-BFC3-408D5CB53C65}
|
{DC37FD87-552C-4613-9F16-1537CA522898} = {664D406C-2F83-48F0-BFC3-408D5CB53C65}
|
||||||
{4E4EE20C-F06A-4A1B-851F-C5577796941C} = {6ED356A7-8B47-4613-AD01-C85CF28491BD}
|
{4E4EE20C-F06A-4A1B-851F-C5577796941C} = {6ED356A7-8B47-4613-AD01-C85CF28491BD}
|
||||||
{EA6127A5-94C9-4C31-AD11-E6811B92B520} = {664D406C-2F83-48F0-BFC3-408D5CB53C65}
|
{EA6127A5-94C9-4C31-AD11-E6811B92B520} = {664D406C-2F83-48F0-BFC3-408D5CB53C65}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"sdk": {
|
"sdk": {
|
||||||
"version": "8.0.203",
|
"version": "8.0.0",
|
||||||
"rollForward": "latestFeature",
|
"rollForward": "latestMajor",
|
||||||
"allowPrerelease": true
|
"allowPrerelease": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
using System.Reflection;
|
|
||||||
using Hutopy.Application.Common.Exceptions;
|
|
||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
using Hutopy.Application.Common.Security;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Common.Behaviours;
|
|
||||||
|
|
||||||
public class AuthorizationBehaviour<TRequest, TResponse>(
|
|
||||||
IUser user,
|
|
||||||
IIdentityService identityService)
|
|
||||||
: IPipelineBehavior<TRequest, TResponse>
|
|
||||||
where TRequest : notnull
|
|
||||||
{
|
|
||||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var authorizeAttributes = request
|
|
||||||
.GetType()
|
|
||||||
.GetCustomAttributes<AuthorizeAttribute>()
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
if (authorizeAttributes.Length == 0)
|
|
||||||
{
|
|
||||||
return await next();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.Id is null)
|
|
||||||
{
|
|
||||||
throw new UnauthorizedAccessException();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role-based authorization
|
|
||||||
var authorizeAttributesWithRoles = authorizeAttributes
|
|
||||||
.Where(a => !string.IsNullOrWhiteSpace(a.Roles))
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
if (authorizeAttributesWithRoles.Length != 0)
|
|
||||||
{
|
|
||||||
var authorized = false;
|
|
||||||
|
|
||||||
foreach (var roles in authorizeAttributesWithRoles.Select(a => a.Roles.Split(',')))
|
|
||||||
{
|
|
||||||
foreach (var role in roles)
|
|
||||||
{
|
|
||||||
var isInRole = await identityService.IsInRoleAsync(user.Id.Value, role.Trim());
|
|
||||||
if (isInRole)
|
|
||||||
{
|
|
||||||
authorized = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must be a member of at least one role in roles
|
|
||||||
if (!authorized)
|
|
||||||
{
|
|
||||||
throw new ForbiddenAccessException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Policy-based authorization
|
|
||||||
var authorizeAttributesWithPolicies = authorizeAttributes
|
|
||||||
.Where(a => !string.IsNullOrWhiteSpace(a.Policy))
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
if (authorizeAttributesWithPolicies.Length == 0)
|
|
||||||
{
|
|
||||||
return await next();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var policy in authorizeAttributesWithPolicies.Select(a => a.Policy))
|
|
||||||
{
|
|
||||||
var authorized = await identityService.AuthorizeAsync(user.Id.Value, policy);
|
|
||||||
|
|
||||||
if (!authorized)
|
|
||||||
{
|
|
||||||
throw new ForbiddenAccessException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// User is authorized / authorization not required
|
|
||||||
return await next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
using MediatR.Pipeline;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Common.Behaviours;
|
|
||||||
|
|
||||||
public class LoggingBehaviour<TRequest>(
|
|
||||||
ILogger<TRequest> logger,
|
|
||||||
IUser user,
|
|
||||||
IIdentityService identityService)
|
|
||||||
: IRequestPreProcessor<TRequest>
|
|
||||||
where TRequest : notnull
|
|
||||||
{
|
|
||||||
private readonly ILogger _logger = logger;
|
|
||||||
|
|
||||||
public async Task Process(TRequest request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var requestName = typeof(TRequest).Name;
|
|
||||||
string? userName = string.Empty;
|
|
||||||
|
|
||||||
if (user.Id.HasValue)
|
|
||||||
{
|
|
||||||
userName = await identityService.GetUserNameAsync(user.Id.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Hutopy Request: {Name} {@UserId} {@UserName} {@Request}",
|
|
||||||
requestName, user.Id ?? Guid.Empty, userName, request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Common.Behaviours;
|
|
||||||
|
|
||||||
public class PerformanceBehaviour<TRequest, TResponse>(
|
|
||||||
ILogger<TRequest> logger,
|
|
||||||
IUser user,
|
|
||||||
IIdentityService identityService)
|
|
||||||
: IPipelineBehavior<TRequest, TResponse>
|
|
||||||
where TRequest : notnull
|
|
||||||
{
|
|
||||||
private readonly Stopwatch _timer = new();
|
|
||||||
|
|
||||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
_timer.Start();
|
|
||||||
|
|
||||||
var response = await next();
|
|
||||||
|
|
||||||
_timer.Stop();
|
|
||||||
|
|
||||||
var elapsedMilliseconds = _timer.ElapsedMilliseconds;
|
|
||||||
|
|
||||||
if (elapsedMilliseconds <= 500) return response;
|
|
||||||
|
|
||||||
var requestName = typeof(TRequest).Name;
|
|
||||||
var userName = string.Empty;
|
|
||||||
|
|
||||||
if (user.Id.HasValue) userName = await identityService.GetUserNameAsync(user.Id.Value);
|
|
||||||
|
|
||||||
logger.LogWarning("Hutopy Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@UserId} {@UserName} {@Request}",
|
|
||||||
requestName, elapsedMilliseconds, user.Id ?? Guid.Empty, userName, request);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Common.Behaviours;
|
|
||||||
|
|
||||||
public class UnhandledExceptionBehaviour<TRequest, TResponse>(
|
|
||||||
ILogger<TRequest> logger)
|
|
||||||
: IPipelineBehavior<TRequest, TResponse>
|
|
||||||
where TRequest : notnull
|
|
||||||
{
|
|
||||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await next();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
var requestName = typeof(TRequest).Name;
|
|
||||||
|
|
||||||
logger.LogError(ex, "Hutopy Request: Unhandled Exception for Request {Name} {@Request}", requestName, request);
|
|
||||||
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using ValidationException = Hutopy.Application.Common.Exceptions.ValidationException;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Common.Behaviours;
|
|
||||||
|
|
||||||
public class ValidationBehaviour<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
|
|
||||||
: IPipelineBehavior<TRequest, TResponse>
|
|
||||||
where TRequest : notnull
|
|
||||||
{
|
|
||||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!validators.Any()) return await next();
|
|
||||||
|
|
||||||
var context = new ValidationContext<TRequest>(request);
|
|
||||||
|
|
||||||
var validationResults = await Task.WhenAll(
|
|
||||||
validators.Select(v =>
|
|
||||||
v.ValidateAsync(context, cancellationToken)));
|
|
||||||
|
|
||||||
var failures = validationResults
|
|
||||||
.Where(r => r.Errors.Any())
|
|
||||||
.SelectMany(r => r.Errors)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (failures.Count != 0) throw new ValidationException(failures);
|
|
||||||
|
|
||||||
return await next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Hutopy.Application.Common.Exceptions;
|
|
||||||
|
|
||||||
public class ForbiddenAccessException : Exception
|
|
||||||
{
|
|
||||||
public ForbiddenAccessException() : base() { }
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
using FluentValidation.Results;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Common.Exceptions;
|
|
||||||
|
|
||||||
public class ValidationException()
|
|
||||||
: Exception("One or more validation failures have occurred.")
|
|
||||||
{
|
|
||||||
public ValidationException(IEnumerable<ValidationFailure> failures)
|
|
||||||
: this()
|
|
||||||
{
|
|
||||||
Errors = failures
|
|
||||||
.GroupBy(e => e.PropertyName, e => e.ErrorMessage)
|
|
||||||
.ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
public IDictionary<string, string[]> Errors { get; } = new Dictionary<string, string[]>();
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
using Hutopy.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Common.Interfaces;
|
|
||||||
|
|
||||||
public interface IApplicationDbContext
|
|
||||||
{
|
|
||||||
DbSet<FutureCreator> FutureCreators { get; }
|
|
||||||
DbSet<UserTransaction> UserTransactions { get; }
|
|
||||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace Hutopy.Application.Common.Interfaces;
|
|
||||||
|
|
||||||
public interface IBlobStorage
|
|
||||||
{
|
|
||||||
Task<string> UploadFileAsync(string containerName, string blobName, Stream stream, string contentType,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
Task<MemoryStream> DownloadFileAsync(string containerName, string blobName, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Common.Interfaces;
|
|
||||||
|
|
||||||
public interface IIdentityService
|
|
||||||
{
|
|
||||||
Task<Result<Guid>> CreateUserAsync(
|
|
||||||
string email,
|
|
||||||
string userName,
|
|
||||||
string firstName,
|
|
||||||
string lastName,
|
|
||||||
string password);
|
|
||||||
|
|
||||||
Task<UserModel?> GetCurrentUserAsync();
|
|
||||||
Task<Result> UpdateCurrentUserPortraitUrlAsync(string url);
|
|
||||||
Task<Result<Guid>> UpdateCurrentUserAsync(UserModel userModel);
|
|
||||||
Task<IList<string>> GetCurrentUserRolesAsync();
|
|
||||||
Task<UserModel?> FindUserByIdAsync(string id);
|
|
||||||
Task<UserModel?> FindUserByEmailAsync(string email);
|
|
||||||
Task<UserModel?> GetUserByUserNameAsync(string userName);
|
|
||||||
Task<string?> LoginAsync(string email, string password);
|
|
||||||
Task<bool> IsInRoleAsync(Guid userId, string role);
|
|
||||||
Task<bool> AuthorizeAsync(Guid userId, string policyName);
|
|
||||||
Task<string?> GetUserNameAsync(Guid userId);
|
|
||||||
|
|
||||||
Task<Result> AddRoleAsync(string userId, string role);
|
|
||||||
Task<Result> DeleteUserAsync(string userId);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Common.Interfaces;
|
|
||||||
|
|
||||||
public interface IRoleService
|
|
||||||
{
|
|
||||||
public Task<Result> CreateRoleAsync(string roleName);
|
|
||||||
public Task<Result> DeleteRoleAsync(string roleName);
|
|
||||||
public Task<RoleModel?> FindRoleByIdAsync(string roleId);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Models;
|
|
||||||
using Hutopy.Application.Stripe.Commands;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Common.Interfaces;
|
|
||||||
|
|
||||||
|
|
||||||
public interface IStripeService
|
|
||||||
{
|
|
||||||
public Task<string> CreateCheckoutSession(int amount, string creatorId, string currency);
|
|
||||||
public Result ValidateTransaction(ConfirmStripeTransactionCommand request);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Hutopy.Application.Common.Interfaces;
|
|
||||||
|
|
||||||
public interface IUser
|
|
||||||
{
|
|
||||||
Guid? Id { get; }
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Common.Mappings;
|
|
||||||
|
|
||||||
public static class MappingExtensions
|
|
||||||
{
|
|
||||||
public static Task<PaginatedList<TDestination>> PaginatedListAsync<TDestination>(this IQueryable<TDestination> queryable, int pageNumber, int pageSize) where TDestination : class
|
|
||||||
=> PaginatedList<TDestination>.CreateAsync(queryable.AsNoTracking(), pageNumber, pageSize);
|
|
||||||
|
|
||||||
public static Task<List<TDestination>> ProjectToListAsync<TDestination>(this IQueryable queryable, IConfigurationProvider configuration) where TDestination : class
|
|
||||||
=> queryable.ProjectTo<TDestination>(configuration).AsNoTracking().ToListAsync();
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
namespace Hutopy.Application.Common.Models;
|
|
||||||
|
|
||||||
public class PaginatedList<T>(
|
|
||||||
IReadOnlyCollection<T> items,
|
|
||||||
int count,
|
|
||||||
int pageNumber,
|
|
||||||
int pageSize)
|
|
||||||
{
|
|
||||||
public IReadOnlyCollection<T> Items { get; } = items;
|
|
||||||
public int PageNumber { get; } = pageNumber;
|
|
||||||
public int TotalPages { get; } = (int)Math.Ceiling(count / (double)pageSize);
|
|
||||||
public int TotalCount { get; } = count;
|
|
||||||
|
|
||||||
public bool HasPreviousPage => PageNumber > 1;
|
|
||||||
|
|
||||||
public bool HasNextPage => PageNumber < TotalPages;
|
|
||||||
|
|
||||||
public static async Task<PaginatedList<T>> CreateAsync(IQueryable<T> source, int pageNumber, int pageSize)
|
|
||||||
{
|
|
||||||
var count = await source.CountAsync();
|
|
||||||
var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync();
|
|
||||||
|
|
||||||
return new PaginatedList<T>(items, count, pageNumber, pageSize);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
namespace Hutopy.Application.Common.Security;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Specifies the class this attribute is applied to requires authorization.
|
|
||||||
/// </summary>
|
|
||||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
|
|
||||||
public class AuthorizeAttribute : Attribute
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="AuthorizeAttribute"/> class.
|
|
||||||
/// </summary>
|
|
||||||
public AuthorizeAttribute() { }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a comma delimited list of roles that are allowed to access the resource.
|
|
||||||
/// </summary>
|
|
||||||
public string Roles { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the policy name that determines access to the resource.
|
|
||||||
/// </summary>
|
|
||||||
public string Policy { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
using System.Reflection;
|
|
||||||
using Hutopy.Application.Common.Behaviours;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
|
|
||||||
namespace Hutopy.Application;
|
|
||||||
|
|
||||||
public static class DependencyInjection
|
|
||||||
{
|
|
||||||
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddAutoMapper(Assembly.GetExecutingAssembly());
|
|
||||||
|
|
||||||
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
|
||||||
|
|
||||||
services.AddMediatR(cfg =>
|
|
||||||
{
|
|
||||||
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
|
|
||||||
//cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(UnhandledExceptionBehaviour<,>));
|
|
||||||
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(AuthorizationBehaviour<,>));
|
|
||||||
//cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
|
|
||||||
//cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(PerformanceBehaviour<,>));
|
|
||||||
});
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
using Hutopy.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.FutureCreators.Commands;
|
|
||||||
|
|
||||||
public record CreateFutureCreatorCommand : IRequest<Guid>
|
|
||||||
{
|
|
||||||
public required string FirstName { get; init; }
|
|
||||||
public required string LastName { get; init; }
|
|
||||||
public required string EmailAddress { get; init; }
|
|
||||||
public required string PhoneNumber { get; init; }
|
|
||||||
public required string SocialNetworkAccount { get; init; }
|
|
||||||
public required string ReasonToJoin { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class CreateFutureCreatorCommandHandler(
|
|
||||||
IApplicationDbContext context)
|
|
||||||
: IRequestHandler<CreateFutureCreatorCommand, Guid>
|
|
||||||
{
|
|
||||||
public async Task<Guid> Handle(CreateFutureCreatorCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var entity = new FutureCreator
|
|
||||||
{
|
|
||||||
FirstName = request.FirstName,
|
|
||||||
LastName = request.LastName,
|
|
||||||
EmailAddress = request.EmailAddress,
|
|
||||||
PhoneNumber = request.PhoneNumber,
|
|
||||||
SocialNetworkAccount = request.SocialNetworkAccount,
|
|
||||||
ReasonToJoin = request.ReasonToJoin,
|
|
||||||
};
|
|
||||||
|
|
||||||
context.FutureCreators.Add(entity);
|
|
||||||
|
|
||||||
await context.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return entity.Id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
using Hutopy.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.FutureCreators.Queries;
|
|
||||||
|
|
||||||
public class FutureCreatorListDto
|
|
||||||
{
|
|
||||||
public Guid Id { get; init; }
|
|
||||||
|
|
||||||
public required string FirstName { get; init; }
|
|
||||||
|
|
||||||
public required string LastName { get; init; }
|
|
||||||
|
|
||||||
private class Mapping : Profile
|
|
||||||
{
|
|
||||||
public Mapping()
|
|
||||||
{
|
|
||||||
CreateMap<FutureCreator, FutureCreatorListDto>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
|
|
||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
using Hutopy.Application.Common.Mappings;
|
|
||||||
using Hutopy.Application.Common.Models;
|
|
||||||
using Hutopy.Application.Common.Security;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.FutureCreators.Queries;
|
|
||||||
|
|
||||||
[Authorize(Roles = "Administrator")]
|
|
||||||
public record GetFutureCreatorListQuery : IRequest<PaginatedList<FutureCreatorListDto>>
|
|
||||||
{
|
|
||||||
public int PageNumber { get; init; } = 1;
|
|
||||||
public int PageSize { get; init; } = 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class GetFutureCreatorListQueryHandler(
|
|
||||||
IApplicationDbContext context,
|
|
||||||
IMapper mapper)
|
|
||||||
: IRequestHandler<GetFutureCreatorListQuery, PaginatedList<FutureCreatorListDto>>
|
|
||||||
{
|
|
||||||
public async Task<PaginatedList<FutureCreatorListDto>> Handle(GetFutureCreatorListQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return await context.FutureCreators
|
|
||||||
.OrderBy(x => x.FirstName)
|
|
||||||
.ProjectTo<FutureCreatorListDto>(mapper.ConfigurationProvider)
|
|
||||||
.PaginatedListAsync(request.PageNumber, request.PageSize);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
global using Ardalis.GuardClauses;
|
|
||||||
global using AutoMapper;
|
|
||||||
global using AutoMapper.QueryableExtensions;
|
|
||||||
global using Microsoft.EntityFrameworkCore;
|
|
||||||
global using FluentValidation;
|
|
||||||
global using MediatR;
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Stripe.Commands;
|
|
||||||
public class ConfirmStripeTransactionCommand : IRequest<string>
|
|
||||||
{
|
|
||||||
public string Id { get; set; }
|
|
||||||
public string Object { get; set; }
|
|
||||||
public int Created { get; set; }
|
|
||||||
public Data Data { get; set; }
|
|
||||||
public Request Request { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Data
|
|
||||||
{
|
|
||||||
public Object Object { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Object
|
|
||||||
{
|
|
||||||
public string Id { get; set; } = string.Empty;
|
|
||||||
public int Amount { get; set; }
|
|
||||||
public BillingDetails Billing_details { get; set; } = new();
|
|
||||||
public string Calculated_statement_descriptor { get; set; } = string.Empty;
|
|
||||||
public string Currency { get; set; } = string.Empty;
|
|
||||||
public bool Paid { get; set; }
|
|
||||||
public string Payment_intent { get; set; } = string.Empty;
|
|
||||||
public string Payment_method { get; set; } = string.Empty;
|
|
||||||
public string Receipt_url { get; set; } = string.Empty;
|
|
||||||
public string Status { get; set; } = string.Empty;
|
|
||||||
public string Failure_message { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class BillingDetails
|
|
||||||
{
|
|
||||||
public string Email { get; set; } = string.Empty;
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public string Phone { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Request
|
|
||||||
{
|
|
||||||
public string Id { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ConfirmStripeTransactionCommandHandler(
|
|
||||||
IApplicationDbContext dbContext,
|
|
||||||
IStripeService stripeService
|
|
||||||
)
|
|
||||||
: IRequestHandler<ConfirmStripeTransactionCommand, string>
|
|
||||||
{
|
|
||||||
public async Task<string> Handle(ConfirmStripeTransactionCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var lastTransaction = await dbContext.UserTransactions.OrderBy(x => x.CreatedAt).LastAsync(cancellationToken);
|
|
||||||
var stripeConfirmation = stripeService.ValidateTransaction(request);
|
|
||||||
|
|
||||||
if (stripeConfirmation.Succeeded)
|
|
||||||
{
|
|
||||||
lastTransaction.IsConfirmed = true;
|
|
||||||
}
|
|
||||||
lastTransaction.Paid = request.Data.Object.Paid;
|
|
||||||
lastTransaction.StripeChargeId = request.Data.Object.Id;
|
|
||||||
lastTransaction.StripeEventId = request.Id;
|
|
||||||
lastTransaction.StripeReceiptUrl = request.Data.Object.Receipt_url;
|
|
||||||
lastTransaction.StripePaymentIntent = request.Data.Object.Payment_intent;
|
|
||||||
lastTransaction.StripePaymentMethod = request.Data.Object.Payment_method;
|
|
||||||
lastTransaction.StripeBillingDetailEmail = request.Data.Object.Billing_details.Email;
|
|
||||||
lastTransaction.StripeBillingDetailName = request.Data.Object.Billing_details.Name;
|
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
using Hutopy.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Stripe.Commands;
|
|
||||||
|
|
||||||
public record CreateSessionCheckoutCommand : IRequest<string>
|
|
||||||
{
|
|
||||||
public required Guid CreatorId { get; init; }
|
|
||||||
public required int Amount { get; init; }
|
|
||||||
public string Currency { get; init; } = "CAD";
|
|
||||||
public string TipMessage { get; init; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class CreateSessionCheckoutCommandHandler(
|
|
||||||
IApplicationDbContext dbContext,
|
|
||||||
IStripeService stripeService
|
|
||||||
)
|
|
||||||
: IRequestHandler<CreateSessionCheckoutCommand, string>
|
|
||||||
{
|
|
||||||
public async Task<string> Handle(CreateSessionCheckoutCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var stripeSecret = await stripeService.CreateCheckoutSession(
|
|
||||||
request.Amount,
|
|
||||||
request.CreatorId.ToString(),
|
|
||||||
request.Currency);
|
|
||||||
|
|
||||||
// ReSharper disable once PossibleLossOfFraction
|
|
||||||
decimal priceInDollars = (request.Amount / 100);
|
|
||||||
|
|
||||||
var userTransaction = new UserTransaction
|
|
||||||
{
|
|
||||||
Currency = request.Currency,
|
|
||||||
Amount = priceInDollars,
|
|
||||||
TipMessage = request.TipMessage,
|
|
||||||
ApplicationUserId = request.CreatorId
|
|
||||||
};
|
|
||||||
|
|
||||||
await dbContext.UserTransactions.AddAsync(userTransaction, cancellationToken);
|
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return stripeSecret;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Stripe.Queries;
|
|
||||||
|
|
||||||
public record GetMyLastReceiptQuery : IRequest<MyLastReceiptDto>
|
|
||||||
{
|
|
||||||
public Guid CreatorId { get; set; }
|
|
||||||
public string Email { get; set; } = string.Empty;
|
|
||||||
};
|
|
||||||
|
|
||||||
public class GetMyLastReceiptQueryHandler(
|
|
||||||
IApplicationDbContext dbContext
|
|
||||||
)
|
|
||||||
: IRequestHandler<GetMyLastReceiptQuery, MyLastReceiptDto>
|
|
||||||
{
|
|
||||||
public async Task<MyLastReceiptDto> Handle(GetMyLastReceiptQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var lastTransaction = await dbContext.UserTransactions.OrderBy(x => x.CreatedAt)
|
|
||||||
.LastOrDefaultAsync(
|
|
||||||
x => x.ApplicationUserId == request.CreatorId && x.StripeBillingDetailEmail == request.Email,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
var receiptUrl = new MyLastReceiptDto { ReceiptUrl = lastTransaction?.StripeReceiptUrl ?? "", };
|
|
||||||
|
|
||||||
return receiptUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Hutopy.Application.Stripe.Queries;
|
|
||||||
|
|
||||||
public class MyLastReceiptDto
|
|
||||||
{
|
|
||||||
public string ReceiptUrl { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Users.Commands;
|
|
||||||
public record CreateUserCommand : IRequest<IResult>
|
|
||||||
{
|
|
||||||
public required string FirstName { get; init; }
|
|
||||||
public required string LastName { get; init; }
|
|
||||||
public required string EmailAddress { get; init; }
|
|
||||||
public required string UserName { get; init; }
|
|
||||||
public required string Password { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class CreateUserCommandHandler : IRequestHandler<CreateUserCommand, IResult>
|
|
||||||
{
|
|
||||||
private readonly IApplicationDbContext _context;
|
|
||||||
private readonly IIdentityService _identityService;
|
|
||||||
|
|
||||||
public CreateUserCommandHandler(IApplicationDbContext context, IIdentityService identityService)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
_identityService = identityService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IResult> Handle(CreateUserCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await _identityService.CreateUserAsync(request.EmailAddress, request.UserName, request.FirstName, request.LastName, request.Password);
|
|
||||||
|
|
||||||
var user = await _identityService.FindUserByEmailAsync(request.EmailAddress);
|
|
||||||
|
|
||||||
if (user is null) throw new InvalidOperationException("This should never happen, we just created the user.");
|
|
||||||
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Results.Ok(user.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Users.Commands;
|
|
||||||
|
|
||||||
public record LoginCommand(
|
|
||||||
string Email,
|
|
||||||
string Password)
|
|
||||||
: IRequest<LoginResponse>;
|
|
||||||
|
|
||||||
public record LoginResponse(
|
|
||||||
string AccessToken,
|
|
||||||
string RefreshToken);
|
|
||||||
|
|
||||||
public class LoginCommandHandler(
|
|
||||||
IApplicationDbContext Context,
|
|
||||||
IIdentityService identityService)
|
|
||||||
: IRequestHandler<LoginCommand, LoginResponse>
|
|
||||||
{
|
|
||||||
public async Task<LoginResponse> Handle(LoginCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var accessToken = await identityService.LoginAsync(request.Email, request.Password);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(accessToken)) throw new InvalidOperationException("Invalid login credentials");
|
|
||||||
|
|
||||||
return new LoginResponse(accessToken, string.Empty);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
using Hutopy.Application.Common.Models;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Users.Commands;
|
|
||||||
|
|
||||||
public class UpdateCurrentUserCommand : IRequest<IResult>
|
|
||||||
{
|
|
||||||
public required string? Alias { get; init; }
|
|
||||||
public required string? FirstName { get; init; }
|
|
||||||
public required string? LastName { get; init; }
|
|
||||||
public required string? Occupation { get; init; }
|
|
||||||
public required string? BirthDate { get; init; }
|
|
||||||
public required string? Country { get; init; }
|
|
||||||
public required string? City { get; init; }
|
|
||||||
public required string? Address { get; init; }
|
|
||||||
|
|
||||||
[NotMapped]
|
|
||||||
private class Mapping : Profile
|
|
||||||
{
|
|
||||||
public Mapping()
|
|
||||||
{
|
|
||||||
CreateMap<UpdateCurrentUserCommand, UserModel>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class UpdateCurrentUserCommandHandler(
|
|
||||||
IApplicationDbContext context,
|
|
||||||
IIdentityService identityService,
|
|
||||||
IMapper mapper)
|
|
||||||
: IRequestHandler<UpdateCurrentUserCommand, IResult>
|
|
||||||
{
|
|
||||||
public async Task<IResult> Handle(UpdateCurrentUserCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var identityUser = await identityService.GetCurrentUserAsync();
|
|
||||||
|
|
||||||
if (identityUser?.Id is null) return Results.Problem("Current user not found.");
|
|
||||||
|
|
||||||
var userModel = mapper.Map<UserModel>(request);
|
|
||||||
userModel.Id = identityUser.Id;
|
|
||||||
|
|
||||||
var result = await identityService.UpdateCurrentUserAsync(userModel);
|
|
||||||
|
|
||||||
await context.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return result.Succeeded ? Results.Ok(result.GetValueOrDefault()) : Results.Problem(result.GetErrorsAsString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Users.Queries.GetUser;
|
|
||||||
|
|
||||||
public record GetUserByIdQuery : IRequest<UserDto>
|
|
||||||
{
|
|
||||||
public required string UserId { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class GetUserByIdHandler(
|
|
||||||
IIdentityService identityService
|
|
||||||
)
|
|
||||||
: IRequestHandler<GetUserByIdQuery, UserDto>
|
|
||||||
{
|
|
||||||
public async Task<UserDto> Handle(GetUserByIdQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var user = await identityService.FindUserByIdAsync(query.UserId);
|
|
||||||
|
|
||||||
if (user is null) throw new InvalidOperationException();
|
|
||||||
|
|
||||||
return user.ToDto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Users.Queries.GetUser;
|
|
||||||
|
|
||||||
public record GetUserByUserNameQuery : IRequest<UserDto>
|
|
||||||
{
|
|
||||||
public required string UserName { get; init; }
|
|
||||||
};
|
|
||||||
|
|
||||||
public class GetUserByUserNameQueryHandler(
|
|
||||||
IIdentityService identityService
|
|
||||||
)
|
|
||||||
: IRequestHandler<GetUserByUserNameQuery, UserDto>
|
|
||||||
{
|
|
||||||
public async Task<UserDto> Handle(GetUserByUserNameQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var user = await identityService.GetUserByUserNameAsync(query.UserName);
|
|
||||||
|
|
||||||
if (user is null) throw new InvalidOperationException();
|
|
||||||
|
|
||||||
return user.ToDto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace Hutopy.Application.Users.Queries.GetUser;
|
|
||||||
|
|
||||||
public class UserDto
|
|
||||||
{
|
|
||||||
public required Guid Id { get; init; }
|
|
||||||
public required string UserName { get; init; }
|
|
||||||
public string? FirstName { get; init; }
|
|
||||||
public string? LastName { get; init; }
|
|
||||||
public string? Occupation { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class UserDtoExtensions
|
|
||||||
{
|
|
||||||
public static UserDto ToDto(this UserModel model) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = model.Id,
|
|
||||||
FirstName = model.Firstname,
|
|
||||||
LastName = model.Lastname,
|
|
||||||
UserName = model.Username
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace Hutopy.Domain.Entities;
|
|
||||||
|
|
||||||
public class FutureCreator : BaseAuditableEntity
|
|
||||||
{
|
|
||||||
public required string FirstName { get; init; }
|
|
||||||
public required string LastName { get; init; }
|
|
||||||
public required string EmailAddress { get; init; }
|
|
||||||
public required string PhoneNumber { get; init; }
|
|
||||||
public required string SocialNetworkAccount { get; init; }
|
|
||||||
public required string ReasonToJoin { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
namespace Hutopy.Domain.Entities;
|
|
||||||
|
|
||||||
public class UserTransaction : BaseAuditableEntity
|
|
||||||
{
|
|
||||||
public decimal Amount { get; set; }
|
|
||||||
public string Currency { get; set; } = "CAD";
|
|
||||||
public string TipMessage { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
// Foreign key to ApplicationUser
|
|
||||||
public required Guid ApplicationUserId { get; set; }
|
|
||||||
public bool IsConfirmed { get; set; }
|
|
||||||
public string StripeEventId { get; set; } = string.Empty;
|
|
||||||
public string StripeChargeId { get; set; } = string.Empty;
|
|
||||||
public string StripePaymentIntent { get; set; } = string.Empty;
|
|
||||||
public string StripePaymentMethod { get; set; } = string.Empty;
|
|
||||||
public string StripeReceiptUrl { get; set; } = string.Empty;
|
|
||||||
public string StripeBillingDetailEmail { get; set; } = string.Empty;
|
|
||||||
public string StripeBillingDetailName { get; set; } = string.Empty;
|
|
||||||
public bool Paid { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,2 @@
|
|||||||
global using Hutopy.Domain.Common;
|
global using Hutopy.Domain.Common;
|
||||||
global using Hutopy.Domain.Entities;
|
|
||||||
global using Hutopy.Domain.Enums;
|
|
||||||
global using Hutopy.Domain.Exceptions;
|
global using Hutopy.Domain.Exceptions;
|
||||||
global using Hutopy.Domain.ValueObjects;
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
using Azure;
|
using Azure;
|
||||||
using Azure.Storage.Blobs;
|
using Azure.Storage.Blobs;
|
||||||
using Azure.Storage.Blobs.Models;
|
using Azure.Storage.Blobs.Models;
|
||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Hutopy.Infrastructure.AzureBlob;
|
namespace Hutopy.Infrastructure.AzureBlob;
|
||||||
|
|
||||||
public class AzureBlobStorage : IBlobStorage
|
public class AzureBlobStorage
|
||||||
{
|
{
|
||||||
private const long MaxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes
|
private const long MaxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
using System.Text;
|
namespace Hutopy.Infrastructure.AzureBlob;
|
||||||
|
|
||||||
namespace Hutopy.Infrastructure.AzureBlob;
|
|
||||||
|
|
||||||
public static class ContentTypes
|
public static class ContentTypes
|
||||||
{
|
{
|
||||||
private static string ImagePng = "image/png";
|
private const string ImagePng = "image/png";
|
||||||
private static string ImageJpeg = "image/jpeg";
|
private const string ImageJpeg = "image/jpeg";
|
||||||
private static string ImageJpg = "image/jpg";
|
private const string ImageJpg = "image/jpg";
|
||||||
private static string TextHtml = "text/html";
|
|
||||||
|
|
||||||
public static HashSet<string> AllowedContentTypes = new HashSet<string> { ImagePng, ImageJpeg, ImageJpg, TextHtml };
|
|
||||||
|
|
||||||
public static bool IsAllowed(string contentType, Stream fileStream)
|
private static readonly HashSet<string> AllowedContentTypes = [ImagePng, ImageJpeg, ImageJpg];
|
||||||
|
|
||||||
|
public static bool IsAllowed(
|
||||||
|
string contentType,
|
||||||
|
Stream fileStream)
|
||||||
{
|
{
|
||||||
return IsValidFileType(fileStream) && AllowedContentTypes.Contains(contentType);
|
return IsValidFileType(fileStream) && AllowedContentTypes.Contains(contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsValidFileType(Stream fileStream)
|
private static bool IsValidFileType(
|
||||||
|
Stream fileStream)
|
||||||
{
|
{
|
||||||
byte[] buffer = new byte[512];
|
byte[] buffer = new byte[4];
|
||||||
fileStream.Read(buffer, 0, buffer.Length);
|
fileStream.Read(buffer, 0, buffer.Length);
|
||||||
fileStream.Position = 0;
|
fileStream.Position = 0;
|
||||||
|
|
||||||
@@ -33,13 +33,6 @@ public static class ContentTypes
|
|||||||
{
|
{
|
||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
using System.Reflection;
|
using Hutopy.Infrastructure.Identity;
|
||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
using Hutopy.Domain.Entities;
|
|
||||||
using Hutopy.Infrastructure.Identity;
|
|
||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Hutopy.Infrastructure.Data
|
namespace Hutopy.Infrastructure.Data
|
||||||
{
|
{
|
||||||
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
public class ApplicationDbContext(
|
||||||
: IdentityDbContext<ApplicationUser, ApplicationRole, Guid>(options), IApplicationDbContext
|
DbContextOptions<ApplicationDbContext> options)
|
||||||
{
|
: IdentityDbContext<ApplicationUser, ApplicationRole, Guid>(options);
|
||||||
public DbSet<FutureCreator> FutureCreators => Set<FutureCreator>();
|
|
||||||
public DbSet<UserTransaction> UserTransactions => Set<UserTransaction>();
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
|
||||||
{
|
|
||||||
base.OnModelCreating(builder);
|
|
||||||
|
|
||||||
// Apply configurations
|
|
||||||
builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
||||||
using Hutopy.Domain.Entities;
|
|
||||||
using Hutopy.Infrastructure.Identity;
|
|
||||||
|
|
||||||
namespace Hutopy.Infrastructure.Data.Configurations
|
|
||||||
{
|
|
||||||
public class UserTransactionConfiguration : IEntityTypeConfiguration<UserTransaction>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<UserTransaction> builder)
|
|
||||||
{
|
|
||||||
// Relationship between ApplicationUser and UserTransaction
|
|
||||||
builder.HasOne<ApplicationUser>()
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(ut => ut.ApplicationUserId)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
builder.Property(x => x.Amount).HasPrecision(18, 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
using Hutopy.Domain.Common;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
|
||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
||||||
|
|
||||||
namespace Hutopy.Infrastructure.Data.Interceptors;
|
|
||||||
|
|
||||||
public class AuditableEntityInterceptor(
|
|
||||||
IUser user,
|
|
||||||
TimeProvider dateTime) : SaveChangesInterceptor
|
|
||||||
{
|
|
||||||
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
|
|
||||||
{
|
|
||||||
UpdateEntities(eventData.Context);
|
|
||||||
|
|
||||||
return base.SavingChanges(eventData, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
UpdateEntities(eventData.Context);
|
|
||||||
|
|
||||||
return base.SavingChangesAsync(eventData, result, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpdateEntities(DbContext? context)
|
|
||||||
{
|
|
||||||
if (context == null) return;
|
|
||||||
|
|
||||||
foreach (var entry in context.ChangeTracker.Entries<BaseAuditableEntity>())
|
|
||||||
{
|
|
||||||
if (entry.State is EntityState.Added or EntityState.Modified || entry.HasChangedOwnedEntities())
|
|
||||||
{
|
|
||||||
var utcNow = dateTime.GetUtcNow();
|
|
||||||
if (entry.State == EntityState.Added)
|
|
||||||
{
|
|
||||||
entry.Entity.CreatedBy = user.Id;
|
|
||||||
entry.Entity.CreatedAt = utcNow;
|
|
||||||
}
|
|
||||||
entry.Entity.LastModifiedBy = user.Id;
|
|
||||||
entry.Entity.LastModifiedAt = utcNow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class Extensions
|
|
||||||
{
|
|
||||||
public static bool HasChangedOwnedEntities(this EntityEntry entry) =>
|
|
||||||
entry.References.Any(r =>
|
|
||||||
r.TargetEntry != null &&
|
|
||||||
r.TargetEntry.Metadata.IsOwned() &&
|
|
||||||
(r.TargetEntry.State == EntityState.Added || r.TargetEntry.State == EntityState.Modified));
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
using Hutopy.Domain.Common;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
||||||
|
|
||||||
namespace Hutopy.Infrastructure.Data.Interceptors;
|
|
||||||
|
|
||||||
public class DispatchDomainEventsInterceptor(
|
|
||||||
IPublisher mediator)
|
|
||||||
: SaveChangesInterceptor
|
|
||||||
{
|
|
||||||
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
|
|
||||||
{
|
|
||||||
DispatchDomainEvents(eventData.Context).GetAwaiter().GetResult();
|
|
||||||
|
|
||||||
return base.SavingChanges(eventData, result);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await DispatchDomainEvents(eventData.Context);
|
|
||||||
|
|
||||||
return await base.SavingChangesAsync(eventData, result, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DispatchDomainEvents(DbContext? context)
|
|
||||||
{
|
|
||||||
if (context == null) return;
|
|
||||||
|
|
||||||
var entities = context.ChangeTracker
|
|
||||||
.Entries<BaseEntity>()
|
|
||||||
.Where(e => e.Entity.DomainEvents.Any())
|
|
||||||
.Select(e => e.Entity);
|
|
||||||
|
|
||||||
var domainEvents = entities
|
|
||||||
.SelectMany(e => e.DomainEvents)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
entities.ToList().ForEach(e => e.ClearDomainEvents());
|
|
||||||
|
|
||||||
foreach (var domainEvent in domainEvents)
|
|
||||||
await mediator.Publish(domainEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
using Hutopy.Application.Common.Interfaces;
|
using Hutopy.Domain.Constants;
|
||||||
using Hutopy.Domain.Constants;
|
|
||||||
using Hutopy.Infrastructure.AzureBlob;
|
using Hutopy.Infrastructure.AzureBlob;
|
||||||
using Hutopy.Infrastructure.Data;
|
using Hutopy.Infrastructure.Data;
|
||||||
using Hutopy.Infrastructure.Data.Interceptors;
|
|
||||||
using Hutopy.Infrastructure.Identity;
|
using Hutopy.Infrastructure.Identity;
|
||||||
using Hutopy.Infrastructure.Stripe;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
@@ -23,17 +20,12 @@ public static class DependencyInjection
|
|||||||
var connectionString = configuration.GetConnectionString("MssqlConnection")
|
var connectionString = configuration.GetConnectionString("MssqlConnection")
|
||||||
?? throw new InvalidOperationException("Missing ConnectionStrings:MssqlConnection");
|
?? throw new InvalidOperationException("Missing ConnectionStrings:MssqlConnection");
|
||||||
|
|
||||||
services.AddScoped<ISaveChangesInterceptor, AuditableEntityInterceptor>();
|
|
||||||
services.AddScoped<ISaveChangesInterceptor, DispatchDomainEventsInterceptor>();
|
|
||||||
|
|
||||||
services.AddDbContext<ApplicationDbContext>((sp, options) =>
|
services.AddDbContext<ApplicationDbContext>((sp, options) =>
|
||||||
{
|
{
|
||||||
options.AddInterceptors(sp.GetServices<ISaveChangesInterceptor>());
|
options.AddInterceptors(sp.GetServices<ISaveChangesInterceptor>());
|
||||||
options.UseSqlServer(connectionString);
|
options.UseSqlServer(connectionString);
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddScoped<IApplicationDbContext>(provider => provider.GetRequiredService<ApplicationDbContext>());
|
|
||||||
|
|
||||||
services.AddScoped<ApplicationDbContextInitializer>();
|
services.AddScoped<ApplicationDbContextInitializer>();
|
||||||
|
|
||||||
services.AddAuthentication()
|
services.AddAuthentication()
|
||||||
@@ -52,14 +44,11 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
// Singleton services
|
// Singleton services
|
||||||
services.AddSingleton(TimeProvider.System);
|
services.AddSingleton(TimeProvider.System);
|
||||||
services.AddSingleton<IBlobStorage, AzureBlobStorage>();
|
services.AddSingleton<AzureBlobStorage>();
|
||||||
|
|
||||||
// Scoped services
|
// Scoped services
|
||||||
services.AddScoped<IIdentityService, IdentityService>();
|
services.AddScoped<IdentityService>();
|
||||||
|
|
||||||
// Transient services
|
|
||||||
services.AddTransient<IStripeService, StripeService>();
|
|
||||||
|
|
||||||
services.AddAuthorization(options =>
|
services.AddAuthorization(options =>
|
||||||
options.AddPolicy(Policies.CanPurge, policy => policy.RequireRole(Roles.Administrator)));
|
options.AddPolicy(Policies.CanPurge, policy => policy.RequireRole(Roles.Administrator)));
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Hutopy.Application.Common.Models;
|
using Hutopy.Infrastructure.Identity.Models;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
|
||||||
namespace Hutopy.Infrastructure.Identity;
|
namespace Hutopy.Infrastructure.Identity;
|
||||||
|
|||||||
@@ -1,74 +1,14 @@
|
|||||||
using Google.Apis.Oauth2.v2.Data;
|
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Hutopy.Application.Common.Interfaces;
|
using Hutopy.Infrastructure.Identity.Models;
|
||||||
using Hutopy.Application.Common.Models;
|
|
||||||
using Hutopy.Infrastructure.Utils;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace Hutopy.Infrastructure.Identity;
|
namespace Hutopy.Infrastructure.Identity;
|
||||||
|
|
||||||
public class IdentityService(
|
public class IdentityService(
|
||||||
ApplicationUserManager userManager,
|
ApplicationUserManager userManager,
|
||||||
SignInManager<ApplicationUser> signInManager,
|
IHttpContextAccessor contextAccessor
|
||||||
IUserClaimsPrincipalFactory<ApplicationUser> userClaimsPrincipalFactory,
|
|
||||||
IAuthorizationService authorizationService,
|
|
||||||
IHttpContextAccessor contextAccessor,
|
|
||||||
IOptionsSnapshot<JwtOptions> jwtOptions
|
|
||||||
)
|
)
|
||||||
: IIdentityService
|
|
||||||
{
|
{
|
||||||
public async Task<string?> GetUserNameAsync(Guid userId)
|
|
||||||
{
|
|
||||||
var user = await userManager.FindByIdAsync(userId.ToString());
|
|
||||||
|
|
||||||
return user?.UserName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<UserModel?> GetUserByUserNameAsync(string userName)
|
|
||||||
{
|
|
||||||
var user = await userManager.FindByNameAsync(userName);
|
|
||||||
|
|
||||||
if (user == null) return null;
|
|
||||||
|
|
||||||
return new()
|
|
||||||
{
|
|
||||||
Id = user.Id,
|
|
||||||
Username = user.UserName!,
|
|
||||||
PhoneNumber = user.PhoneNumber,
|
|
||||||
Email = user.Email,
|
|
||||||
Alias = user.Alias,
|
|
||||||
Firstname = user.Firstname,
|
|
||||||
Lastname = user.Lastname,
|
|
||||||
BirthDate = user.BirthDate,
|
|
||||||
Address = user.Address,
|
|
||||||
PortraitUrl = user.PortraitUrl
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<Guid>> CreateUserAsync(Userinfo userInfo)
|
|
||||||
{
|
|
||||||
var applicationUser = new ApplicationUser
|
|
||||||
{
|
|
||||||
UserName = userInfo.Name,
|
|
||||||
Email = userInfo.Email,
|
|
||||||
Firstname = userInfo.GivenName,
|
|
||||||
Lastname = userInfo.FamilyName
|
|
||||||
};
|
|
||||||
|
|
||||||
var password = Guid.NewGuid().ToString("N")[..32];
|
|
||||||
|
|
||||||
var identityResult = await userManager.CreateAsync(applicationUser, password);
|
|
||||||
|
|
||||||
var applicationResult = identityResult.ToApplicationResult();
|
|
||||||
|
|
||||||
var result = new Result<Guid>(applicationUser.Id, applicationResult.Succeeded, applicationResult.Errors);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<Guid>> CreateUserAsync(string email, string userName, string firstName, string lastName,
|
public async Task<Result<Guid>> CreateUserAsync(string email, string userName, string firstName, string lastName,
|
||||||
string password)
|
string password)
|
||||||
{
|
{
|
||||||
@@ -89,35 +29,6 @@ public class IdentityService(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<Guid>> UpdateCurrentUserAsync(UserModel userModel)
|
|
||||||
{
|
|
||||||
var applicationUser = await userManager.FindByIdAsync(userModel.Id.ToString());
|
|
||||||
|
|
||||||
if (applicationUser is null) return Result<Guid>.Failure(Guid.Empty, new[] { "User not found." });
|
|
||||||
|
|
||||||
applicationUser.Id = userModel.Id;
|
|
||||||
applicationUser.Email = userModel.Email;
|
|
||||||
applicationUser.PhoneNumber = userModel.PhoneNumber;
|
|
||||||
applicationUser.Alias = userModel.Alias;
|
|
||||||
applicationUser.Firstname = userModel.Firstname;
|
|
||||||
applicationUser.Lastname = userModel.Lastname;
|
|
||||||
applicationUser.BirthDate = userModel.BirthDate;
|
|
||||||
applicationUser.Address = userModel.Address;
|
|
||||||
applicationUser.PortraitUrl = userModel.PortraitUrl;
|
|
||||||
|
|
||||||
var response = await userManager.UpdateAsync(applicationUser);
|
|
||||||
|
|
||||||
var applicationResult = response.ToApplicationResult();
|
|
||||||
|
|
||||||
var result = new Result<Guid>(
|
|
||||||
userModel.Id,
|
|
||||||
applicationResult.Succeeded,
|
|
||||||
applicationResult.Errors);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private static UserModel BuildModelFrom(ApplicationUser response)
|
private static UserModel BuildModelFrom(ApplicationUser response)
|
||||||
{
|
{
|
||||||
var userModel = new UserModel
|
var userModel = new UserModel
|
||||||
@@ -168,80 +79,6 @@ public class IdentityService(
|
|||||||
return await FindUserByIdAsync(currentUserId);
|
return await FindUserByIdAsync(currentUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result> UpdateCurrentUserPortraitUrlAsync(string url)
|
|
||||||
{
|
|
||||||
var userModel = await GetCurrentUserAsync();
|
|
||||||
|
|
||||||
var applicationUser = await userManager.FindByIdAsync(userModel.Id.ToString());
|
|
||||||
if (applicationUser is null) return Result.Failure(["ApplicationUser not found."]);
|
|
||||||
|
|
||||||
applicationUser.PortraitUrl = url;
|
|
||||||
|
|
||||||
var response = await userManager.UpdateAsync(applicationUser);
|
|
||||||
|
|
||||||
return response.ToApplicationResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> IsInRoleAsync(Guid userId, string role)
|
|
||||||
{
|
|
||||||
var user = await userManager.FindByIdAsync(userId.ToString());
|
|
||||||
|
|
||||||
return user != null && await userManager.IsInRoleAsync(user, role);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> CurrentUserIsInRoleAsync(string role)
|
|
||||||
{
|
|
||||||
var currentUserModel = await GetCurrentUserAsync();
|
|
||||||
var currentUser = await userManager.FindByIdAsync(currentUserModel.Id.ToString());
|
|
||||||
|
|
||||||
return currentUser != null && await userManager.IsInRoleAsync(currentUser, role);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> AuthorizeAsync(Guid userId, string policyName)
|
|
||||||
{
|
|
||||||
var user = await userManager.FindByIdAsync(userId.ToString());
|
|
||||||
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var principal = await userClaimsPrincipalFactory.CreateAsync(user);
|
|
||||||
|
|
||||||
var result = await authorizationService.AuthorizeAsync(principal, policyName);
|
|
||||||
|
|
||||||
return result.Succeeded;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result> DeleteUserAsync(string userId)
|
|
||||||
{
|
|
||||||
var user = await userManager.FindByIdAsync(userId);
|
|
||||||
|
|
||||||
return user != null ? await DeleteUserAsync(user) : Result.Success();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result> DeleteUserAsync(ApplicationUser user)
|
|
||||||
{
|
|
||||||
var result = await userManager.DeleteAsync(user);
|
|
||||||
|
|
||||||
return result.ToApplicationResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result> AddRoleAsync(string userId, string role)
|
|
||||||
{
|
|
||||||
var hasAdminAccess = await CurrentUserIsInRoleAsync("Administrator");
|
|
||||||
|
|
||||||
if (!hasAdminAccess) return Result.Failure(new[] { "Only administrator can assign new roles to a user." });
|
|
||||||
|
|
||||||
var user = await userManager.FindByIdAsync(userId);
|
|
||||||
|
|
||||||
if (user is null) return Result.Failure(new[] { "User not found." });
|
|
||||||
|
|
||||||
var result = await userManager.AddToRoleAsync(user, role);
|
|
||||||
|
|
||||||
return result.ToApplicationResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IList<string>> GetCurrentUserRolesAsync()
|
public async Task<IList<string>> GetCurrentUserRolesAsync()
|
||||||
{
|
{
|
||||||
var currentUserModel = await GetCurrentUserAsync();
|
var currentUserModel = await GetCurrentUserAsync();
|
||||||
@@ -255,32 +92,4 @@ public class IdentityService(
|
|||||||
return userRoles;
|
return userRoles;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> LoginAsync(string userName, string password)
|
|
||||||
{
|
|
||||||
var result =
|
|
||||||
await signInManager.PasswordSignInAsync(userName, password, isPersistent: false, lockoutOnFailure: false);
|
|
||||||
|
|
||||||
if (!result.Succeeded)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await GetUserByUserNameAsync(userName);
|
|
||||||
|
|
||||||
if (user is null) throw new InvalidOperationException();
|
|
||||||
|
|
||||||
var token = JwtTokenHelper.GenerateJwtToken(
|
|
||||||
expiresIn: jwtOptions.Value.Lifetime,
|
|
||||||
issuer: jwtOptions.Value.Issuer,
|
|
||||||
audience: jwtOptions.Value.Audience,
|
|
||||||
key: jwtOptions.Value.Key,
|
|
||||||
userId: user.Id.ToString(),
|
|
||||||
email: user.Email,
|
|
||||||
alias: user.Alias,
|
|
||||||
firstname: user.Firstname,
|
|
||||||
lastname: user.Lastname,
|
|
||||||
portraitUrl: user.PortraitUrl);
|
|
||||||
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Hutopy.Application.Common.Models;
|
namespace Hutopy.Infrastructure.Identity.Models;
|
||||||
|
|
||||||
public class Result(
|
public class Result(
|
||||||
bool succeeded,
|
bool succeeded,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Hutopy.Application.Common.Models;
|
namespace Hutopy.Infrastructure.Identity.Models;
|
||||||
|
|
||||||
public class RoleModel
|
public class RoleModel
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Hutopy.Application.Common.Models;
|
namespace Hutopy.Infrastructure.Identity.Models;
|
||||||
|
|
||||||
public class UserModel
|
public class UserModel
|
||||||
{
|
{
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
using Hutopy.Application.Common.Models;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
|
|
||||||
namespace Hutopy.Infrastructure.Identity;
|
|
||||||
|
|
||||||
public class RoleService(
|
|
||||||
RoleManager<ApplicationRole> roleManager
|
|
||||||
)
|
|
||||||
: IRoleService
|
|
||||||
{
|
|
||||||
public async Task<Result> CreateRoleAsync(string roleName)
|
|
||||||
{
|
|
||||||
var role = new ApplicationRole { Name = roleName, Id = Guid.NewGuid()};
|
|
||||||
var result = await roleManager.CreateAsync(role);
|
|
||||||
|
|
||||||
return result.ToApplicationResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result> DeleteRoleAsync(string roleName)
|
|
||||||
{
|
|
||||||
var role = new ApplicationRole { Name = roleName };
|
|
||||||
var result = await roleManager.DeleteAsync(role);
|
|
||||||
|
|
||||||
return result.ToApplicationResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RoleModel?> FindRoleByIdAsync(string roleId)
|
|
||||||
{
|
|
||||||
var result = await roleManager.FindByIdAsync(roleId);
|
|
||||||
|
|
||||||
if (result is null) return null;
|
|
||||||
|
|
||||||
var roleModel = new RoleModel { Id = result.Id, Name = result.Name };
|
|
||||||
|
|
||||||
return roleModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RoleModel?> FindRoleByNameAsync(string roleName)
|
|
||||||
{
|
|
||||||
var result = await roleManager.FindByNameAsync(roleName);
|
|
||||||
|
|
||||||
if (result is null) return null;
|
|
||||||
|
|
||||||
var roleModel = new RoleModel { Id = result.Id, Name = result.Name };
|
|
||||||
|
|
||||||
return roleModel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
using Stripe;
|
|
||||||
using Stripe.Checkout;
|
|
||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Hutopy.Application.Common.Models;
|
|
||||||
using Hutopy.Application.Stripe.Commands;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace Hutopy.Infrastructure.Stripe;
|
|
||||||
|
|
||||||
public class StripeService : IStripeService
|
|
||||||
{
|
|
||||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
|
||||||
|
|
||||||
public StripeService(IHttpContextAccessor httpContextAccessor, IConfiguration configuration)
|
|
||||||
{
|
|
||||||
_httpContextAccessor = httpContextAccessor;
|
|
||||||
var stripeKey = configuration["Stripe:apiKey"] ?? "";
|
|
||||||
StripeConfiguration.ApiKey = stripeKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> CreateCheckoutSession(int amount, string creatorId, string currency = "cad")
|
|
||||||
{
|
|
||||||
var options = new SessionCreateOptions
|
|
||||||
{
|
|
||||||
LineItems =
|
|
||||||
[
|
|
||||||
new SessionLineItemOptions
|
|
||||||
{
|
|
||||||
PriceData = new SessionLineItemPriceDataOptions
|
|
||||||
{
|
|
||||||
UnitAmount = amount,
|
|
||||||
Currency = currency,
|
|
||||||
ProductData = new SessionLineItemPriceDataProductDataOptions { Name = "Tip", },
|
|
||||||
},
|
|
||||||
Quantity = 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
],
|
|
||||||
Mode = "payment",
|
|
||||||
UiMode = "embedded",
|
|
||||||
ReturnUrl = $"https://hutopy.ca/paymentcompleted?creatorId={creatorId}",
|
|
||||||
InvoiceCreation = new SessionInvoiceCreationOptions(){ Enabled = true},
|
|
||||||
ClientReferenceId = creatorId
|
|
||||||
};
|
|
||||||
|
|
||||||
var service = new SessionService();
|
|
||||||
Session session = await service.CreateAsync(options);
|
|
||||||
|
|
||||||
return session.ClientSecret;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Result ValidateTransaction(ConfirmStripeTransactionCommand request)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (request.Data.Object.Status is "succeeded")
|
|
||||||
{
|
|
||||||
return new Result(true, new List<string>());
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Result(false, new List<string>());
|
|
||||||
}
|
|
||||||
catch (StripeException e)
|
|
||||||
{
|
|
||||||
Console.WriteLine("Error: {0}", e.Message);
|
|
||||||
return new Result(false, new List<string>{e.Message});
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
return new Result(false, new List<string>{e.Message});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Hutopy.Application.Common.Interfaces;
|
using Hutopy.Infrastructure.Identity;
|
||||||
using Hutopy.Infrastructure.Utils;
|
using Hutopy.Infrastructure.Utils;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
@@ -8,7 +8,9 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
|
|
||||||
namespace Hutopy.Web.Controllers;
|
namespace Hutopy.Web.Controllers;
|
||||||
|
|
||||||
public class FacebookController(IIdentityService identityService) : Controller
|
public class FacebookController(
|
||||||
|
IdentityService identityService)
|
||||||
|
: Controller
|
||||||
{
|
{
|
||||||
[Microsoft.AspNetCore.Mvc.HttpGet("/api/facebook/sign-in")]
|
[Microsoft.AspNetCore.Mvc.HttpGet("/api/facebook/sign-in")]
|
||||||
public async Task SignIn()
|
public async Task SignIn()
|
||||||
@@ -33,7 +35,7 @@ public class FacebookController(IIdentityService identityService) : Controller
|
|||||||
var claimsIdentity = new ClaimsIdentity(
|
var claimsIdentity = new ClaimsIdentity(
|
||||||
new List<Claim>
|
new List<Claim>
|
||||||
{
|
{
|
||||||
new(ClaimTypes.Name, name),
|
new(ClaimTypes.Name, name),
|
||||||
new(ClaimTypes.Email, email),
|
new(ClaimTypes.Email, email),
|
||||||
new(ClaimTypes.GivenName, givenName),
|
new(ClaimTypes.GivenName, givenName),
|
||||||
new(ClaimTypes.Surname, familyName)
|
new(ClaimTypes.Surname, familyName)
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Azure.Identity;
|
using Azure.Identity;
|
||||||
using Hutopy.Application.Common.Interfaces;
|
|
||||||
using Hutopy.Infrastructure.Data;
|
using Hutopy.Infrastructure.Data;
|
||||||
using Hutopy.Web.Infrastructure;
|
|
||||||
using Hutopy.Web.Services;
|
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authentication.Facebook;
|
using Microsoft.AspNetCore.Authentication.Facebook;
|
||||||
using Microsoft.AspNetCore.Authentication.Google;
|
using Microsoft.AspNetCore.Authentication.Google;
|
||||||
@@ -19,15 +16,11 @@ public static class DependencyInjection
|
|||||||
{
|
{
|
||||||
services.AddDatabaseDeveloperPageExceptionFilter();
|
services.AddDatabaseDeveloperPageExceptionFilter();
|
||||||
|
|
||||||
services.AddScoped<IUser, CurrentUser>();
|
|
||||||
|
|
||||||
services.AddHttpContextAccessor();
|
services.AddHttpContextAccessor();
|
||||||
|
|
||||||
services.AddHealthChecks()
|
services.AddHealthChecks()
|
||||||
.AddDbContextCheck<ApplicationDbContext>();
|
.AddDbContextCheck<ApplicationDbContext>();
|
||||||
|
|
||||||
services.AddExceptionHandler<CustomExceptionHandler>();
|
|
||||||
|
|
||||||
services.AddRazorPages();
|
services.AddRazorPages();
|
||||||
|
|
||||||
services.AddHttpClient();
|
services.AddHttpClient();
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
using Hutopy.Application.Common.Models;
|
|
||||||
using Hutopy.Application.FutureCreators.Commands;
|
|
||||||
using Hutopy.Application.FutureCreators.Queries;
|
|
||||||
using Hutopy.Web.Infrastructure;
|
|
||||||
|
|
||||||
namespace Hutopy.Web.Endpoints;
|
|
||||||
|
|
||||||
public class JoinUs : EndpointGroupBase
|
|
||||||
{
|
|
||||||
public override void Map(WebApplication app)
|
|
||||||
{
|
|
||||||
app.MapGroup(this)
|
|
||||||
.MapGet(GetFutureCreators)
|
|
||||||
.MapPost(CreateFutureCreator);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Task<Guid> CreateFutureCreator(ISender sender, CreateFutureCreatorCommand command)
|
|
||||||
{
|
|
||||||
return sender.Send(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Task<PaginatedList<FutureCreatorListDto>> GetFutureCreators(ISender sender, [AsParameters] GetFutureCreatorListQuery query)
|
|
||||||
{
|
|
||||||
return sender.Send(query);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using Hutopy.Application.Stripe.Commands;
|
|
||||||
using Hutopy.Application.Stripe.Queries;
|
|
||||||
using Hutopy.Web.Infrastructure;
|
|
||||||
|
|
||||||
namespace Hutopy.Web.Endpoints;
|
|
||||||
|
|
||||||
public class Stripe : EndpointGroupBase
|
|
||||||
{
|
|
||||||
public override void Map(WebApplication app)
|
|
||||||
{
|
|
||||||
app.MapGroup(this)
|
|
||||||
.MapPost(ConfirmTransaction, "/confirmTransaction")
|
|
||||||
.MapGet(GetMyLastReceipt, "/getMyLastReceipt")
|
|
||||||
.MapPost(CreateSessionCheckout);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Task<string> CreateSessionCheckout(ISender sender, CreateSessionCheckoutCommand command)
|
|
||||||
{
|
|
||||||
return sender.Send(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async static Task<string> ConfirmTransaction(ISender sender, ConfirmStripeTransactionCommand command)
|
|
||||||
{
|
|
||||||
return await sender.Send(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<MyLastReceiptDto> GetMyLastReceipt(ISender sender, [AsParameters] GetMyLastReceiptQuery query)
|
|
||||||
{
|
|
||||||
return await sender.Send(query);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using Hutopy.Application.Users.Commands;
|
|
||||||
using Hutopy.Web.Infrastructure;
|
|
||||||
|
|
||||||
namespace Hutopy.Web.Endpoints;
|
|
||||||
|
|
||||||
public class UpdateMyUser : EndpointGroupBase
|
|
||||||
{
|
|
||||||
public override void Map(WebApplication app)
|
|
||||||
{
|
|
||||||
app.MapGroup(this)
|
|
||||||
.RequireAuthorization()
|
|
||||||
.MapPatch("/profile", UpdateCurrentUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> UpdateCurrentUser(ISender sender, UpdateCurrentUserCommand command)
|
|
||||||
{
|
|
||||||
return await sender.Send(command);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
using Hutopy.Application.Users.Commands;
|
|
||||||
using Hutopy.Application.Users.Queries.GetUser;
|
|
||||||
using Hutopy.Web.Infrastructure;
|
|
||||||
|
|
||||||
namespace Hutopy.Web.Endpoints;
|
|
||||||
|
|
||||||
public class Users : EndpointGroupBase
|
|
||||||
{
|
|
||||||
public override void Map(WebApplication app)
|
|
||||||
{
|
|
||||||
app.MapGroup(this)
|
|
||||||
.MapPost(CreateUser)
|
|
||||||
.MapPost(Login, "/login")
|
|
||||||
.MapGet(GetUserById, "/id")
|
|
||||||
.MapGet(GetUserByUserName, "/user-name");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> CreateUser(ISender sender, CreateUserCommand command)
|
|
||||||
{
|
|
||||||
return await sender.Send(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<UserDto> GetUserById(ISender sender,
|
|
||||||
[AsParameters] GetUserByIdQuery query)
|
|
||||||
{
|
|
||||||
return await sender.Send(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<UserDto> GetUserByUserName(ISender sender,
|
|
||||||
[AsParameters] GetUserByUserNameQuery query)
|
|
||||||
{
|
|
||||||
return await sender.Send(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<LoginResponse> Login(ISender sender, LoginCommand command)
|
|
||||||
{
|
|
||||||
return await sender.Send(command);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,11 +10,11 @@ public class ContentDbContext(
|
|||||||
|
|
||||||
public DbSet<Content> Contents => Set<Content>();
|
public DbSet<Content> Contents => Set<Content>();
|
||||||
public DbSet<Creator> Creators => Set<Creator>();
|
public DbSet<Creator> Creators => Set<Creator>();
|
||||||
public DbSet<Subscription> Subscriptions => Set<Subscription>();
|
public DbSet<Follower> Followers => Set<Follower>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.HasDefaultSchema("Content");
|
modelBuilder.HasDefaultSchema(SchemaName);
|
||||||
|
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.Entity<Content>()
|
.Entity<Content>()
|
||||||
@@ -34,13 +34,13 @@ public class ContentDbContext(
|
|||||||
.ToTable(nameof(ContentReaction).Pluralize());
|
.ToTable(nameof(ContentReaction).Pluralize());
|
||||||
|
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.Entity<Subscription>()
|
.Entity<Follower>()
|
||||||
.HasOne(c => c.Creator)
|
.HasOne(c => c.Creator)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(c => c.CreatorId);
|
.HasForeignKey(c => c.CreatorId);
|
||||||
|
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.Entity<Subscription>()
|
.Entity<Follower>()
|
||||||
.HasKey(s => new { s.CreatedBy, s.CreatorId });
|
.HasKey(s => new { s.CreatedBy, s.CreatorId });
|
||||||
|
|
||||||
modelBuilder
|
modelBuilder
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace Hutopy.Web.Features.Contents.Data;
|
namespace Hutopy.Web.Features.Contents.Data;
|
||||||
|
|
||||||
public class Subscription
|
public class Follower
|
||||||
{
|
{
|
||||||
public Guid CreatedBy { get; init; }
|
public Guid CreatedBy { get; init; }
|
||||||
public DateTimeOffset CreatedAt { get; init; }
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
using Hutopy.Application.AzureBlobStorage.Constants;
|
using Hutopy.Application.AzureBlobStorage.Constants;
|
||||||
using Hutopy.Application.Common.Interfaces;
|
using Hutopy.Infrastructure.AzureBlob;
|
||||||
using Hutopy.Web.Features.Contents.Data;
|
using Hutopy.Web.Features.Contents.Data;
|
||||||
|
|
||||||
namespace Hutopy.Web.Features.Contents.Handlers;
|
namespace Hutopy.Web.Features.Contents.Handlers;
|
||||||
@@ -16,7 +16,7 @@ public record ChangeBannerResponse(
|
|||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public class ChangeBannerHandler(
|
public class ChangeBannerHandler(
|
||||||
ContentDbContext context,
|
ContentDbContext context,
|
||||||
IBlobStorage blobStorage)
|
AzureBlobStorage blobStorage)
|
||||||
: Endpoint<ChangeBannerRequest, ChangeBannerResponse>
|
: Endpoint<ChangeBannerRequest, ChangeBannerResponse>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using Hutopy.Application.AzureBlobStorage.Constants;
|
using Hutopy.Application.AzureBlobStorage.Constants;
|
||||||
using Hutopy.Application.Common.Interfaces;
|
using Hutopy.Infrastructure.AzureBlob;
|
||||||
using Hutopy.Web.Features.Contents.Data;
|
using Hutopy.Web.Features.Contents.Data;
|
||||||
|
|
||||||
namespace Hutopy.Web.Features.Contents.Handlers;
|
namespace Hutopy.Web.Features.Contents.Handlers;
|
||||||
@@ -27,7 +27,7 @@ public sealed class ChangeLogoRequestValidator : Validator<ChangeLogoRequest>
|
|||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public class ChangeLogoHandler(
|
public class ChangeLogoHandler(
|
||||||
ContentDbContext context,
|
ContentDbContext context,
|
||||||
IBlobStorage blobStorage)
|
AzureBlobStorage blobStorage)
|
||||||
: Endpoint<ChangeLogoRequest>
|
: Endpoint<ChangeLogoRequest>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using Hutopy.Application.AzureBlobStorage.Constants;
|
using Hutopy.Application.AzureBlobStorage.Constants;
|
||||||
using Hutopy.Application.Common.Interfaces;
|
using Hutopy.Infrastructure.AzureBlob;
|
||||||
using Hutopy.Web.Common;
|
using Hutopy.Web.Common;
|
||||||
using Hutopy.Web.Features.Contents.Data;
|
using Hutopy.Web.Features.Contents.Data;
|
||||||
using Hutopy.Web.Features.Contents.Handlers.Models;
|
using Hutopy.Web.Features.Contents.Handlers.Models;
|
||||||
@@ -44,7 +44,7 @@ public sealed class PostContentRequestValidator : Validator<PostContentRequest>
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed class PostContent(
|
public sealed class PostContent(
|
||||||
IBlobStorage blobStorage,
|
AzureBlobStorage blobStorage,
|
||||||
ContentDbContext context)
|
ContentDbContext context)
|
||||||
: Endpoint<PostContentRequest>
|
: Endpoint<PostContentRequest>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,40 +5,40 @@ using Hutopy.Web.Features.Contents.Handlers.Models;
|
|||||||
namespace Hutopy.Web.Features.Contents.Handlers;
|
namespace Hutopy.Web.Features.Contents.Handlers;
|
||||||
|
|
||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public sealed class SubscribeToCreatorRequest
|
public sealed class FollowCreatorRequest
|
||||||
{
|
{
|
||||||
public Guid CreatorId { get; set; }
|
public Guid CreatorId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public sealed class SubscribeToCreatorHandler(
|
public sealed class FollowCreatorHandler(
|
||||||
ContentDbContext context)
|
ContentDbContext context)
|
||||||
: Endpoint<SubscribeToCreatorRequest, SubscriptionModel>
|
: Endpoint<FollowCreatorRequest, FollowModel>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Post("/api/creators/{CreatorId}/subscribe");
|
Post("/api/creators/{CreatorId}/follow");
|
||||||
Options((o => o.WithTags("Subscriptions")));
|
Options((o => o.WithTags("creators")));
|
||||||
Description(x => x.Accepts<string>("*/*"));
|
Description(x => x.Accepts<string>("*/*"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(
|
public override async Task HandleAsync(
|
||||||
SubscribeToCreatorRequest req,
|
FollowCreatorRequest req,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
await context.Subscriptions.AddAsync(
|
await context.Followers.AddAsync(
|
||||||
new() { CreatedBy = HttpContext.User.GetUserId(), CreatorId = req.CreatorId },
|
new Follower { CreatedBy = User.GetUserId(), CreatorId = req.CreatorId },
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
await context.SaveChangesAsync(ct);
|
await context.SaveChangesAsync(ct);
|
||||||
|
|
||||||
var creator = await context
|
var creator = await context
|
||||||
.Creators
|
.Creators
|
||||||
.Where(c => c.Id == req.CreatorId)
|
.Where(creator => creator.Id == req.CreatorId)
|
||||||
.Select(c => new SubscriptionModel(
|
.Select(creator => new FollowModel(
|
||||||
req.CreatorId,
|
req.CreatorId,
|
||||||
c.Name,
|
creator.Name,
|
||||||
c.Images.Logo
|
creator.Images.Logo
|
||||||
))
|
))
|
||||||
.FirstOrDefaultAsync(cancellationToken: ct);
|
.FirstOrDefaultAsync(cancellationToken: ct);
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ public class GetCreatorByAliasHandler(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var subscriberCount = await context.Subscriptions.CountAsync(
|
var followerCount = await context.Followers.CountAsync(
|
||||||
s => s.CreatorId == creator.Id,
|
s => s.CreatorId == creator.Id,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ public class GetCreatorByAliasHandler(
|
|||||||
creator.Socials,
|
creator.Socials,
|
||||||
creator.Colors,
|
creator.Colors,
|
||||||
creator.Images,
|
creator.Images,
|
||||||
subscriberCount);
|
followerCount);
|
||||||
|
|
||||||
await SendAsync(model, cancellation: ct);
|
await SendAsync(model, cancellation: ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
using Hutopy.Web.Common;
|
|
||||||
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 GetFollowedContentsRequest
|
|
||||||
{
|
|
||||||
[BindFrom("page_size")] public int PageSize { get; set; } = 10;
|
|
||||||
[BindFrom("last_id")] public Guid? LastId { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[PublicAPI]
|
|
||||||
public class GetFollowedContentsHandler(
|
|
||||||
ContentDbContext context)
|
|
||||||
: Endpoint<GetFollowedContentsRequest, List<ContentModel>>
|
|
||||||
{
|
|
||||||
public override void Configure()
|
|
||||||
{
|
|
||||||
Get("/api/contents/followed");
|
|
||||||
Options(o => o.WithTags("Contents"));
|
|
||||||
AllowAnonymous();
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task HandleAsync(
|
|
||||||
GetFollowedContentsRequest req,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
|
|
||||||
var userId = HttpContext.User.GetUserId();
|
|
||||||
|
|
||||||
var userSubscriptionIds = await context
|
|
||||||
.Subscriptions
|
|
||||||
.Where(s => s.CreatedBy == userId)
|
|
||||||
.Select(s => s.CreatorId)
|
|
||||||
.ToListAsync(cancellationToken: ct);
|
|
||||||
|
|
||||||
var query = context.Contents
|
|
||||||
.Where(c => c.DeletedAt == null)
|
|
||||||
.Where(x => userSubscriptionIds.Contains(x.CreatedBy));
|
|
||||||
if (req.LastId.HasValue)
|
|
||||||
{
|
|
||||||
query = query.Where(c => c.Id > req.LastId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
query = query.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,
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,14 +5,14 @@ using Hutopy.Web.Features.Contents.Handlers.Models;
|
|||||||
namespace Hutopy.Web.Features.Contents.Handlers;
|
namespace Hutopy.Web.Features.Contents.Handlers;
|
||||||
|
|
||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public class GetSubscriptionsHandler(
|
public class GetFollowedCreatorsHandler(
|
||||||
ContentDbContext context)
|
ContentDbContext context)
|
||||||
: EndpointWithoutRequest<List<SubscriptionModel>>
|
: EndpointWithoutRequest<List<FollowModel>>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Get("/api/subscriptions");
|
Get("/api/creators/followed");
|
||||||
Options((o => o.WithTags("Subscriptions")));
|
Options((o => o.WithTags("Creators")));
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(
|
public override async Task HandleAsync(
|
||||||
@@ -21,9 +21,9 @@ public class GetSubscriptionsHandler(
|
|||||||
var userId = HttpContext.User.GetUserId();
|
var userId = HttpContext.User.GetUserId();
|
||||||
|
|
||||||
var subscriptions = await context
|
var subscriptions = await context
|
||||||
.Subscriptions
|
.Followers
|
||||||
.Where(s => s.CreatedBy == userId)
|
.Where(s => s.CreatedBy == userId)
|
||||||
.Select(s => new SubscriptionModel(
|
.Select(s => new FollowModel(
|
||||||
s.CreatorId,
|
s.CreatorId,
|
||||||
s.Creator!.Name,
|
s.Creator!.Name,
|
||||||
s.Creator.Images.Logo))
|
s.Creator.Images.Logo))
|
||||||
@@ -12,4 +12,4 @@ public record struct CreatorModel(
|
|||||||
Socials Socials,
|
Socials Socials,
|
||||||
Colors Colors,
|
Colors Colors,
|
||||||
Images Images,
|
Images Images,
|
||||||
int SubscriberCount);
|
int FollowerCount);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace Hutopy.Web.Features.Contents.Handlers.Models;
|
namespace Hutopy.Web.Features.Contents.Handlers.Models;
|
||||||
|
|
||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public record SubscriptionModel(
|
public record FollowModel(
|
||||||
Guid CreatorId,
|
Guid CreatorId,
|
||||||
string CreatorName,
|
string CreatorName,
|
||||||
string? CreatorPortraitUrl);
|
string? CreatorPortraitUrl);
|
||||||
@@ -16,8 +16,8 @@ public class UnsubscribeFromCreatorHandler(
|
|||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Post("/api/creators/{CreatorId}/unsubscribe");
|
Post("/api/creators/{CreatorId}/unfollow");
|
||||||
Options((o => o.WithTags("Subscriptions")));
|
Options((o => o.WithTags("Creators")));
|
||||||
Description(x => x.Accepts<string>("*/*"));
|
Description(x => x.Accepts<string>("*/*"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,14 +25,15 @@ public class UnsubscribeFromCreatorHandler(
|
|||||||
UnsubscribeFromCreatorRequest req,
|
UnsubscribeFromCreatorRequest req,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var subscription = new Subscription { CreatorId = req.CreatorId, CreatedBy = HttpContext.User.GetUserId() };
|
|
||||||
|
|
||||||
context.Subscriptions.Attach(subscription);
|
|
||||||
context.Subscriptions.Remove(subscription);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var subscription = new Follower { CreatorId = req.CreatorId, CreatedBy = HttpContext.User.GetUserId() };
|
||||||
|
|
||||||
|
context.Followers.Attach(subscription);
|
||||||
|
context.Followers.Remove(subscription);
|
||||||
|
|
||||||
await context.SaveChangesAsync(ct);
|
await context.SaveChangesAsync(ct);
|
||||||
|
|
||||||
await SendOkAsync(ct);
|
await SendOkAsync(ct);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
310
src/Web/Features/Contents/Migrations/20241011103653_FromSubscribersToFollowers.Designer.cs
generated
Normal file
310
src/Web/Features/Contents/Migrations/20241011103653_FromSubscribersToFollowers.Designer.cs
generated
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
// <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.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ContentDbContext))]
|
||||||
|
[Migration("20241011103653_FromSubscribersToFollowers")]
|
||||||
|
partial class FromSubscribersToFollowers
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("Content")
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.4")
|
||||||
|
.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>("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.Follower", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("CreatedBy")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatorId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("CreatedBy", "CreatorId");
|
||||||
|
|
||||||
|
b.HasIndex("CreatorId");
|
||||||
|
|
||||||
|
b.ToTable("Followers", "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("ContentReactions", "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();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Follower", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Creator");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Contents.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FromSubscribersToFollowers : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Subscriptions",
|
||||||
|
schema: "Content");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Followers",
|
||||||
|
schema: "Content",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Followers", x => new { x.CreatedBy, x.CreatorId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Followers_Creators_CreatorId",
|
||||||
|
column: x => x.CreatorId,
|
||||||
|
principalSchema: "Content",
|
||||||
|
principalTable: "Creators",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Followers_CreatorId",
|
||||||
|
schema: "Content",
|
||||||
|
table: "Followers",
|
||||||
|
column: "CreatorId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Followers",
|
||||||
|
schema: "Content");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Subscriptions",
|
||||||
|
schema: "Content",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Subscriptions", x => new { x.CreatedBy, x.CreatorId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Subscriptions_Creators_CreatorId",
|
||||||
|
column: x => x.CreatorId,
|
||||||
|
principalSchema: "Content",
|
||||||
|
principalTable: "Creators",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Subscriptions_CreatorId",
|
||||||
|
schema: "Content",
|
||||||
|
table: "Subscriptions",
|
||||||
|
column: "CreatorId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,7 +92,7 @@ namespace Hutopy.Web.Features.Contents.Migrations
|
|||||||
b.ToTable("Creators", "Content");
|
b.ToTable("Creators", "Content");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Subscription", b =>
|
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Follower", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("CreatedBy")
|
b.Property<Guid>("CreatedBy")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
@@ -107,7 +107,7 @@ namespace Hutopy.Web.Features.Contents.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CreatorId");
|
b.HasIndex("CreatorId");
|
||||||
|
|
||||||
b.ToTable("Subscriptions", "Content");
|
b.ToTable("Followers", "Content");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
|
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
|
||||||
@@ -294,7 +294,7 @@ namespace Hutopy.Web.Features.Contents.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Subscription", b =>
|
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Follower", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
|
b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
|
|||||||
8
src/Web/Features/Memberships/Data/Creator.cs
Normal file
8
src/Web/Features/Memberships/Data/Creator.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Hutopy.Web.Features.Memberships.Data;
|
||||||
|
|
||||||
|
public class Creator
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string StripeAccountId { get; set; }
|
||||||
|
}
|
||||||
58
src/Web/Features/Memberships/Data/MembershipDbContext.cs
Normal file
58
src/Web/Features/Memberships/Data/MembershipDbContext.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/Web/Features/Memberships/Data/Subscription.cs
Normal file
18
src/Web/Features/Memberships/Data/Subscription.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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 > DateTime.UtcNow;
|
||||||
|
public string? StripeSessionId { get; set; }
|
||||||
|
public string? StripeSubscriptionId { get; set; }
|
||||||
|
|
||||||
|
}
|
||||||
14
src/Web/Features/Memberships/Data/Tier.cs
Normal file
14
src/Web/Features/Memberships/Data/Tier.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
public string CurrencyCode { get; set; }
|
||||||
|
|
||||||
|
public ICollection<Subscription> Subscriptions { get; set; }
|
||||||
|
}
|
||||||
15
src/Web/Features/Memberships/Data/Tip.cs
Normal file
15
src/Web/Features/Memberships/Data/Tip.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
11
src/Web/Features/Memberships/Data/Transaction.cs
Normal file
11
src/Web/Features/Memberships/Data/Transaction.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Hutopy.Web.Features.Memberships.Data;
|
||||||
|
|
||||||
|
public class Transaction
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
public string StripeCheckoutSessionId { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string Type { get; set; } // Subscription, Tip
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
}
|
||||||
27
src/Web/Features/Memberships/DependencyInjection.cs
Normal file
27
src/Web/Features/Memberships/DependencyInjection.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using Hutopy.Web.Features.Memberships.Data;
|
||||||
|
using Hutopy.Web.Features.Memberships.Services;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/Web/Features/Memberships/Events/SubscriptionPaid.cs
Normal file
7
src/Web/Features/Memberships/Events/SubscriptionPaid.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Hutopy.Web.Features.Memberships.Events;
|
||||||
|
|
||||||
|
public record struct SubscriptionPaid(
|
||||||
|
Guid CreatorId,
|
||||||
|
string CreatorName,
|
||||||
|
string Tier,
|
||||||
|
DateTimeOffset Since);
|
||||||
8
src/Web/Features/Memberships/Events/TipPaid.cs
Normal file
8
src/Web/Features/Memberships/Events/TipPaid.cs
Normal 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);
|
||||||
48
src/Web/Features/Memberships/Handlers/CancelSubscription.cs
Normal file
48
src/Web/Features/Memberships/Handlers/CancelSubscription.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using Hutopy.Web.Features.Memberships.Data;
|
||||||
|
using Hutopy.Web.Features.Memberships.Services;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Memberships.Handlers;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class CancelSubscriptionRequest
|
||||||
|
{
|
||||||
|
public Guid SubscriptionId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CancelSubscriptionHandler(
|
||||||
|
MembershipDbContext dbDbContext,
|
||||||
|
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 dbDbContext
|
||||||
|
.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 dbDbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await SendOkAsync(subscription.Id, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Hutopy.Web.Features.Memberships.Data;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Memberships.Handlers;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class CreateMembershipTierRequest
|
||||||
|
{
|
||||||
|
public Guid CreatorId { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class CreateMembershipTierEndpoint(
|
||||||
|
MembershipDbContext dbDbContext)
|
||||||
|
: 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 tier = dbDbContext
|
||||||
|
.Tiers
|
||||||
|
.Add(new Tier { CreatorId = req.CreatorId, Price = req.Price, Name = req.Name });
|
||||||
|
|
||||||
|
await dbDbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await SendOkAsync(tier, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Hutopy.Web.Common;
|
||||||
|
using Hutopy.Web.Features.Memberships.Data;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Memberships.Handlers;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class GetActiveSubscriptionsRequest;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class GetActiveSubscriptionsHandler(
|
||||||
|
MembershipDbContext dbDbContext)
|
||||||
|
: Endpoint<GetActiveSubscriptionsRequest>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/api/membership/active");
|
||||||
|
Options(o => o.WithTags("Memberships"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(
|
||||||
|
GetActiveSubscriptionsRequest req,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var subscriptions = await dbDbContext
|
||||||
|
.Subscriptions
|
||||||
|
.Where(subscription => subscription.UserId == User.GetUserId())
|
||||||
|
.Where(subscription => subscription.IsActive)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
await SendOkAsync(subscriptions, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/Web/Features/Memberships/Handlers/GetMembershipTier.cs
Normal file
47
src/Web/Features/Memberships/Handlers/GetMembershipTier.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using Hutopy.Web.Features.Memberships.Data;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Memberships.Handlers;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class GetMembershipTierRequest
|
||||||
|
{
|
||||||
|
public Guid CreatorId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public record struct TierModel(
|
||||||
|
Guid Id,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
string Name,
|
||||||
|
decimal Price,
|
||||||
|
string CurrencyCode);
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class GetMembershipTierEndpoint(
|
||||||
|
MembershipDbContext dbDbContext)
|
||||||
|
: Endpoint<CreateMembershipTierRequest, List<TierModel>>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/api/membership/tiers");
|
||||||
|
Options(o => o.WithTags("Memberships"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(
|
||||||
|
CreateMembershipTierRequest req,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var tiers = await dbDbContext
|
||||||
|
.Tiers
|
||||||
|
.Where(tier => tier.CreatorId == req.CreatorId)
|
||||||
|
.Select(tier => new TierModel(
|
||||||
|
tier.Id,
|
||||||
|
tier.CreatedAt,
|
||||||
|
tier.Name,
|
||||||
|
tier.Price,
|
||||||
|
tier.CurrencyCode))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
await SendOkAsync(tiers, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/Web/Features/Memberships/Handlers/GetReceivedTips.cs
Normal file
45
src/Web/Features/Memberships/Handlers/GetReceivedTips.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using Hutopy.Web.Common;
|
||||||
|
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 dbDbContext)
|
||||||
|
: 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 dbDbContext
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/Web/Features/Memberships/Handlers/GetSentTips.cs
Normal file
45
src/Web/Features/Memberships/Handlers/GetSentTips.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using Hutopy.Web.Common;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/Web/Features/Memberships/Handlers/HandleStripe.cs
Normal file
71
src/Web/Features/Memberships/Handlers/HandleStripe.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using Hutopy.Web.Features.Memberships.Data;
|
||||||
|
using Hutopy.Web.Features.Memberships.Services;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Memberships.Handlers;
|
||||||
|
|
||||||
|
public static class StripeEvents
|
||||||
|
{
|
||||||
|
public const string SubscriptionCreated = "subscription_created";
|
||||||
|
public const string CustomerSubscriptionDeleted = "customer.subscription_deleted";
|
||||||
|
public const string InvoicePaymentSucceeded = "invoice.payment_succeeded";
|
||||||
|
public const string InvoicePaymentFailed = "invoice.payment_failed";
|
||||||
|
public const string CheckoutSessionCompleted = "checkout.session.completed";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StripeWebhookEndpoint(
|
||||||
|
MembershipDbContext dbContext,
|
||||||
|
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 StripeEvents.InvoicePaymentSucceeded:
|
||||||
|
await stripeService.HandlePaymentSucceeded(stripeEvent, ct);
|
||||||
|
break;
|
||||||
|
case StripeEvents.InvoicePaymentFailed:
|
||||||
|
await stripeService.HandlePaymentFailed(stripeEvent, ct);
|
||||||
|
break;
|
||||||
|
case StripeEvents.CheckoutSessionCompleted:
|
||||||
|
await stripeService.HandleCheckoutSessionCompleted(stripeEvent, ct);
|
||||||
|
break;
|
||||||
|
case StripeEvents.CustomerSubscriptionDeleted:
|
||||||
|
{
|
||||||
|
var subscription = stripeEvent.Data.Object as Stripe.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendOkAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/Web/Features/Memberships/Handlers/SendTip.cs
Normal file
117
src/Web/Features/Memberships/Handlers/SendTip.cs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
using Hutopy.Web.Common;
|
||||||
|
using Hutopy.Web.Features.Memberships.Data;
|
||||||
|
using Hutopy.Web.Features.Memberships.Services;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Memberships.Handlers;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public record SendTipRequest
|
||||||
|
{
|
||||||
|
public Guid CreatorId { get; set; }
|
||||||
|
public required decimal Amount { get; init; }
|
||||||
|
public required string Currency { get; init; }
|
||||||
|
public required string Message { get; init; }
|
||||||
|
public required string CheckoutSuccessUrl { get; init; }
|
||||||
|
public required string CheckoutCancelledUrl { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class SendTipResponse
|
||||||
|
{
|
||||||
|
public required string Status { get; init; }
|
||||||
|
public required string StripeCheckoutUrl { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 dbDbContext,
|
||||||
|
StripeService stripeService)
|
||||||
|
: Endpoint<SendTipRequest, SendTipResponse>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/tips/{CreatorId}");
|
||||||
|
Options(o => o.WithTags("Memberships"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(
|
||||||
|
SendTipRequest req,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = User.GetUserId();
|
||||||
|
var userName = User.GetName();
|
||||||
|
|
||||||
|
var creator = await dbDbContext.Creators.FindAsync(
|
||||||
|
[req.CreatorId],
|
||||||
|
cancellationToken: ct);
|
||||||
|
if (creator == null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkoutSession = await stripeService.CreateTipCheckoutSession(
|
||||||
|
userId,
|
||||||
|
req.Amount,
|
||||||
|
req.Currency,
|
||||||
|
creator.Id,
|
||||||
|
creator.Name,
|
||||||
|
creator.StripeAccountId,
|
||||||
|
req.CheckoutSuccessUrl,
|
||||||
|
req.CheckoutCancelledUrl);
|
||||||
|
|
||||||
|
dbDbContext.Tips.Add(
|
||||||
|
new Tip
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
TipperId = userId,
|
||||||
|
TipperName = userName,
|
||||||
|
CreatorId = creator.Id,
|
||||||
|
CreatorName = creator.Name,
|
||||||
|
Amount = req.Amount,
|
||||||
|
Currency = req.Currency,
|
||||||
|
Message = req.Message,
|
||||||
|
StripeSessionId = checkoutSession.Id
|
||||||
|
});
|
||||||
|
|
||||||
|
dbDbContext.Transactions.Add(
|
||||||
|
new Transaction
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
StripeCheckoutSessionId = checkoutSession.Id,
|
||||||
|
Amount = req.Amount,
|
||||||
|
Type = "Tip",
|
||||||
|
Timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
await dbDbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await SendAsync(
|
||||||
|
new SendTipResponse { Status = "Pending", StripeCheckoutUrl = checkoutSession.Url },
|
||||||
|
cancellation: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/Web/Features/Memberships/Handlers/SubscribeToCreator.cs
Normal file
110
src/Web/Features/Memberships/Handlers/SubscribeToCreator.cs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
using Hutopy.Web.Common;
|
||||||
|
using Hutopy.Web.Features.Memberships.Data;
|
||||||
|
using Hutopy.Web.Features.Memberships.Services;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Memberships.Handlers;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class SubscribeRequest
|
||||||
|
{
|
||||||
|
public Guid CreatorId { get; set; }
|
||||||
|
public Guid TierId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public record struct SubscriptionResponse(
|
||||||
|
Guid SubscriptionId,
|
||||||
|
Guid CreatorId,
|
||||||
|
Guid UserId,
|
||||||
|
bool IsActive,
|
||||||
|
string Tier,
|
||||||
|
DateTimeOffset StartDate,
|
||||||
|
DateTimeOffset? EndDate);
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class SubscribeValidator : Validator<SubscribeRequest>
|
||||||
|
{
|
||||||
|
public SubscribeValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.TierId).NotEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class SubscribeHandler(
|
||||||
|
MembershipDbContext dbDbContext,
|
||||||
|
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 dbDbContext
|
||||||
|
.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 stripeSubscription = await stripeService.CreateSubscriptionCheckoutSession(
|
||||||
|
User.GetUserId(),
|
||||||
|
tier.Price,
|
||||||
|
tier.CurrencyCode,
|
||||||
|
$"{tier.Name} from {tier.Creator.Name}",
|
||||||
|
tier.Creator.StripeAccountId,
|
||||||
|
"",
|
||||||
|
"");
|
||||||
|
|
||||||
|
// Record subscription and transaction
|
||||||
|
var subscription = new Subscription
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
StripeSubscriptionId = stripeSubscription.Id,
|
||||||
|
CreatorId = tier.CreatorId,
|
||||||
|
UserId = User.GetUserId(),
|
||||||
|
Tier = tier,
|
||||||
|
StartDate = DateTimeOffset.Now,
|
||||||
|
EndDate = DateTimeOffset.Now.AddMonths(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
dbDbContext.Subscriptions.Add(subscription);
|
||||||
|
|
||||||
|
dbDbContext.Transactions.Add(
|
||||||
|
new Transaction
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
StripeCheckoutSessionId = stripeSubscription.Id,
|
||||||
|
Amount = tier.Price,
|
||||||
|
Type = "Subscription",
|
||||||
|
Timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
await dbDbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await SendOkAsync(
|
||||||
|
new SubscriptionResponse
|
||||||
|
{
|
||||||
|
UserId = subscription.UserId,
|
||||||
|
CreatorId = subscription.CreatorId,
|
||||||
|
SubscriptionId = subscription.Id,
|
||||||
|
IsActive = subscription.IsActive,
|
||||||
|
StartDate = subscription.StartDate,
|
||||||
|
EndDate = subscription.EndDate,
|
||||||
|
Tier = tier.Name,
|
||||||
|
},
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
233
src/Web/Features/Memberships/Migrations/20241011100852_Initial.Designer.cs
generated
Normal file
233
src/Web/Features/Memberships/Migrations/20241011100852_Initial.Designer.cs
generated
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
// <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.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(MembershipDbContext))]
|
||||||
|
[Migration("20241011100852_Initial")]
|
||||||
|
partial class Initial
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("Membership")
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.4")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hutopy.Web.Features.Membership.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.Membership.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")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("StripeSubscriptionId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
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.Membership.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()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<decimal>("Price")
|
||||||
|
.HasColumnType("numeric");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatorId");
|
||||||
|
|
||||||
|
b.ToTable("Tiers", "Membership");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hutopy.Web.Features.Membership.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.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Tips", "Membership");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hutopy.Web.Features.Membership.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>("StripeCheckoutSessionId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Timestamp")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Transactions", "Membership");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Subscription", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hutopy.Web.Features.Membership.Data.Creator", "Creator")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Hutopy.Web.Features.Membership.Data.Tier", "Tier")
|
||||||
|
.WithMany("Subscriptions")
|
||||||
|
.HasForeignKey("TierId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Creator");
|
||||||
|
|
||||||
|
b.Navigation("Tier");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hutopy.Web.Features.Membership.Data.Creator", "Creator")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Creator");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Subscriptions");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Memberships.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: "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)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Tips", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
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"),
|
||||||
|
StripeCheckoutSessionId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Amount = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
Type = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Timestamp = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Transactions", 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: "text", nullable: false),
|
||||||
|
Price = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
CurrencyCode = table.Column<string>(type: "text", 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: "text", nullable: true),
|
||||||
|
StripeSubscriptionId = table.Column<string>(type: "text", 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.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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Subscriptions",
|
||||||
|
schema: "Membership");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Tips",
|
||||||
|
schema: "Membership");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Transactions",
|
||||||
|
schema: "Membership");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Tiers",
|
||||||
|
schema: "Membership");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Creators",
|
||||||
|
schema: "Membership");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// <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.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.4")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hutopy.Web.Features.Membership.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.Membership.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")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("StripeSubscriptionId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
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.Membership.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()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<decimal>("Price")
|
||||||
|
.HasColumnType("numeric");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatorId");
|
||||||
|
|
||||||
|
b.ToTable("Tiers", "Membership");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hutopy.Web.Features.Membership.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.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Tips", "Membership");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hutopy.Web.Features.Membership.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>("StripeCheckoutSessionId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Timestamp")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Transactions", "Membership");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Subscription", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hutopy.Web.Features.Membership.Data.Creator", "Creator")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Hutopy.Web.Features.Membership.Data.Tier", "Tier")
|
||||||
|
.WithMany("Subscriptions")
|
||||||
|
.HasForeignKey("TierId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Creator");
|
||||||
|
|
||||||
|
b.Navigation("Tier");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hutopy.Web.Features.Membership.Data.Creator", "Creator")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Creator");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Subscriptions");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Hutopy.Web.Features.Memberships.Services;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
224
src/Web/Features/Memberships/Services/StripeService.cs
Normal file
224
src/Web/Features/Memberships/Services/StripeService.cs
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Memberships.Services;
|
||||||
|
|
||||||
|
public class StripeOptions
|
||||||
|
{
|
||||||
|
[Required] public required string SecretKey { get; init; }
|
||||||
|
|
||||||
|
[Required] public required string WebhookSecret { get; init; }
|
||||||
|
|
||||||
|
[Range(0, 1)] public required decimal HutopyRate { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StripeService(
|
||||||
|
IOptions<StripeOptions> paymentOptions,
|
||||||
|
MembershipDbContext dbDbContext,
|
||||||
|
PushNotificationService notificationService)
|
||||||
|
{
|
||||||
|
public async Task<Session> CreateTipCheckoutSession(
|
||||||
|
Guid userId,
|
||||||
|
decimal amount,
|
||||||
|
string currencyCode,
|
||||||
|
Guid creatorId,
|
||||||
|
string creatorName,
|
||||||
|
string creatorAccountId,
|
||||||
|
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 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Session> CreateSubscriptionCheckoutSession(
|
||||||
|
Guid userId,
|
||||||
|
decimal amount,
|
||||||
|
string currencyCode,
|
||||||
|
string productName,
|
||||||
|
string creatorAccountId,
|
||||||
|
string successUrl,
|
||||||
|
string cancelUrl)
|
||||||
|
{
|
||||||
|
// 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
|
||||||
|
{
|
||||||
|
PriceData = new SessionLineItemPriceDataOptions
|
||||||
|
{
|
||||||
|
Currency = currencyCode,
|
||||||
|
Recurring = new SessionLineItemPriceDataRecurringOptions { Interval = "month" },
|
||||||
|
UnitAmountDecimal = amount, // Amount in cents
|
||||||
|
ProductData = new SessionLineItemPriceDataProductDataOptions { Name = productName }
|
||||||
|
},
|
||||||
|
Quantity = 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Mode = "subscription",
|
||||||
|
SubscriptionData = new SessionSubscriptionDataOptions
|
||||||
|
{
|
||||||
|
ApplicationFeePercent = paymentOptions.Value.HutopyRate, // Platform fee as a percentage
|
||||||
|
TransferData = new SessionSubscriptionDataTransferDataOptions
|
||||||
|
{
|
||||||
|
Destination = creatorAccountId // Creator's Stripe account ID
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SuccessUrl = successUrl, // Redirect after successful payment
|
||||||
|
CancelUrl = cancelUrl // Redirect after canceled payment
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CancelSubscription(
|
||||||
|
Guid subscriptionId)
|
||||||
|
{
|
||||||
|
var subscriptionService = new SubscriptionService();
|
||||||
|
await subscriptionService.CancelAsync(subscriptionId.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task HandlePaymentSucceeded(
|
||||||
|
Event stripeEvent,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var invoice = stripeEvent.Data.Object as Invoice;
|
||||||
|
var subscriptionId = invoice.SubscriptionId;
|
||||||
|
|
||||||
|
var subscription = await dbDbContext
|
||||||
|
.Subscriptions
|
||||||
|
.FirstOrDefaultAsync(x => x.StripeSubscriptionId == subscriptionId, ct);
|
||||||
|
|
||||||
|
if (subscription != null)
|
||||||
|
{
|
||||||
|
subscription.EndDate = null;
|
||||||
|
await dbDbContext.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task HandlePaymentFailed(
|
||||||
|
Event stripeEvent,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var invoice = stripeEvent.Data.Object as Invoice;
|
||||||
|
var subscriptionId = invoice!.SubscriptionId;
|
||||||
|
|
||||||
|
var subscription = await dbDbContext
|
||||||
|
.Subscriptions
|
||||||
|
.FirstOrDefaultAsync(x => x.StripeSubscriptionId == subscriptionId, ct);
|
||||||
|
|
||||||
|
if (subscription != null)
|
||||||
|
{
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var lastDay = DateTime.DaysInMonth(today.Year, today.Month);
|
||||||
|
var lastDayOfMonth = new DateTime(today.Year, today.Month, lastDay);
|
||||||
|
subscription.EndDate = lastDayOfMonth;
|
||||||
|
await dbDbContext.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task HandleCheckoutSessionCompleted(
|
||||||
|
Event stripeEvent,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var session = stripeEvent.Data.Object as Session;
|
||||||
|
var sessionId = session!.Id;
|
||||||
|
|
||||||
|
var tip = await dbDbContext
|
||||||
|
.Tips
|
||||||
|
.Where(tip => tip.StripeSessionId == sessionId)
|
||||||
|
.SingleOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (tip is not null)
|
||||||
|
{
|
||||||
|
notificationService.NotifyCreator(
|
||||||
|
tip.CreatorId,
|
||||||
|
new TipPaid(
|
||||||
|
tip.CreatorId,
|
||||||
|
tip.CreatorName,
|
||||||
|
tip.Amount,
|
||||||
|
tip.Currency,
|
||||||
|
tip.Message));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var subscription = await dbDbContext
|
||||||
|
.Subscriptions
|
||||||
|
.Where(subscription => subscription.StripeSessionId == sessionId)
|
||||||
|
.Include(subscription => subscription.Tier)
|
||||||
|
.Include(subscription => subscription.Creator)
|
||||||
|
.SingleOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (subscription is not null)
|
||||||
|
{
|
||||||
|
notificationService.NotifyCreator(
|
||||||
|
subscription.CreatorId,
|
||||||
|
new SubscriptionPaid(
|
||||||
|
subscription.CreatorId,
|
||||||
|
subscription.Creator.Name,
|
||||||
|
subscription.Tier.Name,
|
||||||
|
subscription.StartDate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
using Hutopy.Application.AzureBlobStorage.Constants;
|
using Hutopy.Application.AzureBlobStorage.Constants;
|
||||||
using Hutopy.Application.Common.Interfaces;
|
using Hutopy.Infrastructure.AzureBlob;
|
||||||
using Hutopy.Infrastructure.Identity;
|
using Hutopy.Infrastructure.Identity;
|
||||||
using Hutopy.Web.Common;
|
using Hutopy.Web.Common;
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ public sealed class ChangePortraitRequestValidator : Validator<ChangePortraitReq
|
|||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public class ChangePortraitHandler(
|
public class ChangePortraitHandler(
|
||||||
ApplicationUserManager userManager,
|
ApplicationUserManager userManager,
|
||||||
IBlobStorage blobStorage)
|
AzureBlobStorage blobStorage)
|
||||||
: Endpoint<ChangePortraitRequest, ChangePortraitResponse>
|
: Endpoint<ChangePortraitRequest, ChangePortraitResponse>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
using Hutopy.Application.Common.Interfaces;
|
using Hutopy.Infrastructure.Identity;
|
||||||
using Hutopy.Web.Features.Users.Handlers.Models;
|
using Hutopy.Web.Features.Users.Handlers.Models;
|
||||||
|
|
||||||
namespace Hutopy.Web.Features.Users.Handlers;
|
namespace Hutopy.Web.Features.Users.Handlers;
|
||||||
|
|
||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public class GetCurrentUserQueryHandler(
|
public class GetCurrentUserQueryHandler(
|
||||||
IIdentityService identityService
|
IdentityService identityService)
|
||||||
)
|
|
||||||
: EndpointWithoutRequest<UserDto>
|
: EndpointWithoutRequest<UserDto>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
using Hutopy.Application.AzureBlobStorage.Constants;
|
using Hutopy.Application.AzureBlobStorage.Constants;
|
||||||
using Hutopy.Application.Common.Interfaces;
|
using Hutopy.Infrastructure.AzureBlob;
|
||||||
|
using Hutopy.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace Hutopy.Web.Features.Users.Handlers;
|
namespace Hutopy.Web.Features.Users.Handlers;
|
||||||
|
|
||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public class GetCurrentUserPortraitHandler(
|
public class GetCurrentUserPortraitHandler(
|
||||||
IIdentityService identityService,
|
IdentityService identityService,
|
||||||
IBlobStorage blobStorage
|
AzureBlobStorage blobStorage
|
||||||
)
|
)
|
||||||
: EndpointWithoutRequest<Stream>
|
: EndpointWithoutRequest<Stream>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace Hutopy.Web.Features.Wallets;
|
|
||||||
|
|
||||||
public class UserTransactionDto
|
|
||||||
{
|
|
||||||
public required decimal Amount { get; init; }
|
|
||||||
|
|
||||||
public string Currency { get; init; } = "cad";
|
|
||||||
|
|
||||||
public string TipMessage { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public DateTimeOffset Created { get; init; }
|
|
||||||
|
|
||||||
public bool IsConfirmed { get; init; }
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user