diff --git a/Eternelle.slnx b/Eternelle.slnx index b2c3b55..933c179 100644 --- a/Eternelle.slnx +++ b/Eternelle.slnx @@ -1,4 +1,19 @@ + + + + + + + + + + + + + + + @@ -15,6 +30,13 @@ + + + + + + + diff --git a/src/API/Eternelle.Api/Eternelle.Api.csproj b/src/API/Eternelle.Api/Eternelle.Api.csproj index cc488d7..991e197 100644 --- a/src/API/Eternelle.Api/Eternelle.Api.csproj +++ b/src/API/Eternelle.Api/Eternelle.Api.csproj @@ -21,6 +21,7 @@ + diff --git a/src/API/Eternelle.Api/Extensions/MigrationExtensions.cs b/src/API/Eternelle.Api/Extensions/MigrationExtensions.cs index b56f3b5..0d62e02 100644 --- a/src/API/Eternelle.Api/Extensions/MigrationExtensions.cs +++ b/src/API/Eternelle.Api/Extensions/MigrationExtensions.cs @@ -1,3 +1,4 @@ +using Eternelle.Modules.Catalog.Infrastructure.Database; using Eternelle.Modules.Users.Infrastructure.Database; using Eternelle.Modules.Weddings.Infrastructure.Database; using Microsoft.EntityFrameworkCore; @@ -12,6 +13,7 @@ public static void ApplyMigrations(this IApplicationBuilder app) ApplyMigration(scope); ApplyMigration(scope); + ApplyMigration(scope); } private static void ApplyMigration(IServiceScope scope) diff --git a/src/API/Eternelle.Api/Program.cs b/src/API/Eternelle.Api/Program.cs index b77968e..df3a262 100644 --- a/src/API/Eternelle.Api/Program.cs +++ b/src/API/Eternelle.Api/Program.cs @@ -6,6 +6,7 @@ using Eternelle.Common.Infrastructure; using Eternelle.Common.Infrastructure.Configuration; using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Modules.Catalog.Infrastructure; using Eternelle.Modules.Users.Infrastructure; using Eternelle.Modules.Weddings.Infrastructure; using Eternelle.Modules.Weddings.Presentation; @@ -25,7 +26,8 @@ builder.Services.AddApplication([ Eternelle.Modules.Weddings.Application.AssemblyReference.Assembly, - Eternelle.Modules.Users.Application.AssemblyReference.Assembly + Eternelle.Modules.Users.Application.AssemblyReference.Assembly, + Eternelle.Modules.Catalog.Application.AssemblyReference.Assembly ]); string databaseConnectionString = builder.Configuration.GetConnectionStringOrThrow("Database"); @@ -37,7 +39,7 @@ databaseConnectionString: databaseConnectionString, redisConnectionString: redisConnectionString); -builder.Configuration.AddModuleConfiguration(["weddings", "users"]); +builder.Configuration.AddModuleConfiguration(["weddings", "users", "catalog"]); Uri keyCloakHealthUrl = builder.Configuration.GetKeyCloakHealthUrl(); @@ -50,6 +52,8 @@ builder.Services.AddUsersModule(builder.Configuration); +builder.Services.AddCatalogModule(builder.Configuration); + builder.Services.AddRateLimiter(options => { // IP-based fixed-window limiter for the public guest photo upload endpoint. diff --git a/src/API/Eternelle.Api/modules.catalog.Development.json b/src/API/Eternelle.Api/modules.catalog.Development.json new file mode 100644 index 0000000..61dfc8c --- /dev/null +++ b/src/API/Eternelle.Api/modules.catalog.Development.json @@ -0,0 +1,8 @@ +{ + "Catalog": { + "Outbox": { + "IntervalInSeconds": 10, + "BatchSize": 10 + } + } +} diff --git a/src/API/Eternelle.Api/modules.catalog.json b/src/API/Eternelle.Api/modules.catalog.json new file mode 100644 index 0000000..61dfc8c --- /dev/null +++ b/src/API/Eternelle.Api/modules.catalog.json @@ -0,0 +1,8 @@ +{ + "Catalog": { + "Outbox": { + "IntervalInSeconds": 10, + "BatchSize": 10 + } + } +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Abstractions/Data/IUnitOfWork.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Abstractions/Data/IUnitOfWork.cs new file mode 100644 index 0000000..a47c928 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Abstractions/Data/IUnitOfWork.cs @@ -0,0 +1,6 @@ +namespace Eternelle.Modules.Catalog.Application.Abstractions.Data; + +public interface IUnitOfWork +{ + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/AssemblyReference.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/AssemblyReference.cs new file mode 100644 index 0000000..5600467 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/AssemblyReference.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace Eternelle.Modules.Catalog.Application; + +public static class AssemblyReference +{ + public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly; +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Eternelle.Modules.Catalog.Application.csproj b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Eternelle.Modules.Catalog.Application.csproj new file mode 100644 index 0000000..a6c704f --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Eternelle.Modules.Catalog.Application.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSectionVariant/AddSectionVariantCommand.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSectionVariant/AddSectionVariantCommand.cs new file mode 100644 index 0000000..3f7a747 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSectionVariant/AddSectionVariantCommand.cs @@ -0,0 +1,12 @@ +using Eternelle.Common.Application.Messaging; +using Eternelle.Modules.Catalog.Domain.Templates; + +namespace Eternelle.Modules.Catalog.Application.Templates.AddSectionVariant; + +public sealed record AddSectionVariantCommand( + Guid TemplateId, + SectionId SectionId, + string Variant, + bool IsDefault, + PriceClass PriceClass, + int? UploadLimit) : ICommand; diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSectionVariant/AddSectionVariantCommandHandler.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSectionVariant/AddSectionVariantCommandHandler.cs new file mode 100644 index 0000000..4dcd906 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSectionVariant/AddSectionVariantCommandHandler.cs @@ -0,0 +1,39 @@ +using Eternelle.Common.Application.Caching; +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Catalog.Application.Abstractions.Data; +using Eternelle.Modules.Catalog.Domain.Templates; + +namespace Eternelle.Modules.Catalog.Application.Templates.AddSectionVariant; + +internal sealed class AddSectionVariantCommandHandler( + ITemplateRepository templateRepository, + IUnitOfWork unitOfWork, + ICacheService cacheService, + IDateTimeProvider dateTimeProvider) : ICommandHandler +{ + public async Task Handle(AddSectionVariantCommand command, CancellationToken cancellationToken) + { + Template? template = await templateRepository.GetAsync(new TemplateId(command.TemplateId), cancellationToken); + if (template is null) + { + return Result.Failure(TemplateErrors.NotFound(new TemplateId(command.TemplateId))); + } + + Result addResult = template.AddSectionVariant( + command.SectionId, command.Variant.Trim(), command.IsDefault, command.PriceClass, + command.UploadLimit, dateTimeProvider.UtcNow); + if (addResult.IsFailure) + { + return addResult; + } + + templateRepository.Update(template); + await unitOfWork.SaveChangesAsync(cancellationToken); + + await cacheService.RemoveAsync(CatalogCacheKeys.TemplateById(command.TemplateId), cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSectionVariant/AddSectionVariantCommandValidator.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSectionVariant/AddSectionVariantCommandValidator.cs new file mode 100644 index 0000000..f1e3654 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSectionVariant/AddSectionVariantCommandValidator.cs @@ -0,0 +1,16 @@ +using Eternelle.Modules.Catalog.Domain.Templates; +using FluentValidation; + +namespace Eternelle.Modules.Catalog.Application.Templates.AddSectionVariant; + +internal sealed class AddSectionVariantCommandValidator : AbstractValidator +{ + public AddSectionVariantCommandValidator() + { + RuleFor(c => c.TemplateId).NotEmpty(); + RuleFor(c => c.SectionId).IsInEnum(); + RuleFor(c => c.Variant).NotEmpty().MaximumLength(TemplateSectionVariant.MaxVariantLength); + RuleFor(c => c.PriceClass).IsInEnum(); + RuleFor(c => c.UploadLimit).GreaterThan(0).When(c => c.UploadLimit is not null); + } +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSupportedSection/AddSupportedSectionCommand.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSupportedSection/AddSupportedSectionCommand.cs new file mode 100644 index 0000000..68cfaf0 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSupportedSection/AddSupportedSectionCommand.cs @@ -0,0 +1,9 @@ +using Eternelle.Common.Application.Messaging; +using Eternelle.Modules.Catalog.Domain.Templates; + +namespace Eternelle.Modules.Catalog.Application.Templates.AddSupportedSection; + +public sealed record AddSupportedSectionCommand( + Guid TemplateId, + SectionId SectionId, + int DisplayOrder) : ICommand; diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSupportedSection/AddSupportedSectionCommandHandler.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSupportedSection/AddSupportedSectionCommandHandler.cs new file mode 100644 index 0000000..a988833 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSupportedSection/AddSupportedSectionCommandHandler.cs @@ -0,0 +1,37 @@ +using Eternelle.Common.Application.Caching; +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Catalog.Application.Abstractions.Data; +using Eternelle.Modules.Catalog.Domain.Templates; + +namespace Eternelle.Modules.Catalog.Application.Templates.AddSupportedSection; + +internal sealed class AddSupportedSectionCommandHandler( + ITemplateRepository templateRepository, + IUnitOfWork unitOfWork, + ICacheService cacheService, + IDateTimeProvider dateTimeProvider) : ICommandHandler +{ + public async Task Handle(AddSupportedSectionCommand command, CancellationToken cancellationToken) + { + Template? template = await templateRepository.GetAsync(new TemplateId(command.TemplateId), cancellationToken); + if (template is null) + { + return Result.Failure(TemplateErrors.NotFound(new TemplateId(command.TemplateId))); + } + + Result addResult = template.AddSupportedSection(command.SectionId, command.DisplayOrder, dateTimeProvider.UtcNow); + if (addResult.IsFailure) + { + return addResult; + } + + templateRepository.Update(template); + await unitOfWork.SaveChangesAsync(cancellationToken); + + await cacheService.RemoveAsync(CatalogCacheKeys.TemplateById(command.TemplateId), cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSupportedSection/AddSupportedSectionCommandValidator.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSupportedSection/AddSupportedSectionCommandValidator.cs new file mode 100644 index 0000000..be601cc --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddSupportedSection/AddSupportedSectionCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Eternelle.Modules.Catalog.Application.Templates.AddSupportedSection; + +internal sealed class AddSupportedSectionCommandValidator : AbstractValidator +{ + public AddSupportedSectionCommandValidator() + { + RuleFor(c => c.TemplateId).NotEmpty(); + RuleFor(c => c.SectionId).IsInEnum(); + RuleFor(c => c.DisplayOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddTemplateTheme/AddTemplateThemeCommand.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddTemplateTheme/AddTemplateThemeCommand.cs new file mode 100644 index 0000000..4b5806e --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddTemplateTheme/AddTemplateThemeCommand.cs @@ -0,0 +1,12 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Catalog.Application.Templates.AddTemplateTheme; + +public sealed record AddTemplateThemeCommand( + Guid TemplateId, + int ThemeIndex, + string Name, + IReadOnlyDictionary Colors, + IReadOnlyDictionary Typography, + IReadOnlyDictionary? Textures, + bool IsDark) : ICommand; diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddTemplateTheme/AddTemplateThemeCommandHandler.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddTemplateTheme/AddTemplateThemeCommandHandler.cs new file mode 100644 index 0000000..5dca8fc --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddTemplateTheme/AddTemplateThemeCommandHandler.cs @@ -0,0 +1,48 @@ +using Eternelle.Common.Application.Caching; +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Catalog.Application.Abstractions.Data; +using Eternelle.Modules.Catalog.Domain.Templates; + +namespace Eternelle.Modules.Catalog.Application.Templates.AddTemplateTheme; + +internal sealed class AddTemplateThemeCommandHandler( + ITemplateRepository templateRepository, + IUnitOfWork unitOfWork, + ICacheService cacheService, + IDateTimeProvider dateTimeProvider) : ICommandHandler +{ + public async Task Handle(AddTemplateThemeCommand command, CancellationToken cancellationToken) + { + Template? template = await templateRepository.GetAsync(new TemplateId(command.TemplateId), cancellationToken); + if (template is null) + { + return Result.Failure(TemplateErrors.NotFound(new TemplateId(command.TemplateId))); + } + + Result nameResult = TemplateThemeName.Create(command.Name); + if (nameResult.IsFailure) + { + return Result.Failure(nameResult.Error); + } + + var colors = new ThemeColors(command.Colors); + var typography = new ThemeTypography(command.Typography); + ThemeTextures? textures = command.Textures is null ? null : new ThemeTextures(command.Textures); + + Result addResult = template.AddTheme( + command.ThemeIndex, nameResult.Value, colors, typography, textures, command.IsDark, dateTimeProvider.UtcNow); + if (addResult.IsFailure) + { + return addResult; + } + + templateRepository.Update(template); + await unitOfWork.SaveChangesAsync(cancellationToken); + + await cacheService.RemoveAsync(CatalogCacheKeys.TemplateById(command.TemplateId), cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddTemplateTheme/AddTemplateThemeCommandValidator.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddTemplateTheme/AddTemplateThemeCommandValidator.cs new file mode 100644 index 0000000..b135666 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/AddTemplateTheme/AddTemplateThemeCommandValidator.cs @@ -0,0 +1,17 @@ +using Eternelle.Modules.Catalog.Domain.Templates; +using FluentValidation; + +namespace Eternelle.Modules.Catalog.Application.Templates.AddTemplateTheme; + +internal sealed class AddTemplateThemeCommandValidator : AbstractValidator +{ + public AddTemplateThemeCommandValidator() + { + RuleFor(c => c.TemplateId).NotEmpty(); + RuleFor(c => c.ThemeIndex).GreaterThanOrEqualTo(0); + RuleFor(c => c.Name).NotEmpty().MaximumLength(TemplateThemeName.MaxLength); + RuleFor(c => c.Colors).NotEmpty(); + RuleFor(c => c.Typography).NotEmpty(); + RuleFor(c => c.Textures).NotEmpty().When(c => c.Textures is not null); + } +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/CatalogCacheKeys.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/CatalogCacheKeys.cs new file mode 100644 index 0000000..3c462ce --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/CatalogCacheKeys.cs @@ -0,0 +1,18 @@ +using Eternelle.Modules.Catalog.Domain.Templates; + +namespace Eternelle.Modules.Catalog.Application.Templates; + +internal static class CatalogCacheKeys +{ + public static string TemplateById(Guid templateId) => $"catalog:template:{templateId}"; + + public static string TemplateList(TemplateTier? tier) => + tier is null ? "catalog:templates:all" : $"catalog:templates:{tier}"; + + public static IReadOnlyList AllTemplateListKeys() => + [ + TemplateList(null), + TemplateList(TemplateTier.FirstClass), + TemplateList(TemplateTier.Signature) + ]; +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/CreateTemplate/CreateTemplateCommand.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/CreateTemplate/CreateTemplateCommand.cs new file mode 100644 index 0000000..8af09bb --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/CreateTemplate/CreateTemplateCommand.cs @@ -0,0 +1,11 @@ +using Eternelle.Common.Application.Messaging; +using Eternelle.Modules.Catalog.Domain.Templates; + +namespace Eternelle.Modules.Catalog.Application.Templates.CreateTemplate; + +public sealed record CreateTemplateCommand( + string Name, + string? Description, + TemplateTier Tier, + string? PreviewImageUrl, + IReadOnlyList? Features) : ICommand; diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/CreateTemplate/CreateTemplateCommandHandler.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/CreateTemplate/CreateTemplateCommandHandler.cs new file mode 100644 index 0000000..375f5fb --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/CreateTemplate/CreateTemplateCommandHandler.cs @@ -0,0 +1,84 @@ +using Eternelle.Common.Application.Caching; +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Catalog.Application.Abstractions.Data; +using Eternelle.Modules.Catalog.Domain.Shared; +using Eternelle.Modules.Catalog.Domain.Templates; +using Microsoft.Extensions.Logging; + +namespace Eternelle.Modules.Catalog.Application.Templates.CreateTemplate; + +internal sealed class CreateTemplateCommandHandler( + ITemplateRepository templateRepository, + IUnitOfWork unitOfWork, + ICacheService cacheService, + IDateTimeProvider dateTimeProvider, + ILogger logger) : ICommandHandler +{ + public async Task> Handle(CreateTemplateCommand command, CancellationToken cancellationToken) + { + Result nameResult = TemplateName.Create(command.Name); + if (nameResult.IsFailure) + { + return Result.Failure(nameResult.Error); + } + + TemplateDescription? description = null; + if (command.Description is not null) + { + Result descriptionResult = TemplateDescription.Create(command.Description); + if (descriptionResult.IsFailure) + { + return Result.Failure(descriptionResult.Error); + } + description = descriptionResult.Value; + } + + ImageUrl? previewImageUrl = null; + if (command.PreviewImageUrl is not null) + { + Result previewResult = ImageUrl.Create(command.PreviewImageUrl); + if (previewResult.IsFailure) + { + return Result.Failure(previewResult.Error); + } + previewImageUrl = previewResult.Value; + } + + if (await templateRepository.NameExistsAsync(nameResult.Value.Value, cancellationToken)) + { + return Result.Failure(TemplateErrors.NameTaken); + } + + Result