Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
1b819b6
feat(catalog): scaffold five-project module structure
engrkwakwak May 27, 2026
75139b7
feat(catalog): add TemplateTier, PriceClass, SectionId enums
engrkwakwak May 27, 2026
ab649c5
feat(catalog): add strongly-typed ids for Template and children
engrkwakwak May 27, 2026
e88b1bd
feat(catalog): add value objects and errors
engrkwakwak May 27, 2026
e159b63
feat(catalog): add jsonb theme styling records
engrkwakwak May 27, 2026
5567a65
feat(catalog): add Template child entities with variant upload limit
engrkwakwak May 27, 2026
d503809
feat(catalog): add Template domain events and manifest payload
engrkwakwak May 27, 2026
f1ca0ce
feat(catalog): add TemplateErrors
engrkwakwak May 27, 2026
8b245d6
feat(catalog): add Template aggregate root
engrkwakwak May 27, 2026
ba49d6e
feat(catalog): add ITemplateRepository
engrkwakwak May 27, 2026
0eb10cf
style(catalog): remove what-comment from UploadLimit property
engrkwakwak May 27, 2026
724f3fe
Eternelle-180 Add CatalogTemplateManifestChanged integration event
engrkwakwak May 27, 2026
29b8351
Eternelle-180 Add IUnitOfWork and manifest domain-event handler
engrkwakwak May 27, 2026
033c22c
Eternelle-180 Add CreateTemplate command and cache keys
engrkwakwak May 27, 2026
5027920
Eternelle-180 Add UpdateTemplate command
engrkwakwak May 27, 2026
09703e6
Eternelle-180 Add AddTemplateTheme command
engrkwakwak May 27, 2026
8c828d5
Eternelle-180 Add AddSupportedSection command
engrkwakwak May 27, 2026
942a68b
Eternelle-180 Add AddSectionVariant command with upload limit
engrkwakwak May 27, 2026
febdab8
Eternelle-180 Add GetTemplates query
engrkwakwak May 27, 2026
af8134e
Eternelle-180 Add GetTemplateById query
engrkwakwak May 27, 2026
231b6b5
Eternelle-180 Add GetSupportedSections and GetSectionVariants queries
engrkwakwak May 27, 2026
e7f88ee
Eternelle-180 Add CatalogDbContext, id converters, and entity configu…
engrkwakwak May 27, 2026
e4d23ee
Eternelle-180 Add TemplateRepository
engrkwakwak May 27, 2026
bf5d704
Eternelle-180 Add outbox plumbing and CatalogModule DI
engrkwakwak May 27, 2026
70bf5ea
Eternelle-180 Add presentation layer endpoints (Tasks 26-27)
engrkwakwak May 27, 2026
6be8d99
Eternelle-180 Wire Catalog module into the API host and add initial m…
engrkwakwak May 27, 2026
23873b5
Eternelle-180 Seed catalog:manage permission for Admin role
engrkwakwak May 27, 2026
0d94173
Eternelle-180 Apply file-scoped namespace and collection expression f…
engrkwakwak May 27, 2026
7ec9a03
Eternelle-180 Add migration support for CatalogDbContext
engrkwakwak May 27, 2026
fef0171
Eternelle-180 Address code review findings on Template aggregate and …
engrkwakwak May 27, 2026
8169c19
Eternelle-180 Refactor init patterns, migrations, and add comments
engrkwakwak May 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Eternelle.slnx
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
<Solution>
<Folder Name="/docs/">
<File Path="docs/feature-gift-contributions.md" />
<File Path="docs/feature-guest-photo-upload.md" />
<File Path="docs/module-conventions.md" />
</Folder>
<Folder Name="/docs/superpowers/" />
<Folder Name="/docs/superpowers/plans/">
<File Path="docs/superpowers/plans/2026-05-20-users-module.md" />
<File Path="docs/superpowers/plans/2026-05-24-weddings-inbox-outbox-alignment.md" />
<File Path="docs/superpowers/plans/2026-05-27-catalog-module.md" />
</Folder>
<Folder Name="/docs/superpowers/specs/">
<File Path="docs/superpowers/specs/2026-05-20-users-module-design.md" />
<File Path="docs/superpowers/specs/2026-05-27-catalog-module-design.md" />
</Folder>
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
<File Path="Directory.Build.props" />
Expand All @@ -15,6 +30,13 @@
<Project Path="src/Common/Eternelle.Common.Presentation/Eternelle.Common.Presentation.csproj" Id="54cfc49d-e0bb-44a0-84ac-ee35515c1b5e" />
</Folder>
<Folder Name="/src/Modules/" />
<Folder Name="/src/Modules/Catalog/">
<Project Path="src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Eternelle.Modules.Catalog.Application.csproj" />
<Project Path="src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Eternelle.Modules.Catalog.Domain.csproj" />
<Project Path="src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Eternelle.Modules.Catalog.Infrastructure.csproj" />
<Project Path="src/Modules/Catalog/Eternelle.Modules.Catalog.IntegrationEvents/Eternelle.Modules.Catalog.IntegrationEvents.csproj" />
<Project Path="src/Modules/Catalog/Eternelle.Modules.Catalog.Presentation/Eternelle.Modules.Catalog.Presentation.csproj" />
</Folder>
<Folder Name="/src/Modules/Users/">
<Project Path="src/Modules/Users/Eternelle.Modules.Users.Application/Eternelle.Modules.Users.Application.csproj" Id="27530601-1743-4d73-80d3-bddbf28d8eb2" />
<Project Path="src/Modules/Users/Eternelle.Modules.Users.Domain/Eternelle.Modules.Users.Domain.csproj" Id="823bb7bc-2382-4588-a404-73ae27f49b02" />
Expand Down
1 change: 1 addition & 0 deletions src/API/Eternelle.Api/Eternelle.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\Modules\Catalog\Eternelle.Modules.Catalog.Infrastructure\Eternelle.Modules.Catalog.Infrastructure.csproj" />
<ProjectReference Include="..\..\Modules\Users\Eternelle.Modules.Users.Infrastructure\Eternelle.Modules.Users.Infrastructure.csproj" />
<ProjectReference Include="..\..\Modules\Weddings\Eternelle.Modules.Weddings.Infrastructure\Eternelle.Modules.Weddings.Infrastructure.csproj" />
</ItemGroup>
Expand Down
8 changes: 6 additions & 2 deletions src/API/Eternelle.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
Expand All @@ -37,7 +39,7 @@
databaseConnectionString: databaseConnectionString,
redisConnectionString: redisConnectionString);

builder.Configuration.AddModuleConfiguration(["weddings", "users"]);
builder.Configuration.AddModuleConfiguration(["weddings", "users", "catalog"]);

Uri keyCloakHealthUrl = builder.Configuration.GetKeyCloakHealthUrl();

Expand All @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/API/Eternelle.Api/modules.catalog.Development.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Catalog": {
"Outbox": {
"IntervalInSeconds": 10,
"BatchSize": 10
}
}
}
8 changes: 8 additions & 0 deletions src/API/Eternelle.Api/modules.catalog.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Catalog": {
"Outbox": {
"IntervalInSeconds": 10,
"BatchSize": 10
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Eternelle.Modules.Catalog.Application.Abstractions.Data;

public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Reflection;

namespace Eternelle.Modules.Catalog.Application;

public static class AssemblyReference
{
public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<ProjectReference Include="..\Eternelle.Modules.Catalog.Domain\Eternelle.Modules.Catalog.Domain.csproj" />
<ProjectReference Include="..\Eternelle.Modules.Catalog.IntegrationEvents\Eternelle.Modules.Catalog.IntegrationEvents.csproj" />
<ProjectReference Include="..\..\..\Common\Eternelle.Common.Application\Eternelle.Common.Application.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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<AddSectionVariantCommand>
{
public async Task<Result> 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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using FluentValidation;

namespace Eternelle.Modules.Catalog.Application.Templates.AddSectionVariant;

internal sealed class AddSectionVariantCommandValidator : AbstractValidator<AddSectionVariantCommand>
{
public AddSectionVariantCommandValidator()
{
RuleFor(c => c.TemplateId).NotEmpty();
RuleFor(c => c.SectionId).IsInEnum();
RuleFor(c => c.Variant).NotEmpty().MaximumLength(100);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
RuleFor(c => c.PriceClass).IsInEnum();
RuleFor(c => c.UploadLimit).GreaterThan(0).When(c => c.UploadLimit is not null);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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<AddSupportedSectionCommand>
{
public async Task<Result> 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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using FluentValidation;

namespace Eternelle.Modules.Catalog.Application.Templates.AddSupportedSection;

internal sealed class AddSupportedSectionCommandValidator : AbstractValidator<AddSupportedSectionCommand>
{
public AddSupportedSectionCommandValidator()
{
RuleFor(c => c.TemplateId).NotEmpty();
RuleFor(c => c.SectionId).IsInEnum();
RuleFor(c => c.DisplayOrder).GreaterThanOrEqualTo(0);
}
}
Original file line number Diff line number Diff line change
@@ -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<string, string> Colors,
IReadOnlyDictionary<string, string> Typography,
IReadOnlyDictionary<string, string>? Textures,
bool IsDark) : ICommand;
Original file line number Diff line number Diff line change
@@ -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<AddTemplateThemeCommand>
{
public async Task<Result> 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<TemplateThemeName> 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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Eternelle.Modules.Catalog.Domain.Templates;
using FluentValidation;

namespace Eternelle.Modules.Catalog.Application.Templates.AddTemplateTheme;

internal sealed class AddTemplateThemeCommandValidator : AbstractValidator<AddTemplateThemeCommand>
{
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();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -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<string> AllTemplateListKeys() =>
[
TemplateList(null),
TemplateList(TemplateTier.FirstClass),
TemplateList(TemplateTier.Signature)
];
}
Original file line number Diff line number Diff line change
@@ -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<string>? Features) : ICommand<Guid>;
Loading