diff --git a/Directory.Packages.props b/Directory.Packages.props index 438dfe2..ada3c11 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -46,7 +46,7 @@ - + \ No newline at end of file diff --git a/Hutopy.sln b/Hutopy.sln index cf13057..451a7bc 100644 --- a/Hutopy.sln +++ b/Hutopy.sln @@ -13,8 +13,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{6ED356A7-8B4 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{664D406C-2F83-48F0-BFC3-408D5CB53C65}" 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}" EndProject 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}.Release|Any CPU.ActiveCfg = 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.Build.0 = Debug|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} {34C0FACD-F3D9-400C-8945-554DD6B0819A} = {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} {4E4EE20C-F06A-4A1B-851F-C5577796941C} = {6ED356A7-8B47-4613-AD01-C85CF28491BD} {EA6127A5-94C9-4C31-AD11-E6811B92B520} = {664D406C-2F83-48F0-BFC3-408D5CB53C65} diff --git a/global.json b/global.json index 05cf0d8..b5b37b6 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "8.0.203", - "rollForward": "latestFeature", - "allowPrerelease": true + "version": "8.0.0", + "rollForward": "latestMajor", + "allowPrerelease": false } } \ No newline at end of file diff --git a/src/Application/Common/Behaviours/AuthorizationBehaviour.cs b/src/Application/Common/Behaviours/AuthorizationBehaviour.cs deleted file mode 100644 index 8012cce..0000000 --- a/src/Application/Common/Behaviours/AuthorizationBehaviour.cs +++ /dev/null @@ -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( - IUser user, - IIdentityService identityService) - : IPipelineBehavior - where TRequest : notnull -{ - public async Task Handle(TRequest request, RequestHandlerDelegate next, - CancellationToken cancellationToken) - { - var authorizeAttributes = request - .GetType() - .GetCustomAttributes() - .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(); - } -} diff --git a/src/Application/Common/Behaviours/LoggingBehaviour.cs b/src/Application/Common/Behaviours/LoggingBehaviour.cs deleted file mode 100644 index def9dca..0000000 --- a/src/Application/Common/Behaviours/LoggingBehaviour.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Hutopy.Application.Common.Interfaces; -using MediatR.Pipeline; -using Microsoft.Extensions.Logging; - -namespace Hutopy.Application.Common.Behaviours; - -public class LoggingBehaviour( - ILogger logger, - IUser user, - IIdentityService identityService) - : IRequestPreProcessor - 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); - } -} diff --git a/src/Application/Common/Behaviours/PerformanceBehaviour.cs b/src/Application/Common/Behaviours/PerformanceBehaviour.cs deleted file mode 100644 index 20a82a2..0000000 --- a/src/Application/Common/Behaviours/PerformanceBehaviour.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Diagnostics; -using Hutopy.Application.Common.Interfaces; -using Microsoft.Extensions.Logging; - -namespace Hutopy.Application.Common.Behaviours; - -public class PerformanceBehaviour( - ILogger logger, - IUser user, - IIdentityService identityService) - : IPipelineBehavior - where TRequest : notnull -{ - private readonly Stopwatch _timer = new(); - - public async Task Handle(TRequest request, RequestHandlerDelegate 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; - } -} diff --git a/src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs b/src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs deleted file mode 100644 index 28d2a94..0000000 --- a/src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace Hutopy.Application.Common.Behaviours; - -public class UnhandledExceptionBehaviour( - ILogger logger) - : IPipelineBehavior - where TRequest : notnull -{ - public async Task Handle(TRequest request, RequestHandlerDelegate 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; - } - } -} diff --git a/src/Application/Common/Behaviours/ValidationBehaviour.cs b/src/Application/Common/Behaviours/ValidationBehaviour.cs deleted file mode 100644 index a564099..0000000 --- a/src/Application/Common/Behaviours/ValidationBehaviour.cs +++ /dev/null @@ -1,28 +0,0 @@ -using ValidationException = Hutopy.Application.Common.Exceptions.ValidationException; - -namespace Hutopy.Application.Common.Behaviours; - -public class ValidationBehaviour(IEnumerable> validators) - : IPipelineBehavior - where TRequest : notnull -{ - public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) - { - if (!validators.Any()) return await next(); - - var context = new ValidationContext(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(); - } -} diff --git a/src/Application/Common/Exceptions/ForbiddenAccessException.cs b/src/Application/Common/Exceptions/ForbiddenAccessException.cs deleted file mode 100644 index 41f91eb..0000000 --- a/src/Application/Common/Exceptions/ForbiddenAccessException.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Hutopy.Application.Common.Exceptions; - -public class ForbiddenAccessException : Exception -{ - public ForbiddenAccessException() : base() { } -} diff --git a/src/Application/Common/Exceptions/ValidationException.cs b/src/Application/Common/Exceptions/ValidationException.cs deleted file mode 100644 index f167275..0000000 --- a/src/Application/Common/Exceptions/ValidationException.cs +++ /dev/null @@ -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 failures) - : this() - { - Errors = failures - .GroupBy(e => e.PropertyName, e => e.ErrorMessage) - .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); - } - - public IDictionary Errors { get; } = new Dictionary(); -} diff --git a/src/Application/Common/Interfaces/IApplicationDbContext.cs b/src/Application/Common/Interfaces/IApplicationDbContext.cs deleted file mode 100644 index 75eabb6..0000000 --- a/src/Application/Common/Interfaces/IApplicationDbContext.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Hutopy.Domain.Entities; - -namespace Hutopy.Application.Common.Interfaces; - -public interface IApplicationDbContext -{ - DbSet FutureCreators { get; } - DbSet UserTransactions { get; } - Task SaveChangesAsync(CancellationToken cancellationToken); -} diff --git a/src/Application/Common/Interfaces/IBlobStorage.cs b/src/Application/Common/Interfaces/IBlobStorage.cs deleted file mode 100644 index 7dac254..0000000 --- a/src/Application/Common/Interfaces/IBlobStorage.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Hutopy.Application.Common.Interfaces; - -public interface IBlobStorage -{ - Task UploadFileAsync(string containerName, string blobName, Stream stream, string contentType, - CancellationToken ct = default); - Task DownloadFileAsync(string containerName, string blobName, CancellationToken ct = default); -} diff --git a/src/Application/Common/Interfaces/IIdentityService.cs b/src/Application/Common/Interfaces/IIdentityService.cs deleted file mode 100644 index 3ebbfb3..0000000 --- a/src/Application/Common/Interfaces/IIdentityService.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Hutopy.Application.Common.Models; - -namespace Hutopy.Application.Common.Interfaces; - -public interface IIdentityService -{ - Task> CreateUserAsync( - string email, - string userName, - string firstName, - string lastName, - string password); - - Task GetCurrentUserAsync(); - Task UpdateCurrentUserPortraitUrlAsync(string url); - Task> UpdateCurrentUserAsync(UserModel userModel); - Task> GetCurrentUserRolesAsync(); - Task FindUserByIdAsync(string id); - Task FindUserByEmailAsync(string email); - Task GetUserByUserNameAsync(string userName); - Task LoginAsync(string email, string password); - Task IsInRoleAsync(Guid userId, string role); - Task AuthorizeAsync(Guid userId, string policyName); - Task GetUserNameAsync(Guid userId); - - Task AddRoleAsync(string userId, string role); - Task DeleteUserAsync(string userId); -} diff --git a/src/Application/Common/Interfaces/IRoleService.cs b/src/Application/Common/Interfaces/IRoleService.cs deleted file mode 100644 index 96ed4d2..0000000 --- a/src/Application/Common/Interfaces/IRoleService.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Hutopy.Application.Common.Models; - -namespace Hutopy.Application.Common.Interfaces; - -public interface IRoleService -{ - public Task CreateRoleAsync(string roleName); - public Task DeleteRoleAsync(string roleName); - public Task FindRoleByIdAsync(string roleId); -} diff --git a/src/Application/Common/Interfaces/IStripeService.cs b/src/Application/Common/Interfaces/IStripeService.cs deleted file mode 100644 index 6737a3d..0000000 --- a/src/Application/Common/Interfaces/IStripeService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Hutopy.Application.Common.Models; -using Hutopy.Application.Stripe.Commands; - -namespace Hutopy.Application.Common.Interfaces; - - -public interface IStripeService -{ - public Task CreateCheckoutSession(int amount, string creatorId, string currency); - public Result ValidateTransaction(ConfirmStripeTransactionCommand request); -} diff --git a/src/Application/Common/Interfaces/IUser.cs b/src/Application/Common/Interfaces/IUser.cs deleted file mode 100644 index ede555b..0000000 --- a/src/Application/Common/Interfaces/IUser.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Hutopy.Application.Common.Interfaces; - -public interface IUser -{ - Guid? Id { get; } -} diff --git a/src/Application/Common/Mappings/MappingExtensions.cs b/src/Application/Common/Mappings/MappingExtensions.cs deleted file mode 100644 index bdbb707..0000000 --- a/src/Application/Common/Mappings/MappingExtensions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Hutopy.Application.Common.Models; - -namespace Hutopy.Application.Common.Mappings; - -public static class MappingExtensions -{ - public static Task> PaginatedListAsync(this IQueryable queryable, int pageNumber, int pageSize) where TDestination : class - => PaginatedList.CreateAsync(queryable.AsNoTracking(), pageNumber, pageSize); - - public static Task> ProjectToListAsync(this IQueryable queryable, IConfigurationProvider configuration) where TDestination : class - => queryable.ProjectTo(configuration).AsNoTracking().ToListAsync(); -} diff --git a/src/Application/Common/Models/PaginatedList.cs b/src/Application/Common/Models/PaginatedList.cs deleted file mode 100644 index e0a4e59..0000000 --- a/src/Application/Common/Models/PaginatedList.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace Hutopy.Application.Common.Models; - -public class PaginatedList( - IReadOnlyCollection items, - int count, - int pageNumber, - int pageSize) -{ - public IReadOnlyCollection 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> CreateAsync(IQueryable source, int pageNumber, int pageSize) - { - var count = await source.CountAsync(); - var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); - - return new PaginatedList(items, count, pageNumber, pageSize); - } -} diff --git a/src/Application/Common/Security/AuthorizeAttribute.cs b/src/Application/Common/Security/AuthorizeAttribute.cs deleted file mode 100644 index ad2e1e1..0000000 --- a/src/Application/Common/Security/AuthorizeAttribute.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Hutopy.Application.Common.Security; - -/// -/// Specifies the class this attribute is applied to requires authorization. -/// -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] -public class AuthorizeAttribute : Attribute -{ - /// - /// Initializes a new instance of the class. - /// - public AuthorizeAttribute() { } - - /// - /// Gets or sets a comma delimited list of roles that are allowed to access the resource. - /// - public string Roles { get; set; } = string.Empty; - - /// - /// Gets or sets the policy name that determines access to the resource. - /// - public string Policy { get; set; } = string.Empty; -} diff --git a/src/Application/DependencyInjection.cs b/src/Application/DependencyInjection.cs deleted file mode 100644 index 41c71da..0000000 --- a/src/Application/DependencyInjection.cs +++ /dev/null @@ -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; - } -} diff --git a/src/Application/FutureCreators/Commands/CreateFutureCreator.cs b/src/Application/FutureCreators/Commands/CreateFutureCreator.cs deleted file mode 100644 index 7aa7f50..0000000 --- a/src/Application/FutureCreators/Commands/CreateFutureCreator.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Hutopy.Application.Common.Interfaces; -using Hutopy.Domain.Entities; - -namespace Hutopy.Application.FutureCreators.Commands; - -public record CreateFutureCreatorCommand : IRequest -{ - 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 -{ - public async Task 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; - } -} diff --git a/src/Application/FutureCreators/Queries/FutureCreatorListDto.cs b/src/Application/FutureCreators/Queries/FutureCreatorListDto.cs deleted file mode 100644 index 13d95ba..0000000 --- a/src/Application/FutureCreators/Queries/FutureCreatorListDto.cs +++ /dev/null @@ -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(); - } - } -} diff --git a/src/Application/FutureCreators/Queries/GetFutureCreatorList.cs b/src/Application/FutureCreators/Queries/GetFutureCreatorList.cs deleted file mode 100644 index 0281518..0000000 --- a/src/Application/FutureCreators/Queries/GetFutureCreatorList.cs +++ /dev/null @@ -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> -{ - public int PageNumber { get; init; } = 1; - public int PageSize { get; init; } = 10; -} - -public class GetFutureCreatorListQueryHandler( - IApplicationDbContext context, - IMapper mapper) - : IRequestHandler> -{ - public async Task> Handle(GetFutureCreatorListQuery request, CancellationToken cancellationToken) - { - return await context.FutureCreators - .OrderBy(x => x.FirstName) - .ProjectTo(mapper.ConfigurationProvider) - .PaginatedListAsync(request.PageNumber, request.PageSize); - } -} diff --git a/src/Application/GlobalUsings.cs b/src/Application/GlobalUsings.cs deleted file mode 100644 index fa904ba..0000000 --- a/src/Application/GlobalUsings.cs +++ /dev/null @@ -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; \ No newline at end of file diff --git a/src/Application/Stripe/Commands/ConfirmStripeTransaction.cs b/src/Application/Stripe/Commands/ConfirmStripeTransaction.cs deleted file mode 100644 index e287b45..0000000 --- a/src/Application/Stripe/Commands/ConfirmStripeTransaction.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Hutopy.Application.Common.Interfaces; - -namespace Hutopy.Application.Stripe.Commands; -public class ConfirmStripeTransactionCommand : IRequest -{ - 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 -{ - public async Task 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 ""; - } -} diff --git a/src/Application/Stripe/Commands/CreateSessionCheckoutCommand.cs b/src/Application/Stripe/Commands/CreateSessionCheckoutCommand.cs deleted file mode 100644 index 80249d3..0000000 --- a/src/Application/Stripe/Commands/CreateSessionCheckoutCommand.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Hutopy.Application.Common.Interfaces; -using Hutopy.Domain.Entities; - -namespace Hutopy.Application.Stripe.Commands; - -public record CreateSessionCheckoutCommand : IRequest -{ - 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 -{ - public async Task 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; - } -} diff --git a/src/Application/Stripe/Queries/GetMyLastReceipt.cs b/src/Application/Stripe/Queries/GetMyLastReceipt.cs deleted file mode 100644 index c4feb05..0000000 --- a/src/Application/Stripe/Queries/GetMyLastReceipt.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Hutopy.Application.Common.Interfaces; - -namespace Hutopy.Application.Stripe.Queries; - -public record GetMyLastReceiptQuery : IRequest -{ - public Guid CreatorId { get; set; } - public string Email { get; set; } = string.Empty; -}; - -public class GetMyLastReceiptQueryHandler( - IApplicationDbContext dbContext -) - : IRequestHandler -{ - public async Task 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; - } -} diff --git a/src/Application/Stripe/Queries/MyLastReceiptDto.cs b/src/Application/Stripe/Queries/MyLastReceiptDto.cs deleted file mode 100644 index 5ce1645..0000000 --- a/src/Application/Stripe/Queries/MyLastReceiptDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Hutopy.Application.Stripe.Queries; - -public class MyLastReceiptDto -{ - public string ReceiptUrl { get; set; } -} diff --git a/src/Application/Users/Commands/CreateUser.cs b/src/Application/Users/Commands/CreateUser.cs deleted file mode 100644 index d84b69b..0000000 --- a/src/Application/Users/Commands/CreateUser.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Hutopy.Application.Common.Interfaces; -using Microsoft.AspNetCore.Http; - -namespace Hutopy.Application.Users.Commands; -public record CreateUserCommand : IRequest -{ - 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 -{ - private readonly IApplicationDbContext _context; - private readonly IIdentityService _identityService; - - public CreateUserCommandHandler(IApplicationDbContext context, IIdentityService identityService) - { - _context = context; - _identityService = identityService; - } - - public async Task 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); - } -} diff --git a/src/Application/Users/Commands/Login.cs b/src/Application/Users/Commands/Login.cs deleted file mode 100644 index 8f232b5..0000000 --- a/src/Application/Users/Commands/Login.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Hutopy.Application.Common.Interfaces; - -namespace Hutopy.Application.Users.Commands; - -public record LoginCommand( - string Email, - string Password) - : IRequest; - -public record LoginResponse( - string AccessToken, - string RefreshToken); - -public class LoginCommandHandler( - IApplicationDbContext Context, - IIdentityService identityService) - : IRequestHandler -{ - public async Task 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); - } -} diff --git a/src/Application/Users/Commands/UpdateCurrentUserCommand.cs b/src/Application/Users/Commands/UpdateCurrentUserCommand.cs deleted file mode 100644 index 4836544..0000000 --- a/src/Application/Users/Commands/UpdateCurrentUserCommand.cs +++ /dev/null @@ -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 -{ - 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(); - } - } -} - -public class UpdateCurrentUserCommandHandler( - IApplicationDbContext context, - IIdentityService identityService, - IMapper mapper) - : IRequestHandler -{ - public async Task 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(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()); - } -} diff --git a/src/Application/Users/Queries/GetUser/GetUserById.cs b/src/Application/Users/Queries/GetUser/GetUserById.cs deleted file mode 100644 index 1128cfc..0000000 --- a/src/Application/Users/Queries/GetUser/GetUserById.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Hutopy.Application.Common.Interfaces; - -namespace Hutopy.Application.Users.Queries.GetUser; - -public record GetUserByIdQuery : IRequest -{ - public required string UserId { get; init; } -} - -public class GetUserByIdHandler( - IIdentityService identityService - ) - : IRequestHandler -{ - public async Task Handle(GetUserByIdQuery query, CancellationToken cancellationToken) - { - var user = await identityService.FindUserByIdAsync(query.UserId); - - if (user is null) throw new InvalidOperationException(); - - return user.ToDto(); - } -} diff --git a/src/Application/Users/Queries/GetUser/GetUserByUserName.cs b/src/Application/Users/Queries/GetUser/GetUserByUserName.cs deleted file mode 100644 index 131ac8b..0000000 --- a/src/Application/Users/Queries/GetUser/GetUserByUserName.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Hutopy.Application.Common.Interfaces; - -namespace Hutopy.Application.Users.Queries.GetUser; - -public record GetUserByUserNameQuery : IRequest -{ - public required string UserName { get; init; } -}; - -public class GetUserByUserNameQueryHandler( - IIdentityService identityService -) - : IRequestHandler -{ - public async Task Handle(GetUserByUserNameQuery query, CancellationToken cancellationToken) - { - var user = await identityService.GetUserByUserNameAsync(query.UserName); - - if (user is null) throw new InvalidOperationException(); - - return user.ToDto(); - } -} diff --git a/src/Application/Users/Queries/GetUser/UserDto.cs b/src/Application/Users/Queries/GetUser/UserDto.cs deleted file mode 100644 index c64bb21..0000000 --- a/src/Application/Users/Queries/GetUser/UserDto.cs +++ /dev/null @@ -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 - }; -} diff --git a/src/Domain/Entities/FutureCreator.cs b/src/Domain/Entities/FutureCreator.cs deleted file mode 100644 index b0ebde5..0000000 --- a/src/Domain/Entities/FutureCreator.cs +++ /dev/null @@ -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; } -} diff --git a/src/Domain/Entities/UserTransaction.cs b/src/Domain/Entities/UserTransaction.cs deleted file mode 100644 index b7df7da..0000000 --- a/src/Domain/Entities/UserTransaction.cs +++ /dev/null @@ -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; } -} diff --git a/src/Domain/GlobalUsings.cs b/src/Domain/GlobalUsings.cs index e312629..d6161ca 100644 --- a/src/Domain/GlobalUsings.cs +++ b/src/Domain/GlobalUsings.cs @@ -1,5 +1,2 @@ global using Hutopy.Domain.Common; -global using Hutopy.Domain.Entities; -global using Hutopy.Domain.Enums; global using Hutopy.Domain.Exceptions; -global using Hutopy.Domain.ValueObjects; \ No newline at end of file diff --git a/src/Infrastructure/AzureBlob/AzureBlobStorage.cs b/src/Infrastructure/AzureBlob/AzureBlobStorage.cs index fab5301..c2f3c72 100644 --- a/src/Infrastructure/AzureBlob/AzureBlobStorage.cs +++ b/src/Infrastructure/AzureBlob/AzureBlobStorage.cs @@ -1,13 +1,12 @@ using Azure; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; -using Hutopy.Application.Common.Interfaces; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Hutopy.Infrastructure.AzureBlob; -public class AzureBlobStorage : IBlobStorage +public class AzureBlobStorage { private const long MaxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes diff --git a/src/Infrastructure/AzureBlob/ContentTypes.cs b/src/Infrastructure/AzureBlob/ContentTypes.cs index e7f2da3..8af77a1 100644 --- a/src/Infrastructure/AzureBlob/ContentTypes.cs +++ b/src/Infrastructure/AzureBlob/ContentTypes.cs @@ -1,24 +1,24 @@ -using System.Text; - -namespace Hutopy.Infrastructure.AzureBlob; +namespace Hutopy.Infrastructure.AzureBlob; public static class ContentTypes { - private static string ImagePng = "image/png"; - private static string ImageJpeg = "image/jpeg"; - private static string ImageJpg = "image/jpg"; - private static string TextHtml = "text/html"; - - public static HashSet AllowedContentTypes = new HashSet { ImagePng, ImageJpeg, ImageJpg, TextHtml }; + private const string ImagePng = "image/png"; + private const string ImageJpeg = "image/jpeg"; + private const string ImageJpg = "image/jpg"; - public static bool IsAllowed(string contentType, Stream fileStream) + private static readonly HashSet AllowedContentTypes = [ImagePng, ImageJpeg, ImageJpg]; + + public static bool IsAllowed( + string contentType, + Stream fileStream) { 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.Position = 0; @@ -33,13 +33,6 @@ public static class ContentTypes { return true; } - - // Check for HTML content by looking for "" or "" tags - string content = Encoding.UTF8.GetString(buffer); - if (content.Contains("")) - { - return true; - } return false; } diff --git a/src/Infrastructure/Data/ApplicationDbContext.cs b/src/Infrastructure/Data/ApplicationDbContext.cs index a8a5d4f..ae691cb 100644 --- a/src/Infrastructure/Data/ApplicationDbContext.cs +++ b/src/Infrastructure/Data/ApplicationDbContext.cs @@ -1,24 +1,10 @@ -using System.Reflection; -using Hutopy.Application.Common.Interfaces; -using Hutopy.Domain.Entities; -using Hutopy.Infrastructure.Identity; +using Hutopy.Infrastructure.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace Hutopy.Infrastructure.Data { - public class ApplicationDbContext(DbContextOptions options) - : IdentityDbContext(options), IApplicationDbContext - { - public DbSet FutureCreators => Set(); - public DbSet UserTransactions => Set(); - - protected override void OnModelCreating(ModelBuilder builder) - { - base.OnModelCreating(builder); - - // Apply configurations - builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); - } - } + public class ApplicationDbContext( + DbContextOptions options) + : IdentityDbContext(options); } diff --git a/src/Infrastructure/Data/Configurations/UserTransactionConfiguration.cs b/src/Infrastructure/Data/Configurations/UserTransactionConfiguration.cs deleted file mode 100644 index f659e55..0000000 --- a/src/Infrastructure/Data/Configurations/UserTransactionConfiguration.cs +++ /dev/null @@ -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 - { - public void Configure(EntityTypeBuilder builder) - { - // Relationship between ApplicationUser and UserTransaction - builder.HasOne() - .WithMany() - .HasForeignKey(ut => ut.ApplicationUserId) - .IsRequired(); - - builder.Property(x => x.Amount).HasPrecision(18, 2); - } - } -} diff --git a/src/Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs b/src/Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs deleted file mode 100644 index b1d7157..0000000 --- a/src/Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs +++ /dev/null @@ -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 SavingChanges(DbContextEventData eventData, InterceptionResult result) - { - UpdateEntities(eventData.Context); - - return base.SavingChanges(eventData, result); - } - - public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult 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()) - { - 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)); -} diff --git a/src/Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs b/src/Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs deleted file mode 100644 index 3123ee7..0000000 --- a/src/Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs +++ /dev/null @@ -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 SavingChanges(DbContextEventData eventData, InterceptionResult result) - { - DispatchDomainEvents(eventData.Context).GetAwaiter().GetResult(); - - return base.SavingChanges(eventData, result); - - } - - public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult 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() - .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); - } -} diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index f816daa..0a328f5 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -1,10 +1,7 @@ -using Hutopy.Application.Common.Interfaces; -using Hutopy.Domain.Constants; +using Hutopy.Domain.Constants; using Hutopy.Infrastructure.AzureBlob; using Hutopy.Infrastructure.Data; -using Hutopy.Infrastructure.Data.Interceptors; using Hutopy.Infrastructure.Identity; -using Hutopy.Infrastructure.Stripe; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -23,17 +20,12 @@ public static class DependencyInjection var connectionString = configuration.GetConnectionString("MssqlConnection") ?? throw new InvalidOperationException("Missing ConnectionStrings:MssqlConnection"); - services.AddScoped(); - services.AddScoped(); - services.AddDbContext((sp, options) => { options.AddInterceptors(sp.GetServices()); options.UseSqlServer(connectionString); }); - services.AddScoped(provider => provider.GetRequiredService()); - services.AddScoped(); services.AddAuthentication() @@ -52,14 +44,11 @@ public static class DependencyInjection // Singleton services services.AddSingleton(TimeProvider.System); - services.AddSingleton(); + services.AddSingleton(); // Scoped services - services.AddScoped(); + services.AddScoped(); - // Transient services - services.AddTransient(); - services.AddAuthorization(options => options.AddPolicy(Policies.CanPurge, policy => policy.RequireRole(Roles.Administrator))); diff --git a/src/Infrastructure/Identity/IdentityResultExtensions.cs b/src/Infrastructure/Identity/IdentityResultExtensions.cs index 630c372..8ab92c1 100644 --- a/src/Infrastructure/Identity/IdentityResultExtensions.cs +++ b/src/Infrastructure/Identity/IdentityResultExtensions.cs @@ -1,4 +1,4 @@ -using Hutopy.Application.Common.Models; +using Hutopy.Infrastructure.Identity.Models; using Microsoft.AspNetCore.Identity; namespace Hutopy.Infrastructure.Identity; diff --git a/src/Infrastructure/Identity/IdentityService.cs b/src/Infrastructure/Identity/IdentityService.cs index 6195a70..66ce6f8 100644 --- a/src/Infrastructure/Identity/IdentityService.cs +++ b/src/Infrastructure/Identity/IdentityService.cs @@ -1,74 +1,14 @@ -using Google.Apis.Oauth2.v2.Data; using System.Security.Claims; -using Hutopy.Application.Common.Interfaces; -using Hutopy.Application.Common.Models; -using Hutopy.Infrastructure.Utils; -using Microsoft.AspNetCore.Authorization; +using Hutopy.Infrastructure.Identity.Models; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Options; namespace Hutopy.Infrastructure.Identity; public class IdentityService( ApplicationUserManager userManager, - SignInManager signInManager, - IUserClaimsPrincipalFactory userClaimsPrincipalFactory, - IAuthorizationService authorizationService, - IHttpContextAccessor contextAccessor, - IOptionsSnapshot jwtOptions + IHttpContextAccessor contextAccessor ) - : IIdentityService { - public async Task GetUserNameAsync(Guid userId) - { - var user = await userManager.FindByIdAsync(userId.ToString()); - - return user?.UserName; - } - - public async Task 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> 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(applicationUser.Id, applicationResult.Succeeded, applicationResult.Errors); - - return result; - } - public async Task> CreateUserAsync(string email, string userName, string firstName, string lastName, string password) { @@ -89,35 +29,6 @@ public class IdentityService( return result; } - public async Task> UpdateCurrentUserAsync(UserModel userModel) - { - var applicationUser = await userManager.FindByIdAsync(userModel.Id.ToString()); - - if (applicationUser is null) return Result.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( - userModel.Id, - applicationResult.Succeeded, - applicationResult.Errors); - - return result; - } - - private static UserModel BuildModelFrom(ApplicationUser response) { var userModel = new UserModel @@ -168,80 +79,6 @@ public class IdentityService( return await FindUserByIdAsync(currentUserId); } - public async Task 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 IsInRoleAsync(Guid userId, string role) - { - var user = await userManager.FindByIdAsync(userId.ToString()); - - return user != null && await userManager.IsInRoleAsync(user, role); - } - - public async Task 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 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 DeleteUserAsync(string userId) - { - var user = await userManager.FindByIdAsync(userId); - - return user != null ? await DeleteUserAsync(user) : Result.Success(); - } - - public async Task DeleteUserAsync(ApplicationUser user) - { - var result = await userManager.DeleteAsync(user); - - return result.ToApplicationResult(); - } - - public async Task 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> GetCurrentUserRolesAsync() { var currentUserModel = await GetCurrentUserAsync(); @@ -255,32 +92,4 @@ public class IdentityService( return userRoles; } - public async Task 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; - } } diff --git a/src/Application/Common/Models/Result.cs b/src/Infrastructure/Identity/Models/Result.cs similarity index 95% rename from src/Application/Common/Models/Result.cs rename to src/Infrastructure/Identity/Models/Result.cs index 8c30b4a..9d26a1d 100644 --- a/src/Application/Common/Models/Result.cs +++ b/src/Infrastructure/Identity/Models/Result.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Application.Common.Models; +namespace Hutopy.Infrastructure.Identity.Models; public class Result( bool succeeded, diff --git a/src/Application/Common/Models/RoleModel.cs b/src/Infrastructure/Identity/Models/RoleModel.cs similarity index 66% rename from src/Application/Common/Models/RoleModel.cs rename to src/Infrastructure/Identity/Models/RoleModel.cs index 5c1c786..051a272 100644 --- a/src/Application/Common/Models/RoleModel.cs +++ b/src/Infrastructure/Identity/Models/RoleModel.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Application.Common.Models; +namespace Hutopy.Infrastructure.Identity.Models; public class RoleModel { diff --git a/src/Application/Common/Models/UserModel.cs b/src/Infrastructure/Identity/Models/UserModel.cs similarity index 90% rename from src/Application/Common/Models/UserModel.cs rename to src/Infrastructure/Identity/Models/UserModel.cs index 2a28ec7..b43bc71 100644 --- a/src/Application/Common/Models/UserModel.cs +++ b/src/Infrastructure/Identity/Models/UserModel.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Application.Common.Models; +namespace Hutopy.Infrastructure.Identity.Models; public class UserModel { diff --git a/src/Infrastructure/Identity/RoleService.cs b/src/Infrastructure/Identity/RoleService.cs deleted file mode 100644 index ef8749a..0000000 --- a/src/Infrastructure/Identity/RoleService.cs +++ /dev/null @@ -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 roleManager - ) - : IRoleService -{ - public async Task CreateRoleAsync(string roleName) - { - var role = new ApplicationRole { Name = roleName, Id = Guid.NewGuid()}; - var result = await roleManager.CreateAsync(role); - - return result.ToApplicationResult(); - } - - public async Task DeleteRoleAsync(string roleName) - { - var role = new ApplicationRole { Name = roleName }; - var result = await roleManager.DeleteAsync(role); - - return result.ToApplicationResult(); - } - - public async Task 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 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; - } -} diff --git a/src/Infrastructure/Stripe/StripeService.cs b/src/Infrastructure/Stripe/StripeService.cs deleted file mode 100644 index 863db7c..0000000 --- a/src/Infrastructure/Stripe/StripeService.cs +++ /dev/null @@ -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 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()); - } - - return new Result(false, new List()); - } - catch (StripeException e) - { - Console.WriteLine("Error: {0}", e.Message); - return new Result(false, new List{e.Message}); - } - catch (Exception e) - { - return new Result(false, new List{e.Message}); - } - } -} diff --git a/src/Web/Controllers/FacebookController.cs b/src/Web/Controllers/FacebookController.cs index b8b52d9..bae7799 100644 --- a/src/Web/Controllers/FacebookController.cs +++ b/src/Web/Controllers/FacebookController.cs @@ -1,5 +1,5 @@ using System.Security.Claims; -using Hutopy.Application.Common.Interfaces; +using Hutopy.Infrastructure.Identity; using Hutopy.Infrastructure.Utils; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; @@ -8,7 +8,9 @@ using Microsoft.AspNetCore.Mvc; namespace Hutopy.Web.Controllers; -public class FacebookController(IIdentityService identityService) : Controller +public class FacebookController( + IdentityService identityService) + : Controller { [Microsoft.AspNetCore.Mvc.HttpGet("/api/facebook/sign-in")] public async Task SignIn() @@ -33,7 +35,7 @@ public class FacebookController(IIdentityService identityService) : Controller var claimsIdentity = new ClaimsIdentity( new List { - new(ClaimTypes.Name, name), + new(ClaimTypes.Name, name), new(ClaimTypes.Email, email), new(ClaimTypes.GivenName, givenName), new(ClaimTypes.Surname, familyName) diff --git a/src/Web/DependencyInjection.cs b/src/Web/DependencyInjection.cs index 4161aad..1ce13a2 100644 --- a/src/Web/DependencyInjection.cs +++ b/src/Web/DependencyInjection.cs @@ -1,9 +1,6 @@ using System.Text; using Azure.Identity; -using Hutopy.Application.Common.Interfaces; using Hutopy.Infrastructure.Data; -using Hutopy.Web.Infrastructure; -using Hutopy.Web.Services; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Facebook; using Microsoft.AspNetCore.Authentication.Google; @@ -19,15 +16,11 @@ public static class DependencyInjection { services.AddDatabaseDeveloperPageExceptionFilter(); - services.AddScoped(); - services.AddHttpContextAccessor(); services.AddHealthChecks() .AddDbContextCheck(); - services.AddExceptionHandler(); - services.AddRazorPages(); services.AddHttpClient(); diff --git a/src/Web/Endpoints/JoinUs.cs b/src/Web/Endpoints/JoinUs.cs deleted file mode 100644 index 8353ba1..0000000 --- a/src/Web/Endpoints/JoinUs.cs +++ /dev/null @@ -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 CreateFutureCreator(ISender sender, CreateFutureCreatorCommand command) - { - return sender.Send(command); - } - - private static Task> GetFutureCreators(ISender sender, [AsParameters] GetFutureCreatorListQuery query) - { - return sender.Send(query); - } -} diff --git a/src/Web/Endpoints/Stripe.cs b/src/Web/Endpoints/Stripe.cs deleted file mode 100644 index be76a0b..0000000 --- a/src/Web/Endpoints/Stripe.cs +++ /dev/null @@ -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 CreateSessionCheckout(ISender sender, CreateSessionCheckoutCommand command) - { - return sender.Send(command); - } - - private async static Task ConfirmTransaction(ISender sender, ConfirmStripeTransactionCommand command) - { - return await sender.Send(command); - } - - private static async Task GetMyLastReceipt(ISender sender, [AsParameters] GetMyLastReceiptQuery query) - { - return await sender.Send(query); - } -} diff --git a/src/Web/Endpoints/UpdateMyUser.cs b/src/Web/Endpoints/UpdateMyUser.cs deleted file mode 100644 index 7118e4e..0000000 --- a/src/Web/Endpoints/UpdateMyUser.cs +++ /dev/null @@ -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 UpdateCurrentUser(ISender sender, UpdateCurrentUserCommand command) - { - return await sender.Send(command); - } -} diff --git a/src/Web/Endpoints/Users.cs b/src/Web/Endpoints/Users.cs deleted file mode 100644 index 820aa37..0000000 --- a/src/Web/Endpoints/Users.cs +++ /dev/null @@ -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 CreateUser(ISender sender, CreateUserCommand command) - { - return await sender.Send(command); - } - - private static async Task GetUserById(ISender sender, - [AsParameters] GetUserByIdQuery query) - { - return await sender.Send(query); - } - - private static async Task GetUserByUserName(ISender sender, - [AsParameters] GetUserByUserNameQuery query) - { - return await sender.Send(query); - } - - private static async Task Login(ISender sender, LoginCommand command) - { - return await sender.Send(command); - } -} diff --git a/src/Web/Features/Contents/Data/ContentDbContext.cs b/src/Web/Features/Contents/Data/ContentDbContext.cs index 11edff0..94b88ea 100644 --- a/src/Web/Features/Contents/Data/ContentDbContext.cs +++ b/src/Web/Features/Contents/Data/ContentDbContext.cs @@ -10,11 +10,11 @@ public class ContentDbContext( public DbSet Contents => Set(); public DbSet Creators => Set(); - public DbSet Subscriptions => Set(); + public DbSet Followers => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.HasDefaultSchema("Content"); + modelBuilder.HasDefaultSchema(SchemaName); modelBuilder .Entity() @@ -34,13 +34,13 @@ public class ContentDbContext( .ToTable(nameof(ContentReaction).Pluralize()); modelBuilder - .Entity() + .Entity() .HasOne(c => c.Creator) .WithMany() .HasForeignKey(c => c.CreatorId); modelBuilder - .Entity() + .Entity() .HasKey(s => new { s.CreatedBy, s.CreatorId }); modelBuilder diff --git a/src/Web/Features/Contents/Data/Subscription.cs b/src/Web/Features/Contents/Data/Follower.cs similarity index 89% rename from src/Web/Features/Contents/Data/Subscription.cs rename to src/Web/Features/Contents/Data/Follower.cs index 4875fd9..c7368f1 100644 --- a/src/Web/Features/Contents/Data/Subscription.cs +++ b/src/Web/Features/Contents/Data/Follower.cs @@ -1,6 +1,6 @@ namespace Hutopy.Web.Features.Contents.Data; -public class Subscription +public class Follower { public Guid CreatedBy { get; init; } public DateTimeOffset CreatedAt { get; init; } diff --git a/src/Web/Features/Contents/Handlers/ChangeBanner.cs b/src/Web/Features/Contents/Handlers/ChangeBanner.cs index c13aa95..950d1b6 100644 --- a/src/Web/Features/Contents/Handlers/ChangeBanner.cs +++ b/src/Web/Features/Contents/Handlers/ChangeBanner.cs @@ -1,5 +1,5 @@ using Hutopy.Application.AzureBlobStorage.Constants; -using Hutopy.Application.Common.Interfaces; +using Hutopy.Infrastructure.AzureBlob; using Hutopy.Web.Features.Contents.Data; namespace Hutopy.Web.Features.Contents.Handlers; @@ -16,7 +16,7 @@ public record ChangeBannerResponse( [PublicAPI] public class ChangeBannerHandler( ContentDbContext context, - IBlobStorage blobStorage) + AzureBlobStorage blobStorage) : Endpoint { public override void Configure() diff --git a/src/Web/Features/Contents/Handlers/ChangeLogo.cs b/src/Web/Features/Contents/Handlers/ChangeLogo.cs index 0caf367..5bbc6b9 100644 --- a/src/Web/Features/Contents/Handlers/ChangeLogo.cs +++ b/src/Web/Features/Contents/Handlers/ChangeLogo.cs @@ -1,5 +1,5 @@ using Hutopy.Application.AzureBlobStorage.Constants; -using Hutopy.Application.Common.Interfaces; +using Hutopy.Infrastructure.AzureBlob; using Hutopy.Web.Features.Contents.Data; namespace Hutopy.Web.Features.Contents.Handlers; @@ -27,7 +27,7 @@ public sealed class ChangeLogoRequestValidator : Validator [PublicAPI] public class ChangeLogoHandler( ContentDbContext context, - IBlobStorage blobStorage) + AzureBlobStorage blobStorage) : Endpoint { public override void Configure() diff --git a/src/Web/Features/Contents/Handlers/CreateContent.cs b/src/Web/Features/Contents/Handlers/CreateContent.cs index e98f7f1..46e9bb9 100644 --- a/src/Web/Features/Contents/Handlers/CreateContent.cs +++ b/src/Web/Features/Contents/Handlers/CreateContent.cs @@ -1,6 +1,6 @@ using System.Collections.Concurrent; using Hutopy.Application.AzureBlobStorage.Constants; -using Hutopy.Application.Common.Interfaces; +using Hutopy.Infrastructure.AzureBlob; using Hutopy.Web.Common; using Hutopy.Web.Features.Contents.Data; using Hutopy.Web.Features.Contents.Handlers.Models; @@ -44,7 +44,7 @@ public sealed class PostContentRequestValidator : Validator } public sealed class PostContent( - IBlobStorage blobStorage, + AzureBlobStorage blobStorage, ContentDbContext context) : Endpoint { diff --git a/src/Web/Features/Contents/Handlers/SubscribeToCreator.cs b/src/Web/Features/Contents/Handlers/FollowCreator.cs similarity index 59% rename from src/Web/Features/Contents/Handlers/SubscribeToCreator.cs rename to src/Web/Features/Contents/Handlers/FollowCreator.cs index 39352ff..3386046 100644 --- a/src/Web/Features/Contents/Handlers/SubscribeToCreator.cs +++ b/src/Web/Features/Contents/Handlers/FollowCreator.cs @@ -5,40 +5,40 @@ using Hutopy.Web.Features.Contents.Handlers.Models; namespace Hutopy.Web.Features.Contents.Handlers; [PublicAPI] -public sealed class SubscribeToCreatorRequest +public sealed class FollowCreatorRequest { public Guid CreatorId { get; set; } } [PublicAPI] -public sealed class SubscribeToCreatorHandler( +public sealed class FollowCreatorHandler( ContentDbContext context) - : Endpoint + : Endpoint { public override void Configure() { - Post("/api/creators/{CreatorId}/subscribe"); - Options((o => o.WithTags("Subscriptions"))); + Post("/api/creators/{CreatorId}/follow"); + Options((o => o.WithTags("creators"))); Description(x => x.Accepts("*/*")); } public override async Task HandleAsync( - SubscribeToCreatorRequest req, + FollowCreatorRequest req, CancellationToken ct) { - await context.Subscriptions.AddAsync( - new() { CreatedBy = HttpContext.User.GetUserId(), CreatorId = req.CreatorId }, + await context.Followers.AddAsync( + new Follower { CreatedBy = User.GetUserId(), CreatorId = req.CreatorId }, ct); await context.SaveChangesAsync(ct); var creator = await context .Creators - .Where(c => c.Id == req.CreatorId) - .Select(c => new SubscriptionModel( + .Where(creator => creator.Id == req.CreatorId) + .Select(creator => new FollowModel( req.CreatorId, - c.Name, - c.Images.Logo + creator.Name, + creator.Images.Logo )) .FirstOrDefaultAsync(cancellationToken: ct); diff --git a/src/Web/Features/Contents/Handlers/GetCreatorByAlias.cs b/src/Web/Features/Contents/Handlers/GetCreatorByAlias.cs index 2a2b161..c53d2a6 100644 --- a/src/Web/Features/Contents/Handlers/GetCreatorByAlias.cs +++ b/src/Web/Features/Contents/Handlers/GetCreatorByAlias.cs @@ -50,7 +50,7 @@ public class GetCreatorByAliasHandler( } else { - var subscriberCount = await context.Subscriptions.CountAsync( + var followerCount = await context.Followers.CountAsync( s => s.CreatorId == creator.Id, cancellationToken: ct); @@ -63,7 +63,7 @@ public class GetCreatorByAliasHandler( creator.Socials, creator.Colors, creator.Images, - subscriberCount); + followerCount); await SendAsync(model, cancellation: ct); } diff --git a/src/Web/Features/Contents/Handlers/GetFollowedContents.cs b/src/Web/Features/Contents/Handlers/GetFollowedContents.cs deleted file mode 100644 index 39de093..0000000 --- a/src/Web/Features/Contents/Handlers/GetFollowedContents.cs +++ /dev/null @@ -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> -{ - 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); - } -} diff --git a/src/Web/Features/Contents/Handlers/GetSubscriptions.cs b/src/Web/Features/Contents/Handlers/GetFollowedCreators.cs similarity index 73% rename from src/Web/Features/Contents/Handlers/GetSubscriptions.cs rename to src/Web/Features/Contents/Handlers/GetFollowedCreators.cs index 02c320e..6ab7f52 100644 --- a/src/Web/Features/Contents/Handlers/GetSubscriptions.cs +++ b/src/Web/Features/Contents/Handlers/GetFollowedCreators.cs @@ -5,14 +5,14 @@ using Hutopy.Web.Features.Contents.Handlers.Models; namespace Hutopy.Web.Features.Contents.Handlers; [PublicAPI] -public class GetSubscriptionsHandler( +public class GetFollowedCreatorsHandler( ContentDbContext context) - : EndpointWithoutRequest> + : EndpointWithoutRequest> { public override void Configure() { - Get("/api/subscriptions"); - Options((o => o.WithTags("Subscriptions"))); + Get("/api/creators/followed"); + Options((o => o.WithTags("Creators"))); } public override async Task HandleAsync( @@ -21,9 +21,9 @@ public class GetSubscriptionsHandler( var userId = HttpContext.User.GetUserId(); var subscriptions = await context - .Subscriptions + .Followers .Where(s => s.CreatedBy == userId) - .Select(s => new SubscriptionModel( + .Select(s => new FollowModel( s.CreatorId, s.Creator!.Name, s.Creator.Images.Logo)) diff --git a/src/Web/Features/Contents/Handlers/Models/CreatorModel.cs b/src/Web/Features/Contents/Handlers/Models/CreatorModel.cs index 83b7bd6..00abe19 100644 --- a/src/Web/Features/Contents/Handlers/Models/CreatorModel.cs +++ b/src/Web/Features/Contents/Handlers/Models/CreatorModel.cs @@ -12,4 +12,4 @@ public record struct CreatorModel( Socials Socials, Colors Colors, Images Images, - int SubscriberCount); + int FollowerCount); diff --git a/src/Web/Features/Contents/Handlers/Models/SubscriptionModel.cs b/src/Web/Features/Contents/Handlers/Models/FollowModel.cs similarity index 81% rename from src/Web/Features/Contents/Handlers/Models/SubscriptionModel.cs rename to src/Web/Features/Contents/Handlers/Models/FollowModel.cs index 7c748aa..7f722f1 100644 --- a/src/Web/Features/Contents/Handlers/Models/SubscriptionModel.cs +++ b/src/Web/Features/Contents/Handlers/Models/FollowModel.cs @@ -1,7 +1,7 @@ namespace Hutopy.Web.Features.Contents.Handlers.Models; [PublicAPI] -public record SubscriptionModel( +public record FollowModel( Guid CreatorId, string CreatorName, string? CreatorPortraitUrl); diff --git a/src/Web/Features/Contents/Handlers/UnsubscribeFromCreator.cs b/src/Web/Features/Contents/Handlers/UnfollowCreator.cs similarity index 75% rename from src/Web/Features/Contents/Handlers/UnsubscribeFromCreator.cs rename to src/Web/Features/Contents/Handlers/UnfollowCreator.cs index 69ac37e..a4d9669 100644 --- a/src/Web/Features/Contents/Handlers/UnsubscribeFromCreator.cs +++ b/src/Web/Features/Contents/Handlers/UnfollowCreator.cs @@ -16,8 +16,8 @@ public class UnsubscribeFromCreatorHandler( { public override void Configure() { - Post("/api/creators/{CreatorId}/unsubscribe"); - Options((o => o.WithTags("Subscriptions"))); + Post("/api/creators/{CreatorId}/unfollow"); + Options((o => o.WithTags("Creators"))); Description(x => x.Accepts("*/*")); } @@ -25,14 +25,15 @@ public class UnsubscribeFromCreatorHandler( UnsubscribeFromCreatorRequest req, CancellationToken ct) { - var subscription = new Subscription { CreatorId = req.CreatorId, CreatedBy = HttpContext.User.GetUserId() }; - - context.Subscriptions.Attach(subscription); - context.Subscriptions.Remove(subscription); - 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 SendOkAsync(ct); } catch (Exception) diff --git a/src/Web/Features/Contents/Migrations/20241011103653_FromSubscribersToFollowers.Designer.cs b/src/Web/Features/Contents/Migrations/20241011103653_FromSubscribersToFollowers.Designer.cs new file mode 100644 index 0000000..4d4fd2b --- /dev/null +++ b/src/Web/Features/Contents/Migrations/20241011103653_FromSubscribersToFollowers.Designer.cs @@ -0,0 +1,310 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Urls") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.ToTable("Contents", "Content"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("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("CreatedBy") + .HasColumnType("uuid"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("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("ContentId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Reaction") + .HasColumnType("integer"); + + b1.Property("UserId") + .HasColumnType("uuid"); + + b1.Property("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("CreatorId") + .HasColumnType("uuid"); + + b1.Property("Background") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("Error") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("OnBackground") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("OnError") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("OnPrimary") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("OnSecondary") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("OnSurface") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("Primary") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("Secondary") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("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("CreatorId") + .HasColumnType("uuid"); + + b1.Property("Banner") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("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("CreatorId") + .HasColumnType("uuid"); + + b1.Property("FacebookUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("InstagramUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("LinkedInUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("RedditUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("TikTokUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("WebsiteUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("XUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("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 + } + } +} diff --git a/src/Web/Features/Contents/Migrations/20241011103653_FromSubscribersToFollowers.cs b/src/Web/Features/Contents/Migrations/20241011103653_FromSubscribersToFollowers.cs new file mode 100644 index 0000000..8f41082 --- /dev/null +++ b/src/Web/Features/Contents/Migrations/20241011103653_FromSubscribersToFollowers.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Contents.Migrations +{ + /// + public partial class FromSubscribersToFollowers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Subscriptions", + schema: "Content"); + + migrationBuilder.CreateTable( + name: "Followers", + schema: "Content", + columns: table => new + { + CreatedBy = table.Column(type: "uuid", nullable: false), + CreatorId = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Followers", + schema: "Content"); + + migrationBuilder.CreateTable( + name: "Subscriptions", + schema: "Content", + columns: table => new + { + CreatedBy = table.Column(type: "uuid", nullable: false), + CreatorId = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(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"); + } + } +} diff --git a/src/Web/Features/Contents/Migrations/ContentDbContextModelSnapshot.cs b/src/Web/Features/Contents/Migrations/ContentDbContextModelSnapshot.cs index c38d421..2f630c6 100644 --- a/src/Web/Features/Contents/Migrations/ContentDbContextModelSnapshot.cs +++ b/src/Web/Features/Contents/Migrations/ContentDbContextModelSnapshot.cs @@ -92,7 +92,7 @@ namespace Hutopy.Web.Features.Contents.Migrations b.ToTable("Creators", "Content"); }); - modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Subscription", b => + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Follower", b => { b.Property("CreatedBy") .HasColumnType("uuid"); @@ -107,7 +107,7 @@ namespace Hutopy.Web.Features.Contents.Migrations b.HasIndex("CreatorId"); - b.ToTable("Subscriptions", "Content"); + b.ToTable("Followers", "Content"); }); modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b => @@ -294,7 +294,7 @@ namespace Hutopy.Web.Features.Contents.Migrations .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") .WithMany() diff --git a/src/Web/Features/Memberships/Data/Creator.cs b/src/Web/Features/Memberships/Data/Creator.cs new file mode 100644 index 0000000..72ebd31 --- /dev/null +++ b/src/Web/Features/Memberships/Data/Creator.cs @@ -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; } +} diff --git a/src/Web/Features/Memberships/Data/MembershipDbContext.cs b/src/Web/Features/Memberships/Data/MembershipDbContext.cs new file mode 100644 index 0000000..f0dcd47 --- /dev/null +++ b/src/Web/Features/Memberships/Data/MembershipDbContext.cs @@ -0,0 +1,58 @@ +namespace Hutopy.Web.Features.Memberships.Data; + +public sealed class MembershipDbContext( + DbContextOptions options) + : DbContext(options) +{ + public const string SchemaName = "Membership"; + + public DbSet Creators => Set(); + public DbSet Subscriptions => Set(); + public DbSet Tiers => Set(); + public DbSet Tips => Set(); + public DbSet Transactions => Set(); + + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(); + + modelBuilder + .Entity() + .Property(c => c.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + modelBuilder + .Entity() + .HasOne(c => c.Creator) + .WithMany() + .HasForeignKey(c => c.CreatorId); + + modelBuilder + .Entity() + .HasOne(c => c.Creator) + .WithMany() + .HasForeignKey(c => c.CreatorId); + + modelBuilder + .Entity() + .Property(c => c.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + modelBuilder + .Entity() + .Property(c => c.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + modelBuilder + .Entity() + .Property(c => c.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + } +} diff --git a/src/Web/Features/Memberships/Data/MembershipDbContextInitializer.cs b/src/Web/Features/Memberships/Data/MembershipDbContextInitializer.cs new file mode 100644 index 0000000..df9705e --- /dev/null +++ b/src/Web/Features/Memberships/Data/MembershipDbContextInitializer.cs @@ -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(); + + await initializer.InitialiseAsync(); + } +} + +public class MembershipDbContextInitializer( + ILogger 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; + } + } +} diff --git a/src/Web/Features/Memberships/Data/Subscription.cs b/src/Web/Features/Memberships/Data/Subscription.cs new file mode 100644 index 0000000..6bd00d1 --- /dev/null +++ b/src/Web/Features/Memberships/Data/Subscription.cs @@ -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; } + +} diff --git a/src/Web/Features/Memberships/Data/Tier.cs b/src/Web/Features/Memberships/Data/Tier.cs new file mode 100644 index 0000000..9563852 --- /dev/null +++ b/src/Web/Features/Memberships/Data/Tier.cs @@ -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 Subscriptions { get; set; } +} diff --git a/src/Web/Features/Memberships/Data/Tip.cs b/src/Web/Features/Memberships/Data/Tip.cs new file mode 100644 index 0000000..57fe7b7 --- /dev/null +++ b/src/Web/Features/Memberships/Data/Tip.cs @@ -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; } +} diff --git a/src/Web/Features/Memberships/Data/Transaction.cs b/src/Web/Features/Memberships/Data/Transaction.cs new file mode 100644 index 0000000..ae28704 --- /dev/null +++ b/src/Web/Features/Memberships/Data/Transaction.cs @@ -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; } +} diff --git a/src/Web/Features/Memberships/DependencyInjection.cs b/src/Web/Features/Memberships/DependencyInjection.cs new file mode 100644 index 0000000..8203882 --- /dev/null +++ b/src/Web/Features/Memberships/DependencyInjection.cs @@ -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? configureAction = null) + { + builder.Services.AddSingleton(); + + builder.Services.AddDbContext(configureAction); + builder.Services.AddScoped(); + + builder.Services.AddScoped(); + + builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection("Stripe")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + return builder; + } +} diff --git a/src/Web/Features/Memberships/Events/SubscriptionPaid.cs b/src/Web/Features/Memberships/Events/SubscriptionPaid.cs new file mode 100644 index 0000000..b661b35 --- /dev/null +++ b/src/Web/Features/Memberships/Events/SubscriptionPaid.cs @@ -0,0 +1,7 @@ +namespace Hutopy.Web.Features.Memberships.Events; + +public record struct SubscriptionPaid( + Guid CreatorId, + string CreatorName, + string Tier, + DateTimeOffset Since); diff --git a/src/Web/Features/Memberships/Events/TipPaid.cs b/src/Web/Features/Memberships/Events/TipPaid.cs new file mode 100644 index 0000000..6331757 --- /dev/null +++ b/src/Web/Features/Memberships/Events/TipPaid.cs @@ -0,0 +1,8 @@ +namespace Hutopy.Web.Features.Memberships.Events; + +public record struct TipPaid( + Guid CreatorId, + string CreatorName, + decimal Amount, + string Currency, + string Message); diff --git a/src/Web/Features/Memberships/Handlers/CancelSubscription.cs b/src/Web/Features/Memberships/Handlers/CancelSubscription.cs new file mode 100644 index 0000000..676c3f7 --- /dev/null +++ b/src/Web/Features/Memberships/Handlers/CancelSubscription.cs @@ -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 +{ + 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); + } +} diff --git a/src/Web/Features/Memberships/Handlers/CreateMembershipTier.cs b/src/Web/Features/Memberships/Handlers/CreateMembershipTier.cs new file mode 100644 index 0000000..d3dc138 --- /dev/null +++ b/src/Web/Features/Memberships/Handlers/CreateMembershipTier.cs @@ -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 +{ + 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); + } +} diff --git a/src/Web/Features/Memberships/Handlers/GetActiveSubscriptions.cs b/src/Web/Features/Memberships/Handlers/GetActiveSubscriptions.cs new file mode 100644 index 0000000..ddca0c7 --- /dev/null +++ b/src/Web/Features/Memberships/Handlers/GetActiveSubscriptions.cs @@ -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 +{ + 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); + } +} diff --git a/src/Web/Features/Memberships/Handlers/GetMembershipTier.cs b/src/Web/Features/Memberships/Handlers/GetMembershipTier.cs new file mode 100644 index 0000000..abb40f4 --- /dev/null +++ b/src/Web/Features/Memberships/Handlers/GetMembershipTier.cs @@ -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> +{ + 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); + } +} diff --git a/src/Web/Features/Memberships/Handlers/GetReceivedTips.cs b/src/Web/Features/Memberships/Handlers/GetReceivedTips.cs new file mode 100644 index 0000000..7e5e606 --- /dev/null +++ b/src/Web/Features/Memberships/Handlers/GetReceivedTips.cs @@ -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> +{ + 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); + } +} diff --git a/src/Web/Features/Memberships/Handlers/GetSentTips.cs b/src/Web/Features/Memberships/Handlers/GetSentTips.cs new file mode 100644 index 0000000..a37af32 --- /dev/null +++ b/src/Web/Features/Memberships/Handlers/GetSentTips.cs @@ -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> +{ + 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); + } +} diff --git a/src/Web/Features/Memberships/Handlers/HandleStripe.cs b/src/Web/Features/Memberships/Handlers/HandleStripe.cs new file mode 100644 index 0000000..a46dc67 --- /dev/null +++ b/src/Web/Features/Memberships/Handlers/HandleStripe.cs @@ -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 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); + } +} diff --git a/src/Web/Features/Memberships/Handlers/SendTip.cs b/src/Web/Features/Memberships/Handlers/SendTip.cs new file mode 100644 index 0000000..9b59761 --- /dev/null +++ b/src/Web/Features/Memberships/Handlers/SendTip.cs @@ -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 +{ + 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 +{ + 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); + } +} diff --git a/src/Web/Features/Memberships/Handlers/SubscribeToCreator.cs b/src/Web/Features/Memberships/Handlers/SubscribeToCreator.cs new file mode 100644 index 0000000..a006f1a --- /dev/null +++ b/src/Web/Features/Memberships/Handlers/SubscribeToCreator.cs @@ -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 +{ + public SubscribeValidator() + { + RuleFor(x => x.TierId).NotEmpty(); + } +} + +[PublicAPI] +public class SubscribeHandler( + MembershipDbContext dbDbContext, + StripeService stripeService) + : Endpoint +{ + 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); + } +} diff --git a/src/Web/Features/Memberships/Migrations/20241011100852_Initial.Designer.cs b/src/Web/Features/Memberships/Migrations/20241011100852_Initial.Designer.cs new file mode 100644 index 0000000..8e61a0b --- /dev/null +++ b/src/Web/Features/Memberships/Migrations/20241011100852_Initial.Designer.cs @@ -0,0 +1,233 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeAccountId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Creators", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeSessionId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("TierId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("CurrencyCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Price") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("Tiers", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("CreatorName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeSessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TipperId") + .HasColumnType("uuid"); + + b.Property("TipperName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Tips", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("StripeCheckoutSessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("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 + } + } +} diff --git a/src/Web/Features/Memberships/Migrations/20241011100852_Initial.cs b/src/Web/Features/Memberships/Migrations/20241011100852_Initial.cs new file mode 100644 index 0000000..a792a52 --- /dev/null +++ b/src/Web/Features/Memberships/Migrations/20241011100852_Initial.cs @@ -0,0 +1,170 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Memberships.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "Membership"); + + migrationBuilder.CreateTable( + name: "Creators", + schema: "Membership", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + StripeAccountId = table.Column(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(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + StripeSessionId = table.Column(type: "text", nullable: false), + TipperId = table.Column(type: "uuid", nullable: false), + TipperName = table.Column(type: "text", nullable: false), + CreatorId = table.Column(type: "uuid", nullable: false), + CreatorName = table.Column(type: "text", nullable: false), + Amount = table.Column(type: "numeric", nullable: false), + Currency = table.Column(type: "text", nullable: false), + Message = table.Column(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(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + StripeCheckoutSessionId = table.Column(type: "text", nullable: false), + Amount = table.Column(type: "numeric", nullable: false), + Type = table.Column(type: "text", nullable: false), + Timestamp = table.Column(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(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + CreatorId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Price = table.Column(type: "numeric", nullable: false), + CurrencyCode = table.Column(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(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + UserId = table.Column(type: "uuid", nullable: false), + CreatorId = table.Column(type: "uuid", nullable: false), + TierId = table.Column(type: "uuid", nullable: false), + StartDate = table.Column(type: "timestamp with time zone", nullable: false), + EndDate = table.Column(type: "timestamp with time zone", nullable: true), + StripeSessionId = table.Column(type: "text", nullable: true), + StripeSubscriptionId = table.Column(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"); + } + + /// + 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"); + } + } +} diff --git a/src/Web/Features/Memberships/Migrations/MembershipDbContextModelSnapshot.cs b/src/Web/Features/Memberships/Migrations/MembershipDbContextModelSnapshot.cs new file mode 100644 index 0000000..d0354ae --- /dev/null +++ b/src/Web/Features/Memberships/Migrations/MembershipDbContextModelSnapshot.cs @@ -0,0 +1,230 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeAccountId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Creators", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeSessionId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("TierId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("CurrencyCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Price") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("Tiers", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("CreatorName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeSessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TipperId") + .HasColumnType("uuid"); + + b.Property("TipperName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Tips", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("StripeCheckoutSessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("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 + } + } +} diff --git a/src/Web/Features/Memberships/Services/PushNotificationService.cs b/src/Web/Features/Memberships/Services/PushNotificationService.cs new file mode 100644 index 0000000..ab751c1 --- /dev/null +++ b/src/Web/Features/Memberships/Services/PushNotificationService.cs @@ -0,0 +1,13 @@ +namespace Hutopy.Web.Features.Memberships.Services; + +public sealed class PushNotificationService( + ILogger logger) +{ + public void NotifyCreator( + Guid tipCreatorId, + TEvent notification) + where TEvent : struct + { + logger.LogInformation("Notifying creator {CreatorId}, {Notification}", tipCreatorId, notification); + } +} diff --git a/src/Web/Features/Memberships/Services/StripeService.cs b/src/Web/Features/Memberships/Services/StripeService.cs new file mode 100644 index 0000000..12e3769 --- /dev/null +++ b/src/Web/Features/Memberships/Services/StripeService.cs @@ -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 paymentOptions, + MembershipDbContext dbDbContext, + PushNotificationService notificationService) +{ + public async Task 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 { { "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 { { "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 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 { { "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)); + } + } + } +} diff --git a/src/Web/Features/Users/Handlers/ChangePortrait.cs b/src/Web/Features/Users/Handlers/ChangePortrait.cs index 1424fb8..ec624bb 100644 --- a/src/Web/Features/Users/Handlers/ChangePortrait.cs +++ b/src/Web/Features/Users/Handlers/ChangePortrait.cs @@ -1,5 +1,5 @@ using Hutopy.Application.AzureBlobStorage.Constants; -using Hutopy.Application.Common.Interfaces; +using Hutopy.Infrastructure.AzureBlob; using Hutopy.Infrastructure.Identity; using Hutopy.Web.Common; @@ -27,7 +27,7 @@ public sealed class ChangePortraitRequestValidator : Validator { public override void Configure() diff --git a/src/Web/Features/Users/Handlers/GetCurrentUser.cs b/src/Web/Features/Users/Handlers/GetCurrentUser.cs index 30981ac..8d8bd4d 100644 --- a/src/Web/Features/Users/Handlers/GetCurrentUser.cs +++ b/src/Web/Features/Users/Handlers/GetCurrentUser.cs @@ -1,12 +1,11 @@ -using Hutopy.Application.Common.Interfaces; +using Hutopy.Infrastructure.Identity; using Hutopy.Web.Features.Users.Handlers.Models; namespace Hutopy.Web.Features.Users.Handlers; [PublicAPI] public class GetCurrentUserQueryHandler( - IIdentityService identityService -) + IdentityService identityService) : EndpointWithoutRequest { public override void Configure() diff --git a/src/Web/Features/Users/Handlers/GetCurrentUserProfilePicture.cs b/src/Web/Features/Users/Handlers/GetCurrentUserProfilePicture.cs index 806fef9..552a881 100644 --- a/src/Web/Features/Users/Handlers/GetCurrentUserProfilePicture.cs +++ b/src/Web/Features/Users/Handlers/GetCurrentUserProfilePicture.cs @@ -1,12 +1,13 @@ using Hutopy.Application.AzureBlobStorage.Constants; -using Hutopy.Application.Common.Interfaces; +using Hutopy.Infrastructure.AzureBlob; +using Hutopy.Infrastructure.Identity; namespace Hutopy.Web.Features.Users.Handlers; [PublicAPI] public class GetCurrentUserPortraitHandler( - IIdentityService identityService, - IBlobStorage blobStorage + IdentityService identityService, + AzureBlobStorage blobStorage ) : EndpointWithoutRequest { diff --git a/src/Web/Features/Wallets/UserTransactionDto.cs b/src/Web/Features/Wallets/UserTransactionDto.cs deleted file mode 100644 index 361dcff..0000000 --- a/src/Web/Features/Wallets/UserTransactionDto.cs +++ /dev/null @@ -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; } -} diff --git a/src/Web/Infrastructure/CustomExceptionHandler.cs b/src/Web/Infrastructure/CustomExceptionHandler.cs deleted file mode 100644 index 6f321cb..0000000 --- a/src/Web/Infrastructure/CustomExceptionHandler.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Hutopy.Application.Common.Exceptions; -using Microsoft.AspNetCore.Diagnostics; -using Microsoft.AspNetCore.Mvc; -using ProblemDetails = Microsoft.AspNetCore.Mvc.ProblemDetails; -using ValidationException = Hutopy.Application.Common.Exceptions.ValidationException; - -namespace Hutopy.Web.Infrastructure; - -public class CustomExceptionHandler : IExceptionHandler -{ - private readonly Dictionary> _exceptionHandlers; - - public CustomExceptionHandler() - { - // Register known exception types and handlers. - _exceptionHandlers = new Dictionary> - { - { typeof(ValidationException), HandleValidationException }, - { typeof(NotFoundException), HandleNotFoundException }, - { typeof(UnauthorizedAccessException), HandleUnauthorizedAccessException }, - { typeof(ForbiddenAccessException), HandleForbiddenAccessException }, - }; - } - - public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) - { - var exceptionType = exception.GetType(); - - if (!_exceptionHandlers.TryGetValue(exceptionType, out Func? value)) return false; - - await value.Invoke(httpContext, exception); - return true; - - } - - private static async Task HandleValidationException(HttpContext httpContext, Exception ex) - { - var exception = (ValidationException)ex; - - httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; - - await httpContext.Response.WriteAsJsonAsync(new ValidationProblemDetails(exception.Errors) - { - Status = StatusCodes.Status400BadRequest, - Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1" - }); - } - - private static async Task HandleNotFoundException(HttpContext httpContext, Exception ex) - { - var exception = (NotFoundException)ex; - - httpContext.Response.StatusCode = StatusCodes.Status404NotFound; - - await httpContext.Response.WriteAsJsonAsync(new ProblemDetails() - { - Status = StatusCodes.Status404NotFound, - Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4", - Title = "The specified resource was not found.", - Detail = exception.Message - }); - } - - private static async Task HandleUnauthorizedAccessException(HttpContext httpContext, Exception ex) - { - httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; - - await httpContext.Response.WriteAsJsonAsync(new ProblemDetails - { - Status = StatusCodes.Status401Unauthorized, - Title = "Unauthorized", - Type = "https://tools.ietf.org/html/rfc7235#section-3.1" - }); - } - - private static async Task HandleForbiddenAccessException(HttpContext httpContext, Exception ex) - { - httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; - - await httpContext.Response.WriteAsJsonAsync(new ProblemDetails - { - Status = StatusCodes.Status403Forbidden, - Title = "Forbidden", - Type = "https://tools.ietf.org/html/rfc7231#section-6.5.3" - }); - } -} diff --git a/src/Web/Infrastructure/EndpointGroupBase.cs b/src/Web/Infrastructure/EndpointGroupBase.cs deleted file mode 100644 index 00b4ca7..0000000 --- a/src/Web/Infrastructure/EndpointGroupBase.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Hutopy.Web.Infrastructure; - -public abstract class EndpointGroupBase -{ - public abstract void Map(WebApplication app); -} diff --git a/src/Web/Infrastructure/IEndpointRouteBuilderExtensions.cs b/src/Web/Infrastructure/IEndpointRouteBuilderExtensions.cs deleted file mode 100644 index 08badbc..0000000 --- a/src/Web/Infrastructure/IEndpointRouteBuilderExtensions.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace Hutopy.Web.Infrastructure; - -public static class IEndpointRouteBuilderExtensions -{ - public static IEndpointRouteBuilder MapGet(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern = "") - { - Guard.Against.AnonymousMethod(handler); - - builder.MapGet(pattern, handler) - .WithName(handler.Method.Name); - - return builder; - } - - public static IEndpointRouteBuilder MapPost(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern = "") - { - Guard.Against.AnonymousMethod(handler); - - builder.MapPost(pattern, handler) - .WithName(handler.Method.Name); - - return builder; - } - - public static IEndpointRouteBuilder MapPut(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern) - { - Guard.Against.AnonymousMethod(handler); - - builder.MapPut(pattern, handler) - .WithName(handler.Method.Name); - - return builder; - } - - public static IEndpointRouteBuilder MapDelete(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern) - { - Guard.Against.AnonymousMethod(handler); - - builder.MapDelete(pattern, handler) - .WithName(handler.Method.Name); - - return builder; - } -} diff --git a/src/Web/Infrastructure/MethodInfoExtensions.cs b/src/Web/Infrastructure/MethodInfoExtensions.cs deleted file mode 100644 index 342edc4..0000000 --- a/src/Web/Infrastructure/MethodInfoExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Reflection; - -namespace Hutopy.Web.Infrastructure; - -public static class MethodInfoExtensions -{ - private static bool IsAnonymous(this MethodInfo method) - { - var invalidChars = new[] { '<', '>' }; - return method.Name.Any(invalidChars.Contains); - } - - public static void AnonymousMethod(this IGuardClause guardClause, Delegate input) - { - if (input.Method.IsAnonymous()) - throw new ArgumentException("The endpoint name must be specified when using anonymous handlers."); - } -} diff --git a/src/Web/Infrastructure/WebApplicationExtensions.cs b/src/Web/Infrastructure/WebApplicationExtensions.cs deleted file mode 100644 index 7d977fc..0000000 --- a/src/Web/Infrastructure/WebApplicationExtensions.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Reflection; - -namespace Hutopy.Web.Infrastructure; - -public static class WebApplicationExtensions -{ - public static RouteGroupBuilder MapGroup(this WebApplication app, EndpointGroupBase group) - { - var groupName = group.GetType().Name; - - return app - .MapGroup($"/api/{groupName}") - .WithGroupName(groupName) - .WithTags(groupName) - .WithOpenApi(); - } - - public static WebApplication MapEndpoints(this WebApplication app) - { - var endpointGroupType = typeof(EndpointGroupBase); - - var assembly = Assembly.GetExecutingAssembly(); - - var endpointGroupTypes = assembly.GetExportedTypes() - .Where(t => t.IsSubclassOf(endpointGroupType)); - - foreach (var type in endpointGroupTypes) - { - if (Activator.CreateInstance(type) is EndpointGroupBase instance) - { - instance.Map(app); - } - } - - return app; - } -} diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 156130f..741415a 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -6,9 +6,10 @@ using Hutopy.Infrastructure.Identity; using Hutopy.Web; using Hutopy.Web.Features.Contents; using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Features.Memberships; +using Hutopy.Web.Features.Memberships.Data; using Hutopy.Web.Features.Messages; using Hutopy.Web.Features.Messages.Data; -using Hutopy.Web.Infrastructure; using Microsoft.AspNetCore.HttpOverrides; using NSwag; using NSwag.Generation.AspNetCore.Processors; @@ -51,7 +52,6 @@ builder.Services.AddCors(options => // Add services to the container. builder.Services.AddKeyVaultIfConfigured(builder.Configuration); -builder.Services.AddApplicationServices(); builder.Services.AddInfrastructureServices(builder.Configuration); builder.Services.AddWebServices(); builder.Services.AddAuthorizationAndAuthentication(builder.Configuration); @@ -59,7 +59,9 @@ builder.Services.AddAuthorizationAndAuthentication(builder.Configuration); // TODO: This old tech should be remove - need to move Facebook / Google controllers to FastEndpoints builder.Services.AddControllers(); -builder.Services.AddOpenApiDocument((configure, sp) => +builder.Services.AddOpenApiDocument(( + configure, + sp) => { configure.Title = "Hutopy API"; @@ -91,6 +93,10 @@ builder.Services.AddMessagingModule(options => options.UseNpgsql( postgresConnectionString, o => o.MigrationsHistoryTable("__EFMigrationsHistory", MessagingDbContext.SchemaName))); +builder.AddMembershipModule( + options => options.UseNpgsql( + postgresConnectionString, + o => o.MigrationsHistoryTable("__EFMigrationsHistory", MembershipDbContext.SchemaName))); builder.Services.Configure(builder.Configuration.GetRequiredSection(JwtOptions.SectionName)); @@ -111,6 +117,7 @@ app.UseAuthorization(); await app.InitialiseApplicationDatabaseAsync(); await app.InitialiseContentDbContextAsync(); await app.InitialiseMessagingDbContextAsync(); +await app.InitialiseMembershipDbContextAsync(); await app.SeedDatabaseWithTestDataOnlyIfNoDataIsPresentAsync(); // Configure the HTTP request pipeline. @@ -134,10 +141,6 @@ app.MapControllerRoute( name: "default", pattern: "{controller}/{action=Index}/{id?}"); -//TODO: validate the behavior -// app.UseExceptionHandler(); -app.MapEndpoints(); - app.UseFastEndpoints(); app.Run(); diff --git a/src/Web/Services/CurrentUser.cs b/src/Web/Services/CurrentUser.cs deleted file mode 100644 index 29003d5..0000000 --- a/src/Web/Services/CurrentUser.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Hutopy.Application.Common.Interfaces; -using Hutopy.Web.Common; - -namespace Hutopy.Web.Services; - -public class CurrentUser( - IHttpContextAccessor httpContextAccessor) - : IUser -{ - public Guid? Id => httpContextAccessor.HttpContext?.User.GetUserId(); -} diff --git a/src/Web/TestDataSeeder.cs b/src/Web/TestDataSeeder.cs index b2f624f..b6b599b 100644 --- a/src/Web/TestDataSeeder.cs +++ b/src/Web/TestDataSeeder.cs @@ -49,7 +49,7 @@ internal class TestDataSeeder( creator.Id = creatorUser.Id; creator.CreatedBy = creator.Id; - await contentContext.Subscriptions.AddAsync(new Subscription + await contentContext.Followers.AddAsync(new Follower { CreatedBy = userA.Id, CreatorId = creator.Id }); diff --git a/src/Web/Web.http b/src/Web/Web.http index acbc0c9..a56ea97 100644 --- a/src/Web/Web.http +++ b/src/Web/Web.http @@ -75,14 +75,28 @@ Authorization: Bearer {{auth_token}} Content-Type: application/json { - "Primary" : "#fffff0", - "Secondary" : "#fffff0", - "Background" : "#fffff0", - "Surface" : "#fffff0", - "Error" : "#fffff0", - "OnPrimary" : "#fffff0", - "OnSecondary" : "#fffff0", - "OnBackground" : "#fffff0", - "OnSurface" : "#fffff0", - "OnError" : "#fffff0" + "Primary": "#fffff0", + "Secondary": "#fffff0", + "Background": "#fffff0", + "Surface": "#fffff0", + "Error": "#fffff0", + "OnPrimary": "#fffff0", + "OnSecondary": "#fffff0", + "OnBackground": "#fffff0", + "OnSurface": "#fffff0", + "OnError": "#fffff0" +} + + +### +# GET /api/tips/{CreatorId} +POST {{base_url}}/api/tips/8590ba59-58a7-4466-bb50-08dcb5e47c6d/ +Authorization: Bearer {{auth_token}} +Content-Type: application/json + +{ + "amount" : 12300, + "creatorId" : "9a150dea-edda-4b85-f17a-08dce560fa5c", + "currency" : "CAD", + "message" : "TEST" } \ No newline at end of file diff --git a/src/Web/appsettings.Development.json b/src/Web/appsettings.Development.json index d23ea44..be6e89a 100644 --- a/src/Web/appsettings.Development.json +++ b/src/Web/appsettings.Development.json @@ -21,6 +21,8 @@ } }, "Stripe": { - "apiKey": "sk_test_51OoveVDrRyqXtNdBaOs1DFFja0XhrQtJoAo83uSySMuqw4Wyt9NsuugrIHRqet9a50cr5GvolpTP8EZuTSttcgYx00gOUPNDoI" + "SecretKey": "sk_test_51OoveVDrRyqXtNdBaOs1DFFja0XhrQtJoAo83uSySMuqw4Wyt9NsuugrIHRqet9a50cr5GvolpTP8EZuTSttcgYx00gOUPNDoI", + "WebhookSecret": "whsec_cee07ef14cf784850cab63567048b5326fec7fd29c03f4659476524f8299aff1", + "HutopyRate": 0.05 } } \ No newline at end of file diff --git a/stripe.exe b/stripe.exe new file mode 100644 index 0000000..e7dab0e Binary files /dev/null and b/stripe.exe differ diff --git a/tests/Application.FunctionalTests/CustomWebApplicationFactory.cs b/tests/Application.FunctionalTests/CustomWebApplicationFactory.cs index c1d8a80..b8abf73 100644 --- a/tests/Application.FunctionalTests/CustomWebApplicationFactory.cs +++ b/tests/Application.FunctionalTests/CustomWebApplicationFactory.cs @@ -1,5 +1,4 @@ using System.Data.Common; -using Hutopy.Application.Common.Interfaces; using Hutopy.Infrastructure.Data; using Hutopy.Web; using Microsoft.AspNetCore.Hosting; @@ -14,29 +13,20 @@ namespace Hutopy.Application.FunctionalTests; using static Testing; -public class CustomWebApplicationFactory : WebApplicationFactory +public class CustomWebApplicationFactory( + DbConnection connection) + : WebApplicationFactory { - private readonly DbConnection _connection; - - public CustomWebApplicationFactory(DbConnection connection) - { - _connection = connection; - } - protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureTestServices(services => { - services - .RemoveAll() - .AddTransient(provider => Mock.Of(s => s.Id == GetUserId())); - services .RemoveAll>() .AddDbContext((sp, options) => { options.AddInterceptors(sp.GetServices()); - options.UseSqlServer(_connection); + options.UseSqlServer(connection); }); }); } diff --git a/tests/Application.UnitTests/Application.UnitTests.csproj b/tests/Application.UnitTests/Application.UnitTests.csproj deleted file mode 100644 index 805128b..0000000 --- a/tests/Application.UnitTests/Application.UnitTests.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - Hutopy.Application.UnitTests - Hutopy.Application.UnitTests - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - diff --git a/tests/Application.UnitTests/Common/Behaviours/RequestLoggerTests.cs b/tests/Application.UnitTests/Common/Behaviours/RequestLoggerTests.cs deleted file mode 100644 index a6fc766..0000000 --- a/tests/Application.UnitTests/Common/Behaviours/RequestLoggerTests.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Hutopy.Application.Common.Interfaces; -using Moq; -using NUnit.Framework; - -namespace Hutopy.Application.UnitTests.Common.Behaviours; - -public class RequestLoggerTests -{ - private Mock _user = null!; - private Mock _identityService = null!; - - [SetUp] - public void Setup() - { - _user = new Mock(); - _identityService = new Mock(); - } -} diff --git a/tests/Application.UnitTests/Common/Exceptions/ValidationExceptionTests.cs b/tests/Application.UnitTests/Common/Exceptions/ValidationExceptionTests.cs deleted file mode 100644 index c4fcbb4..0000000 --- a/tests/Application.UnitTests/Common/Exceptions/ValidationExceptionTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Hutopy.Application.Common.Exceptions; -using FluentAssertions; -using FluentValidation.Results; -using NUnit.Framework; - -namespace Hutopy.Application.UnitTests.Common.Exceptions; - -public class ValidationExceptionTests -{ - [Test] - public void DefaultConstructorCreatesAnEmptyErrorDictionary() - { - var actual = new ValidationException().Errors; - - actual.Keys.Should().BeEquivalentTo(Array.Empty()); - } - - [Test] - public void SingleValidationFailureCreatesASingleElementErrorDictionary() - { - var failures = new List - { - new ValidationFailure("Age", "must be over 18"), - }; - - var actual = new ValidationException(failures).Errors; - - actual.Keys.Should().BeEquivalentTo(new string[] { "Age" }); - actual["Age"].Should().BeEquivalentTo(new string[] { "must be over 18" }); - } - - [Test] - public void MulitpleValidationFailureForMultiplePropertiesCreatesAMultipleElementErrorDictionaryEachWithMultipleValues() - { - var failures = new List - { - new ValidationFailure("Age", "must be 18 or older"), - new ValidationFailure("Age", "must be 25 or younger"), - new ValidationFailure("Password", "must contain at least 8 characters"), - new ValidationFailure("Password", "must contain a digit"), - new ValidationFailure("Password", "must contain upper case letter"), - new ValidationFailure("Password", "must contain lower case letter"), - }; - - var actual = new ValidationException(failures).Errors; - - actual.Keys.Should().BeEquivalentTo(new string[] { "Password", "Age" }); - - actual["Age"].Should().BeEquivalentTo(new string[] - { - "must be 25 or younger", - "must be 18 or older", - }); - - actual["Password"].Should().BeEquivalentTo(new string[] - { - "must contain lower case letter", - "must contain upper case letter", - "must contain at least 8 characters", - "must contain a digit", - }); - } -} diff --git a/tests/Application.UnitTests/Common/Mappings/MappingTests.cs b/tests/Application.UnitTests/Common/Mappings/MappingTests.cs deleted file mode 100644 index e95ed50..0000000 --- a/tests/Application.UnitTests/Common/Mappings/MappingTests.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using AutoMapper; -using Hutopy.Application.Common.Interfaces; -using NUnit.Framework; - -namespace Hutopy.Application.UnitTests.Common.Mappings; - -public class MappingTests -{ - private readonly IConfigurationProvider _configuration; - private readonly IMapper _mapper; - - public MappingTests() - { - _configuration = new MapperConfiguration(config => - config.AddMaps(Assembly.GetAssembly(typeof(IApplicationDbContext)))); - - _mapper = _configuration.CreateMapper(); - } - - [Test] - public void ShouldHaveValidConfiguration() - { - _configuration.AssertConfigurationIsValid(); - } - - private object GetInstanceOf(Type type) - { - if (type.GetConstructor(Type.EmptyTypes) != null) - return Activator.CreateInstance(type)!; - - // Type without parameterless constructor - return RuntimeHelpers.GetUninitializedObject(type); - } -} diff --git a/update-databases.sh b/update-databases.sh index 2203b11..4b81d45 100644 --- a/update-databases.sh +++ b/update-databases.sh @@ -20,3 +20,10 @@ dotnet ef database update \ --context Hutopy.Web.Features.Contents.Data.ContentDbContext \ --configuration Debug \ --no-build + +dotnet ef database update \ + --project src/Web/Web.csproj \ + --context Hutopy.Web.Features.Memberships.Data.MembershipDbContext \ + --configuration Debug \ + --no-build +