From 83af3206aec9e2238e5ddb026136f4907c7df298 Mon Sep 17 00:00:00 2001 From: Carl Marion <132995625+engrkwakwak@users.noreply.github.com> Date: Sun, 24 May 2026 11:57:56 +0800 Subject: [PATCH 001/155] Eternelle-176 Strengthen code review and DDD enforcement in .coderabbit.yaml (#178) * Eternelle-176 Strengthen code review and DDD enforcement in .coderabbit.yaml - Refined tone and review instructions for Clean Architecture boundaries - Added granular PR labeling and path-based review rules per layer - Enforced DDD conventions: const MaxLength, event handler dependencies, IDOR guards - Introduced custom automated checks for architectural compliance - Disabled docstring warnings; set PR title requirements - Added C# XML doc comment generation guidance - Enabled additional security and quality tools - Excluded build/IDE artifacts from review; updated SQL schema rules * Update .coderabbit.yaml review checks and docstring mode Revised custom review instructions to enforce new .cs file conventions, focusing on aggregate behavior, domain error handling, handler return types, and cross-module reference types. Replaced previous checks and set docstring pre-merge check mode to warning. --- .coderabbit.yaml | 143 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 112 insertions(+), 31 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index c74c1d7..66c124b 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,5 +1,5 @@ language: en-US -tone_instructions: ".NET 10 modular monolith, DDD, Philippine wedding platform. Use precise DDD terms: aggregates, value objects, domain events, repositories. Enforce Clean Architecture boundaries strictly. Concise, actionable feedback only." +tone_instructions: ".NET 10 modular monolith, DDD, Philippine wedding platform. Use precise DDD terms: aggregates, value objects, domain events, repositories. Enforce Clean Architecture layer boundaries. Concise, actionable feedback only." early_access: false enable_free_tier: true @@ -9,6 +9,7 @@ reviews: high_level_summary: true high_level_summary_in_walkthrough: false review_status: true + review_details: false commit_status: true fail_commit_status: false collapse_walkthrough: false @@ -22,11 +23,35 @@ reviews: auto_apply_labels: false suggested_reviewers: true auto_assign_reviewers: false + in_progress_fortune: false poem: false enable_prompt_for_ai_agents: true abort_on_close: true disable_cache: false + path_filters: + - "!**/bin/**" + - "!**/obj/**" + - "!**/.vs/**" + + labeling_instructions: + - label: domain + instructions: Apply when the PR modifies files in a *.Domain project (aggregates, value objects, domain events, repository interfaces, domain errors). + - label: application + instructions: Apply when the PR modifies files in a *.Application project (commands, queries, handlers, validators, domain event handlers). + - label: infrastructure + instructions: Apply when the PR modifies files in a *.Infrastructure project (repositories, EF Core configurations, migrations, external service clients). + - label: integration-events + instructions: Apply when the PR modifies files in a *.IntegrationEvents project or adds/changes integration event handler classes. + - label: breaking-change + instructions: Apply when the PR changes an integration event contract (adds/removes/renames properties), changes a public API route or request/response shape, or removes a public interface member. + - label: migration + instructions: Apply when the PR adds or modifies EF Core migration files under Database/Migrations/. + - label: security + instructions: Apply when the PR touches authentication, authorization, IDOR guards, or secret/key handling. + - label: new-module + instructions: Apply when the PR introduces a new module (new *.Domain, *.Application, *.Infrastructure, *.Presentation, and *.IntegrationEvents projects for a previously non-existent module). + path_instructions: - path: "src/Modules/**/**.Domain/**/*.cs" instructions: > @@ -34,10 +59,11 @@ reviews: or any framework (EF Core, MediatR, Dapper, ASP.NET). Aggregates expose behaviour through methods only — no public setters outside the aggregate. Value objects must be immutable (sealed record or sealed class with private - constructor and static Create() factory). Domain errors must use the Error - type from Eternelle.Common.Domain. String length limits must be defined as - public static readonly int constants on the owning type (e.g. - Partner.MaxFirstNameLength), not magic numbers. + constructor and static Create() factory). Value object length limits must + use const int MaxLength (not static readonly — this is a monorepo compiled + from source). Domain errors must use the Error type from Eternelle.Common.Domain. + Domain events must use primary-constructor syntax and carry all data needed + for integration events at raise-time (no repository reload in handlers). - path: "src/Modules/**/**.Application/**/*.cs" instructions: > @@ -48,9 +74,13 @@ reviews: Query handlers use Dapper via IDbConnectionFactory with CommandDefinition (including cancellationToken) — never EF Core. Command handlers use domain repository interfaces and IUnitOfWork. - String MaximumLength rules must reference domain constants, not hardcoded - numbers. Reorder handlers must validate completeness and duplicates via - HashSet comparison before iterating. + String MaximumLength rules must reference domain VO const constants (e.g. + PersonName.MaxLength), not hardcoded numbers. + Domain event handlers inject only IEventBus — no repository. All + integration-event payload is sourced from the domain event properties. + IDOR guard: handlers that accept a child-resource ID must verify the loaded + entity's parent ID matches the command's parent ID, and return an opaque + NotFound error (never a permission-specific error). - path: "src/Modules/**/**.Infrastructure/**/*.cs" instructions: > @@ -59,17 +89,22 @@ reviews: have a ValueConverter registered globally in ConfigureConventions — not per-property. UseSnakeCaseNamingConvention() is active; explicit HasColumnName() is only needed when the snake_case default is wrong. - String length constants from the domain (e.g. Partner.MaxFirstNameLength) - must be referenced in HasMaxLength() — no magic numbers. + String HasMaxLength() calls must reference the domain VO const (e.g. + PersonName.MaxLength) — no magic numbers. Repositories are internal sealed classes implementing domain interfaces. + EF value converters for VOs: use FromPersistence() (internal, skips + validation) not Create() (which validates and returns Result). - path: "src/Modules/**/**.Presentation/**/*.cs" instructions: > Presentation layer. Endpoints implement IEndpoint and are registered via MapEndpoints(). Endpoints must not contain business logic — they map HTTP - input to commands/queries and dispatch via ISender. HTTP responses follow - the TypedResults / Results pattern. No direct references to domain types - in endpoint request/response models. + input to commands/queries and dispatch via ISender. Route parent IDs + (e.g. {weddingId}, {groupId}) must be explicitly bound from the route and + passed through to the command — never dropped. Use Results.Created only + when creating a single addressable resource with a Location header; + use Results.Ok for reads or batch operations. Integration event handlers + live here and are registered via AddIntegrationEventHandlers(). - path: "src/API/**/*.cs" instructions: > @@ -90,8 +125,8 @@ reviews: - path: "**/*.sql" instructions: > - All tables live in the 'wedding' schema. Column names use snake_case. - UUIDs use gen_random_uuid() or are application-generated (UUIDv7). + All tables live in named schemas (e.g. wedding, identity). Column names + use snake_case. UUIDs use gen_random_uuid() or are application-generated. Prefer text columns with application-level length enforcement unless a DB constraint is explicitly required. @@ -102,6 +137,9 @@ reviews: enabled: true auto_incremental_review: true auto_pause_after_reviewed_commits: 5 + ignore_title_keywords: + - "wip" + - "draft" drafts: false base_branches: - main @@ -115,21 +153,56 @@ reviews: enabled: true simplify: enabled: true + custom: + - name: check conventions + enabled: true + instructions: > + Review the changed .cs files for conventions not covered by pre-merge checks. + Check: (1) aggregates expose behaviour through methods only — no public setters + outside the aggregate root, (2) domain errors use Error.NotFound / Error.Problem / + Error.Conflict / Error.Failure from Eternelle.Common.Domain (never raw exceptions), + (3) handlers return Result or Result — no throw for business rule violations, + (4) cross-module references use plain Guid, never a value object from another module. + Post a concise list of any violations found; if none, confirm compliance. pre_merge_checks: override_requested_reviewers_only: false docstrings: mode: warning - threshold: 80 title: mode: warning + requirements: "Title should reference the Linear ticket (e.g. Eternelle-123) and be concise." description: mode: warning issue_assessment: mode: warning + custom_checks: + - name: IDOR route params wired + mode: error + instructions: > + For every Minimal API endpoint in the changed Presentation files: + verify that each route segment bound as a parameter (e.g. {weddingId}, + {groupId}) is explicitly passed to the corresponding command or query. + Fail if any route parameter is bound but not forwarded to the command. + - name: VO MaxLength is const + mode: error + instructions: > + For every value object (sealed record or sealed class with a static Create + factory) in the changed Domain files: verify that length limit fields are + declared as const int (e.g. public const int MaxLength = 150;) and NOT as + static readonly int. Fail if any value object uses static readonly for a + length constant. + - name: Domain event handler no repository + mode: error + instructions: > + For every DomainEventHandler in the changed Application files: + verify that the constructor does NOT inject a repository interface + (e.g. IWeddingRepository). The handler must inject only IEventBus and + source all integration-event payload from the domain event's own properties. + Fail if any domain event handler constructor has a repository parameter. tools: - # ── Relevant to this project ──────────────────────────────────────────── + # ── Relevant to this project ────────────────────────────────────────── ast-grep: essential_rules: true rule_dirs: [] @@ -146,35 +219,33 @@ reviews: yamllint: enabled: true hadolint: - enabled: true # Dockerfile present + enabled: true actionlint: - enabled: true # GitHub Actions workflows + enabled: true shellcheck: enabled: true gitleaks: - enabled: true # Secrets scanning + enabled: true trufflehog: - enabled: true # Secrets scanning + enabled: true semgrep: - enabled: true # Security patterns + enabled: true opengrep: enabled: true trivy: - enabled: true # Vulnerability scanning + enabled: true osvScanner: - enabled: true # NuGet dependency CVEs + enabled: true checkov: - enabled: true # IaC / docker-compose security + enabled: true sqlfluff: - enabled: true # Raw SQL in migrations and Dapper queries + enabled: true dotenvLint: enabled: true psscriptanalyzer: - enabled: true # PowerShell scripts (PMC, CI scripts) - fortitudeLint: - enabled: false # Fortran linter - not applicable to a .NET/C# project + enabled: true - # ── Disabled — not applicable to a .NET/C# project ───────────────────── + # ── Disabled — not applicable to a .NET/C# project ─────────────────── ruff: enabled: false flake8: @@ -237,6 +308,8 @@ reviews: enabled: false checkmake: enabled: false + fortitudeLint: + enabled: false chat: art: true @@ -255,10 +328,10 @@ knowledge_base: code_guidelines: enabled: true filePatterns: + - "**/CLAUDE.md" - "README.md" - "CONTRIBUTING.md" - "docs/**/*.md" - - ".coderabbit.yaml" learnings: scope: auto issues: @@ -271,6 +344,14 @@ knowledge_base: code_generation: docstrings: language: en-US + path_instructions: + - path: "**/*.cs" + instructions: > + Generate XML doc comments (///) in C# summary style. Keep summaries + to one line. Do not describe obvious getters/setters or generated code. + For domain aggregate methods, describe the business intent and any + domain rules enforced. For value object Create() factories, describe + validation rules and the error returned on failure. unit_tests: path_instructions: [] From 35a10a6174a3f10cb469a879a4eaa272684bdb26 Mon Sep 17 00:00:00 2001 From: Carl Marion <132995625+engrkwakwak@users.noreply.github.com> Date: Mon, 25 May 2026 13:53:50 +0800 Subject: [PATCH 002/155] Eternelle-174 Add Users module with CQRS, EF Core, and Keycloak auth (#175) * Eternelle-174 Add Users module with CQRS, EF Core, and Keycloak auth - Introduce Users module (Application, Domain, Infrastructure, IntegrationEvents, Presentation) and register in solution/API - Implement user aggregate, value objects, roles, permissions, and domain events - Add EF Core config, migrations, and schema for users, roles, permissions, outbox/inbox - Provide CQRS handlers/validators for user registration, profile update, and queries - Integrate Users module into API DI, health checks, and endpoints - Add outbox/inbox processing jobs and idempotent event handlers - Update configs for Users module settings and logging - Add Keycloak service to docker-compose for authentication - Refactor common auth/authorization namespaces - Bump Microsoft.AspNetCore.Authentication.JwtBearer version * Eternelle-174 Refactor user module: UserId, EF Core, validation, updates - Switch UserRepository to use UserId value object for type safety - Add Update method to IUserRepository and implementation - Use CommandDefinition with cancellation tokens in Dapper queries - Add UserIdConverter and configure EF Core for UserId conversion - Move Permission/Role max length to constants and use in config - Remove manual UserId conversion from UserConfiguration - Validate required claims in RegisterUser endpoint - Require FirstName/LastName in UpdateUserProfile request model - Improve event handler registration logic for interface matching * Set default Permission value and improve claim validation Set Permission property default to empty string to avoid nulls. Use string.IsNullOrWhiteSpace for stricter identity claim validation in RegisterUser. * Refactor module config, improve message processing - Move module config loading before registration in Program.cs - Add health check updates - Initialize Role.Name to empty string in private ctor - Throw on null/whitespace identityId in User.Create - Remove unused usings in Schemas.cs - Use ON CONFLICT DO NOTHING for inbox inserts - Use FOR UPDATE SKIP LOCKED in inbox/outbox queries - Split processed/error update logic for messages - Register Outbox/Inbox options with validation in UsersModule * feat(weddings): add fail-fast validation for OutboxOptions and InboxOptions * fix(weddings): pin AddDomainEventHandlers and AddIntegrationEventHandlers LINQ predicates to exact generic types * fix(weddings): use handler short name for outbox consumer idempotency key * fix(weddings): use handler short name for inbox consumer idempotency key * Change ArgumentNullException to ArgumentException --- .claude/settings.local.json | 9 - .gitignore | 3 + Directory.Packages.props | 2 +- Eternelle.slnx | 7 + docker-compose.dcproj | 5 + docker-compose.yml | 16 + src/API/Eternelle.Api/Eternelle.Api.csproj | 1 + .../Extensions/MigrationExtensions.cs | 2 + src/API/Eternelle.Api/Program.cs | 22 +- .../appsettings.Development.json | 4 +- .../modules.users.Development.json | 12 + src/API/Eternelle.Api/modules.users.json | 12 + .../ClaimsPrincipalExtensions.cs | 4 +- .../Authentication/CustomClaims.cs | 2 +- .../CustomClaimsTransformation.cs | 11 +- .../PermissionAuthorizationHandler.cs | 2 +- .../Eternelle.Common.Presentation.csproj | 1 + .../Abstractions/Data/IUnitOfWork.cs | 6 + .../AssemblyReference.cs | 8 + ...Eternelle.Modules.Users.Application.csproj | 9 + .../Users/GetUser/GetUserQuery.cs | 5 + .../Users/GetUser/GetUserQueryHandler.cs | 39 ++ .../Users/GetUser/UserResponse.cs | 3 + .../GetUserPermissionsQuery.cs | 6 + .../GetUserPermissionsQueryHandler.cs | 48 +++ .../Users/RegisterUser/RegisterUserCommand.cs | 6 + .../RegisterUserCommandHandler.cs | 50 +++ .../RegisterUserCommandValidator.cs | 15 + .../UserRegisteredDomainEventHandler.cs | 25 ++ .../Users/UpdateUser/UpdateUserComand.cs | 6 + .../UpdateUser/UpdateUserCommandHandler.cs | 54 +++ .../UpdateUser/UpdateUserCommandValidator.cs | 17 + .../UserProfileUpdatedDomainEventHandler.cs | 25 ++ .../AssemblyInfo.cs | 6 + .../Eternelle.Modules.Users.Domain.csproj | 7 + .../Users/Email.cs | 43 +++ .../Users/EmailErrors.cs | 15 + .../Users/IUserRepository.cs | 12 + .../Users/Permission.cs | 16 + .../Users/PersonName.cs | 33 ++ .../Users/PersonNameErrors.cs | 12 + .../Users/PhoneNumber.cs | 38 ++ .../Users/PhoneNumberErrors.cs | 14 + .../Users/Role.cs | 22 ++ .../Users/User.cs | 68 ++++ .../Users/UserErrors.cs | 12 + .../Users/UserId.cs | 10 + .../Users/UserProfileUpdatedDomainEvent.cs | 18 + .../Users/UserRegisteredDomainEvent.cs | 18 + .../Authorization/PermissionService.cs | 14 + .../Database/Converters/UserIdConverter.cs | 7 + ...260521171549_AddUserAggregates.Designer.cs | 334 ++++++++++++++++++ .../20260521171549_AddUserAggregates.cs | 276 +++++++++++++++ .../Migrations/UsersDbContextModelSnapshot.cs | 331 +++++++++++++++++ .../Database/Schemas.cs | 6 + .../Database/UsersDbContext.cs | 33 ++ ...rnelle.Modules.Users.Infrastructure.csproj | 16 + .../Inbox/ConfigureProcessInboxJob.cs | 23 ++ .../IdempotentIntegrationEventHandler.cs | 62 ++++ .../Inbox/InboxOptions.cs | 8 + .../Inbox/IntegrationEventConsumer.cs | 39 ++ .../Inbox/ProcessInboxJob.cs | 143 ++++++++ .../Outbox/ConfigureProcessOutboxJob.cs | 23 ++ .../Outbox/IdempotentDomainEventHandler.cs | 61 ++++ .../Outbox/OutboxOptions.cs | 8 + .../Outbox/ProcessOutboxJob.cs | 144 ++++++++ .../Users/PermissionConfiguration.cs | 48 +++ .../Users/RoleConfiguration.cs | 32 ++ .../Users/UserConfiguration.cs | 47 +++ .../Users/UserRepository.cs | 37 ++ .../UsersModule.cs | 118 +++++++ ...lle.Modules.Users.IntegrationEvents.csproj | 7 + .../UserProfileUpdatedIntegrationEvent.cs | 29 ++ .../UserRegisteredIntegrationEvent.cs | 29 ++ .../AssemblyReference.cs | 8 + ...ternelle.Modules.Users.Presentation.csproj | 8 + .../Permissions.cs | 7 + .../Tags.cs | 6 + .../Users/GetUserProfile.cs | 27 ++ .../Users/RegisterUser.cs | 40 +++ .../Users/UpdateUserProfile.cs | 40 +++ .../Weddings/Wedding.cs | 2 + .../IdempotentIntegrationEventHandler.cs | 2 +- .../Inbox/ProcessInboxJob.cs | 48 ++- .../Outbox/IdempotentDomainEventHandler.cs | 2 +- .../Outbox/ProcessOutboxJob.cs | 48 ++- .../WeddingsModule.cs | 16 +- 87 files changed, 2846 insertions(+), 64 deletions(-) delete mode 100644 .claude/settings.local.json create mode 100644 src/API/Eternelle.Api/modules.users.Development.json create mode 100644 src/API/Eternelle.Api/modules.users.json rename src/Common/{Eternelle.Common.Infrastructure => Eternelle.Common.Application}/Authentication/ClaimsPrincipalExtensions.cs (91%) rename src/Common/{Eternelle.Common.Infrastructure => Eternelle.Common.Application}/Authentication/CustomClaims.cs (67%) create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/Abstractions/Data/IUnitOfWork.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/AssemblyReference.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/Eternelle.Modules.Users.Application.csproj create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/GetUserQuery.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/GetUserQueryHandler.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/UserResponse.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUserPermissions/GetUserPermissionsQuery.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUserPermissions/GetUserPermissionsQueryHandler.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/RegisterUserCommand.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/RegisterUserCommandHandler.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/RegisterUserCommandValidator.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/UserRegisteredDomainEventHandler.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UpdateUserComand.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UpdateUserCommandHandler.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UpdateUserCommandValidator.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UserProfileUpdatedDomainEventHandler.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/AssemblyInfo.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/Eternelle.Modules.Users.Domain.csproj create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/Users/Email.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/Users/EmailErrors.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/Users/IUserRepository.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/Users/Permission.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PersonName.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PersonNameErrors.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PhoneNumber.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PhoneNumberErrors.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/Users/Role.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/Users/User.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserErrors.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserId.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserProfileUpdatedDomainEvent.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserRegisteredDomainEvent.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Authorization/PermissionService.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Converters/UserIdConverter.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Migrations/20260521171549_AddUserAggregates.Designer.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Migrations/20260521171549_AddUserAggregates.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Migrations/UsersDbContextModelSnapshot.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Schemas.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/UsersDbContext.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Eternelle.Modules.Users.Infrastructure.csproj create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/ConfigureProcessInboxJob.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/InboxOptions.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/IntegrationEventConsumer.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/ProcessInboxJob.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/ConfigureProcessOutboxJob.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/IdempotentDomainEventHandler.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/OutboxOptions.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/ProcessOutboxJob.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/PermissionConfiguration.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/RoleConfiguration.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/UserConfiguration.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/UserRepository.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Infrastructure/UsersModule.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.IntegrationEvents/Eternelle.Modules.Users.IntegrationEvents.csproj create mode 100644 src/Modules/Users/Eternelle.Modules.Users.IntegrationEvents/UserProfileUpdatedIntegrationEvent.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.IntegrationEvents/UserRegisteredIntegrationEvent.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Presentation/AssemblyReference.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Presentation/Eternelle.Modules.Users.Presentation.csproj create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Presentation/Permissions.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Presentation/Tags.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Presentation/Users/GetUserProfile.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Presentation/Users/RegisterUser.cs create mode 100644 src/Modules/Users/Eternelle.Modules.Users.Presentation/Users/UpdateUserProfile.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 8993c9d..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(dotnet build *)", - "Bash(git stash *)", - "Bash(git add *)" - ] - } -} diff --git a/.gitignore b/.gitignore index c7e1e07..95654be 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ appsettings.local.json /create-issues-guest-photos-retry.sh /create-issues-guest-photos.sh /create-issues.sh +/docs/superpowers +/.claude/settings.local.json +/.files/eternelle-realm.json diff --git a/Directory.Packages.props b/Directory.Packages.props index ca73e67..2942e9f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,7 +17,7 @@ - + diff --git a/Eternelle.slnx b/Eternelle.slnx index 96e073b..b2c3b55 100644 --- a/Eternelle.slnx +++ b/Eternelle.slnx @@ -15,6 +15,13 @@ + + + + + + + diff --git a/docker-compose.dcproj b/docker-compose.dcproj index bc7cb0a..b450f4e 100644 --- a/docker-compose.dcproj +++ b/docker-compose.dcproj @@ -3,7 +3,12 @@ 2.1 Linux + False 81dded9d-158b-e303-5f62-77a2896d2a5a + LaunchBrowser + {Scheme}://localhost:{ServicePort}/scalar/v1 + eternelle.api + eternelle diff --git a/docker-compose.yml b/docker-compose.yml index 43bc454..6cf9737 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,22 @@ services: retries: 5 start_period: 5s + eternelle.identity: + image: quay.io/keycloak/keycloak:26.5.1 + container_name: Eternelle.Identity + command: start-dev --import-realm + environment: + - KC_HEALTH_ENABLED=true + - KEYCLOAK_ADMIN=admin + - KEYCLOAK_ADMIN_PASSWORD=admin + volumes: + - ./.containers/identity:/opt/keycloak/data + - ./.files:/opt/keycloak/data/import + ports: + - 18080:8080 + - 9100:9000 + user: root # Required to set volume permissions + eternelle.seq: image: datalust/seq:latest container_name: Eternelle.Seq diff --git a/src/API/Eternelle.Api/Eternelle.Api.csproj b/src/API/Eternelle.Api/Eternelle.Api.csproj index 8cdf7ec..cc488d7 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 9b8a883..b56f3b5 100644 --- a/src/API/Eternelle.Api/Extensions/MigrationExtensions.cs +++ b/src/API/Eternelle.Api/Extensions/MigrationExtensions.cs @@ -1,3 +1,4 @@ +using Eternelle.Modules.Users.Infrastructure.Database; using Eternelle.Modules.Weddings.Infrastructure.Database; using Microsoft.EntityFrameworkCore; @@ -10,6 +11,7 @@ public static void ApplyMigrations(this IApplicationBuilder app) using IServiceScope scope = app.ApplicationServices.CreateScope(); 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 c03d8eb..b77968e 100644 --- a/src/API/Eternelle.Api/Program.cs +++ b/src/API/Eternelle.Api/Program.cs @@ -1,3 +1,4 @@ +using System.Threading.RateLimiting; using Eternelle.Api.Extensions; using Eternelle.Api.Middleware; using Eternelle.Api.OpenTelemetry; @@ -5,14 +6,13 @@ using Eternelle.Common.Infrastructure; using Eternelle.Common.Infrastructure.Configuration; using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Modules.Users.Infrastructure; using Eternelle.Modules.Weddings.Infrastructure; using Eternelle.Modules.Weddings.Presentation; using HealthChecks.UI.Client; using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.AspNetCore.RateLimiting; using Scalar.AspNetCore; using Serilog; -using System.Threading.RateLimiting; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); @@ -24,7 +24,8 @@ builder.Services.AddOpenApi(); builder.Services.AddApplication([ - Eternelle.Modules.Weddings.Application.AssemblyReference.Assembly + Eternelle.Modules.Weddings.Application.AssemblyReference.Assembly, + Eternelle.Modules.Users.Application.AssemblyReference.Assembly ]); string databaseConnectionString = builder.Configuration.GetConnectionStringOrThrow("Database"); @@ -36,14 +37,19 @@ databaseConnectionString: databaseConnectionString, redisConnectionString: redisConnectionString); +builder.Configuration.AddModuleConfiguration(["weddings", "users"]); + +Uri keyCloakHealthUrl = builder.Configuration.GetKeyCloakHealthUrl(); + builder.Services.AddHealthChecks() .AddNpgSql(databaseConnectionString) - .AddRedis(redisConnectionString); - -builder.Configuration.AddModuleConfiguration(["weddings"]); + .AddRedis(redisConnectionString) + .AddKeyCloak(keyCloakHealthUrl); builder.Services.AddWeddingsModule(builder.Configuration); +builder.Services.AddUsersModule(builder.Configuration); + builder.Services.AddRateLimiter(options => { // IP-based fixed-window limiter for the public guest photo upload endpoint. @@ -82,6 +88,10 @@ app.UseExceptionHandler(); +app.UseAuthentication(); + +app.UseAuthorization(); + app.UseRateLimiter(); app.MapEndpoints(); diff --git a/src/API/Eternelle.Api/appsettings.Development.json b/src/API/Eternelle.Api/appsettings.Development.json index ddb0ed2..0e710be 100644 --- a/src/API/Eternelle.Api/appsettings.Development.json +++ b/src/API/Eternelle.Api/appsettings.Development.json @@ -24,7 +24,9 @@ "Override": { "Microsoft": "Information", "Eternelle.Modules.Weddings.Infrastructure.Outbox": "Warning", - "Eternelle.Modules.Weddings.Infrastructure.Inbox": "Warning" + "Eternelle.Modules.Weddings.Infrastructure.Inbox": "Warning", + "Eternelle.Modules.Users.Infrastructure.Outbox": "Warning", + "Eternelle.Modules.Users.Infrastructure.Inbox": "Warning" } }, "WriteTo": [ diff --git a/src/API/Eternelle.Api/modules.users.Development.json b/src/API/Eternelle.Api/modules.users.Development.json new file mode 100644 index 0000000..64be9fb --- /dev/null +++ b/src/API/Eternelle.Api/modules.users.Development.json @@ -0,0 +1,12 @@ +{ + "Users": { + "Outbox": { + "IntervalInSeconds": 5, + "BatchSize": 20 + }, + "Inbox": { + "IntervalInSeconds": 5, + "BatchSize": 20 + } + } +} diff --git a/src/API/Eternelle.Api/modules.users.json b/src/API/Eternelle.Api/modules.users.json new file mode 100644 index 0000000..b3e920d --- /dev/null +++ b/src/API/Eternelle.Api/modules.users.json @@ -0,0 +1,12 @@ +{ + "Users": { + "Outbox": { + "IntervalInSeconds": 5, + "BatchSize": 50 + }, + "Inbox": { + "IntervalInSeconds": 5, + "BatchSize": 50 + } + } +} diff --git a/src/Common/Eternelle.Common.Infrastructure/Authentication/ClaimsPrincipalExtensions.cs b/src/Common/Eternelle.Common.Application/Authentication/ClaimsPrincipalExtensions.cs similarity index 91% rename from src/Common/Eternelle.Common.Infrastructure/Authentication/ClaimsPrincipalExtensions.cs rename to src/Common/Eternelle.Common.Application/Authentication/ClaimsPrincipalExtensions.cs index 97b2b35..18984f6 100644 --- a/src/Common/Eternelle.Common.Infrastructure/Authentication/ClaimsPrincipalExtensions.cs +++ b/src/Common/Eternelle.Common.Application/Authentication/ClaimsPrincipalExtensions.cs @@ -1,7 +1,7 @@ -using System.Security.Claims; +using System.Security.Claims; using Eternelle.Common.Application.Exceptions; -namespace Eternelle.Common.Infrastructure.Authentication; +namespace Eternelle.Common.Application.Authentication; public static class ClaimsPrincipalExtensions { diff --git a/src/Common/Eternelle.Common.Infrastructure/Authentication/CustomClaims.cs b/src/Common/Eternelle.Common.Application/Authentication/CustomClaims.cs similarity index 67% rename from src/Common/Eternelle.Common.Infrastructure/Authentication/CustomClaims.cs rename to src/Common/Eternelle.Common.Application/Authentication/CustomClaims.cs index 8bdd3ff..8cc7597 100644 --- a/src/Common/Eternelle.Common.Infrastructure/Authentication/CustomClaims.cs +++ b/src/Common/Eternelle.Common.Application/Authentication/CustomClaims.cs @@ -1,4 +1,4 @@ -namespace Eternelle.Common.Infrastructure.Authentication; +namespace Eternelle.Common.Application.Authentication; public static class CustomClaims { diff --git a/src/Common/Eternelle.Common.Infrastructure/Authorization/CustomClaimsTransformation.cs b/src/Common/Eternelle.Common.Infrastructure/Authorization/CustomClaimsTransformation.cs index 3568a0d..2ea2a46 100644 --- a/src/Common/Eternelle.Common.Infrastructure/Authorization/CustomClaimsTransformation.cs +++ b/src/Common/Eternelle.Common.Infrastructure/Authorization/CustomClaimsTransformation.cs @@ -1,5 +1,5 @@ using System.Security.Claims; -using Eternelle.Common.Infrastructure.Authentication; +using Eternelle.Common.Application.Authentication; using Eternelle.Common.Application.Authorization; using Eternelle.Common.Application.Exceptions; using Eternelle.Common.Domain; @@ -27,6 +27,15 @@ public async Task TransformAsync(ClaimsPrincipal principal) if (result.IsFailure) { + // A valid token whose identity has no local user yet (e.g. the first call to + // /users/register right after Keycloak signup) has no app permissions. Return + // the principal unchanged rather than failing the request. Any other error is + // a real fault and still throws. + if (result.Error.Type == ErrorType.NotFound) + { + return principal; + } + throw new EternelleException(nameof(IPermissionService.GetUserPermissionsAsync), result.Error); } diff --git a/src/Common/Eternelle.Common.Infrastructure/Authorization/PermissionAuthorizationHandler.cs b/src/Common/Eternelle.Common.Infrastructure/Authorization/PermissionAuthorizationHandler.cs index 00beafd..94c1eb4 100644 --- a/src/Common/Eternelle.Common.Infrastructure/Authorization/PermissionAuthorizationHandler.cs +++ b/src/Common/Eternelle.Common.Infrastructure/Authorization/PermissionAuthorizationHandler.cs @@ -1,4 +1,4 @@ -using Eternelle.Common.Infrastructure.Authentication; +using Eternelle.Common.Application.Authentication; using Microsoft.AspNetCore.Authorization; namespace Eternelle.Common.Infrastructure.Authorization; diff --git a/src/Common/Eternelle.Common.Presentation/Eternelle.Common.Presentation.csproj b/src/Common/Eternelle.Common.Presentation/Eternelle.Common.Presentation.csproj index 5c9cdfd..526d4e9 100644 --- a/src/Common/Eternelle.Common.Presentation/Eternelle.Common.Presentation.csproj +++ b/src/Common/Eternelle.Common.Presentation/Eternelle.Common.Presentation.csproj @@ -5,6 +5,7 @@ + diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Abstractions/Data/IUnitOfWork.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Abstractions/Data/IUnitOfWork.cs new file mode 100644 index 0000000..2266723 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Abstractions/Data/IUnitOfWork.cs @@ -0,0 +1,6 @@ +namespace Eternelle.Modules.Users.Application.Abstractions.Data; + +public interface IUnitOfWork +{ + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/AssemblyReference.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/AssemblyReference.cs new file mode 100644 index 0000000..0cc8d12 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/AssemblyReference.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace Eternelle.Modules.Users.Application; + +public static class AssemblyReference +{ + public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly; +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Eternelle.Modules.Users.Application.csproj b/src/Modules/Users/Eternelle.Modules.Users.Application/Eternelle.Modules.Users.Application.csproj new file mode 100644 index 0000000..8dac27b --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Eternelle.Modules.Users.Application.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/GetUserQuery.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/GetUserQuery.cs new file mode 100644 index 0000000..8d8e98c --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/GetUserQuery.cs @@ -0,0 +1,5 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Users.Application.Users.GetUser; + +public sealed record GetUserQuery(Guid UserId) : IQuery; diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/GetUserQueryHandler.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/GetUserQueryHandler.cs new file mode 100644 index 0000000..a8250a8 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/GetUserQueryHandler.cs @@ -0,0 +1,39 @@ +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Users.Domain.Users; + +namespace Eternelle.Modules.Users.Application.Users.GetUser; + +internal sealed class GetUserQueryHandler(IDbConnectionFactory dbConnectionFactory) + : IQueryHandler +{ + public async Task> Handle(GetUserQuery request, CancellationToken cancellationToken) + { + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + + const string sql = + $""" + SELECT + id AS {nameof(UserResponse.Id)}, + email AS {nameof(UserResponse.Email)}, + first_name AS {nameof(UserResponse.FirstName)}, + last_name AS {nameof(UserResponse.LastName)}, + phone_number AS {nameof(UserResponse.PhoneNumber)} + FROM users.users + WHERE id = @UserId + """; + + UserResponse? user = await connection.QuerySingleOrDefaultAsync( + new CommandDefinition(sql, request, cancellationToken: cancellationToken)); + + if (user is null) + { + return Result.Failure(UserErrors.NotFound(request.UserId)); + } + + return user; + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/UserResponse.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/UserResponse.cs new file mode 100644 index 0000000..33182ec --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/UserResponse.cs @@ -0,0 +1,3 @@ +namespace Eternelle.Modules.Users.Application.Users.GetUser; + +public sealed record UserResponse(Guid Id, string Email, string FirstName, string LastName, string? PhoneNumber); diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUserPermissions/GetUserPermissionsQuery.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUserPermissions/GetUserPermissionsQuery.cs new file mode 100644 index 0000000..c311a47 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUserPermissions/GetUserPermissionsQuery.cs @@ -0,0 +1,6 @@ +using Eternelle.Common.Application.Authorization; +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Users.Application.Users.GetUserPermissions; + +public sealed record GetUserPermissionsQuery(string IdentityId) : IQuery; diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUserPermissions/GetUserPermissionsQueryHandler.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUserPermissions/GetUserPermissionsQueryHandler.cs new file mode 100644 index 0000000..c8b94d2 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUserPermissions/GetUserPermissionsQueryHandler.cs @@ -0,0 +1,48 @@ +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Authorization; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Users.Domain.Users; + +namespace Eternelle.Modules.Users.Application.Users.GetUserPermissions; + +internal sealed class GetUserPermissionsQueryHandler(IDbConnectionFactory dbConnectionFactory) + : IQueryHandler +{ + public async Task> Handle( + GetUserPermissionsQuery request, + CancellationToken cancellationToken) + { + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + + const string sql = + $""" + SELECT DISTINCT + u.id AS {nameof(UserPermission.UserId)}, + rp.permission_code AS {nameof(UserPermission.Permission)} + FROM users.users u + JOIN users.user_roles ur ON ur.user_id = u.id + JOIN users.role_permissions rp ON rp.role_name = ur.role_name + WHERE u.identity_id = @IdentityId + """; + + List permissions = (await connection.QueryAsync( + new CommandDefinition(sql, request, cancellationToken: cancellationToken))).AsList(); + + if (!permissions.Any()) + { + return Result.Failure(UserErrors.NotFound(request.IdentityId)); + } + + return new PermissionsResponse(permissions[0].UserId, permissions.Select(p => p.Permission).ToHashSet()); + } + + internal sealed class UserPermission + { + internal Guid UserId { get; init; } + + internal string Permission { get; init; } = string.Empty; + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/RegisterUserCommand.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/RegisterUserCommand.cs new file mode 100644 index 0000000..7ea33c6 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/RegisterUserCommand.cs @@ -0,0 +1,6 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Users.Application.Users.RegisterUser; + +public sealed record RegisterUserCommand(string IdentityId, string Email, string FirstName, string LastName) + : ICommand; diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/RegisterUserCommandHandler.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/RegisterUserCommandHandler.cs new file mode 100644 index 0000000..8f74ca3 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/RegisterUserCommandHandler.cs @@ -0,0 +1,50 @@ +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Users.Application.Abstractions.Data; +using Eternelle.Modules.Users.Domain.Users; + +namespace Eternelle.Modules.Users.Application.Users.RegisterUser; + +internal sealed class RegisterUserCommandHandler( + IUserRepository userRepository, + IUnitOfWork unitOfWork) + : ICommandHandler +{ + public async Task> Handle(RegisterUserCommand command, CancellationToken cancellationToken) + { + // Idempotent: signup happened at Keycloak, so a retried /users/register + // for an already-provisioned identity returns the existing local user. + User? existing = await userRepository.GetByIdentityIdAsync(command.IdentityId, cancellationToken); + if (existing is not null) + { + return existing.Id.Value; + } + + Result email = Email.Create(command.Email); + Result firstName = PersonName.Create(command.FirstName); + Result lastName = PersonName.Create(command.LastName); + + if (email.IsFailure) + { + return Result.Failure(email.Error); + } + + if (firstName.IsFailure) + { + return Result.Failure(firstName.Error); + } + + if (lastName.IsFailure) + { + return Result.Failure(lastName.Error); + } + + var user = User.Create(command.IdentityId, email.Value, firstName.Value, lastName.Value); + + userRepository.Insert(user); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return user.Id.Value; + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/RegisterUserCommandValidator.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/RegisterUserCommandValidator.cs new file mode 100644 index 0000000..e5c5e2c --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/RegisterUserCommandValidator.cs @@ -0,0 +1,15 @@ +using Eternelle.Modules.Users.Domain.Users; +using FluentValidation; + +namespace Eternelle.Modules.Users.Application.Users.RegisterUser; + +internal sealed class RegisterUserCommandValidator : AbstractValidator +{ + public RegisterUserCommandValidator() + { + RuleFor(c => c.IdentityId).NotEmpty(); + RuleFor(c => c.Email).NotEmpty().EmailAddress().MaximumLength(Email.MaxLength); + RuleFor(c => c.FirstName).NotEmpty().MaximumLength(PersonName.MaxLength); + RuleFor(c => c.LastName).NotEmpty().MaximumLength(PersonName.MaxLength); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/UserRegisteredDomainEventHandler.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/UserRegisteredDomainEventHandler.cs new file mode 100644 index 0000000..ec0a6ae --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/RegisterUser/UserRegisteredDomainEventHandler.cs @@ -0,0 +1,25 @@ +using Eternelle.Common.Application.EventBus; +using Eternelle.Common.Application.Messaging; +using Eternelle.Modules.Users.Domain.Users; +using Eternelle.Modules.Users.IntegrationEvents; + +namespace Eternelle.Modules.Users.Application.Users.RegisterUser; + +internal sealed class UserRegisteredDomainEventHandler(IEventBus eventBus) + : DomainEventHandler +{ + public override async Task Handle( + UserRegisteredDomainEvent domainEvent, + CancellationToken cancellationToken = default) + { + await eventBus.PublishAsync( + new UserRegisteredIntegrationEvent( + domainEvent.Id, + domainEvent.OccurredOnUtc, + domainEvent.UserId, + domainEvent.Email, + domainEvent.FirstName, + domainEvent.LastName), + cancellationToken); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UpdateUserComand.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UpdateUserComand.cs new file mode 100644 index 0000000..1963dc5 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UpdateUserComand.cs @@ -0,0 +1,6 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Users.Application.Users.UpdateUser; + +public sealed record UpdateUserCommand(Guid UserId, string FirstName, string LastName, string? PhoneNumber) + : ICommand; diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UpdateUserCommandHandler.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UpdateUserCommandHandler.cs new file mode 100644 index 0000000..0abf3e8 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UpdateUserCommandHandler.cs @@ -0,0 +1,54 @@ +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Users.Application.Abstractions.Data; +using Eternelle.Modules.Users.Domain.Users; + +namespace Eternelle.Modules.Users.Application.Users.UpdateUser; + +internal sealed class UpdateUserCommandHandler(IUserRepository userRepository, IUnitOfWork unitOfWork) + : ICommandHandler +{ + public async Task Handle(UpdateUserCommand command, CancellationToken cancellationToken) + { + // Target is always the authenticated caller — the endpoint sets UserId = claims.GetUserId(), so there is no IDOR surface here. + User? user = await userRepository.GetAsync(new UserId(command.UserId), cancellationToken); + + if (user is null) + { + return Result.Failure(UserErrors.NotFound(command.UserId)); + } + + Result firstName = PersonName.Create(command.FirstName); + Result lastName = PersonName.Create(command.LastName); + + if (firstName.IsFailure) + { + return Result.Failure(firstName.Error); + } + + if (lastName.IsFailure) + { + return Result.Failure(lastName.Error); + } + + PhoneNumber? phoneNumber = null; + if (command.PhoneNumber is not null) + { + Result phone = PhoneNumber.Create(command.PhoneNumber); + if (phone.IsFailure) + { + return Result.Failure(phone.Error); + } + + phoneNumber = phone.Value; + } + + user.UpdateProfile(firstName.Value, lastName.Value, phoneNumber); + + userRepository.Update(user); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UpdateUserCommandValidator.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UpdateUserCommandValidator.cs new file mode 100644 index 0000000..3be9a9c --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UpdateUserCommandValidator.cs @@ -0,0 +1,17 @@ +using Eternelle.Modules.Users.Domain.Users; +using FluentValidation; + +namespace Eternelle.Modules.Users.Application.Users.UpdateUser; + +internal sealed class UpdateUserCommandValidator : AbstractValidator +{ + public UpdateUserCommandValidator() + { + RuleFor(c => c.UserId).NotEmpty(); + RuleFor(c => c.FirstName).NotEmpty().MaximumLength(PersonName.MaxLength); + RuleFor(c => c.LastName).NotEmpty().MaximumLength(PersonName.MaxLength); + RuleFor(c => c.PhoneNumber) + .MaximumLength(PhoneNumber.MaxLength) + .When(c => c.PhoneNumber is not null); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UserProfileUpdatedDomainEventHandler.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UserProfileUpdatedDomainEventHandler.cs new file mode 100644 index 0000000..716ebdb --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/UpdateUser/UserProfileUpdatedDomainEventHandler.cs @@ -0,0 +1,25 @@ +using Eternelle.Common.Application.EventBus; +using Eternelle.Common.Application.Messaging; +using Eternelle.Modules.Users.Domain.Users; +using Eternelle.Modules.Users.IntegrationEvents; + +namespace Eternelle.Modules.Users.Application.Users.UpdateUser; + +internal sealed class UserProfileUpdatedDomainEventHandler(IEventBus eventBus) + : DomainEventHandler +{ + public override async Task Handle( + UserProfileUpdatedDomainEvent domainEvent, + CancellationToken cancellationToken = default) + { + await eventBus.PublishAsync( + new UserProfileUpdatedIntegrationEvent( + domainEvent.Id, + domainEvent.OccurredOnUtc, + domainEvent.UserId, + domainEvent.FirstName, + domainEvent.LastName, + domainEvent.PhoneNumber), + cancellationToken); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/AssemblyInfo.cs b/src/Modules/Users/Eternelle.Modules.Users.Domain/AssemblyInfo.cs new file mode 100644 index 0000000..7d3d2d9 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Runtime.CompilerServices; + +// Allow the Infrastructure layer to access internal members — specifically +// FromPersistence() factory methods on value objects, which bypass Create() +// validation for values that were already validated on write. +[assembly: InternalsVisibleTo("Eternelle.Modules.Users.Infrastructure")] diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/Eternelle.Modules.Users.Domain.csproj b/src/Modules/Users/Eternelle.Modules.Users.Domain/Eternelle.Modules.Users.Domain.csproj new file mode 100644 index 0000000..419ba76 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/Eternelle.Modules.Users.Domain.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/Email.cs b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/Email.cs new file mode 100644 index 0000000..92ebc80 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/Email.cs @@ -0,0 +1,43 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Users.Domain.Users; + +public sealed record Email +{ + public const int MaxLength = 300; + + private Email(string value) => Value = value; + + public string Value { get; } + + public static Result Create(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return Result.Failure(EmailErrors.Empty); + } + + string trimmed = raw.Trim(); + + if (trimmed.Length > MaxLength) + { + return Result.Failure(EmailErrors.TooLong); + } + + // Minimal structural check: exactly one '@', non-empty local and domain parts, + // and a dot in the domain. Full RFC validation is intentionally out of scope — + // Keycloak already validated the address at signup. + int at = trimmed.IndexOf('@'); + if (at <= 0 || at != trimmed.LastIndexOf('@') || at == trimmed.Length - 1 + || !trimmed[(at + 1)..].Contains('.')) + { + return Result.Failure(EmailErrors.InvalidFormat); + } + + return Result.Success(new Email(trimmed)); + } + + public override string ToString() => Value; + + internal static Email FromPersistence(string value) => new(value); +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/EmailErrors.cs b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/EmailErrors.cs new file mode 100644 index 0000000..e76547f --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/EmailErrors.cs @@ -0,0 +1,15 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Users.Domain.Users; + +public static class EmailErrors +{ + public static readonly Error Empty = + Error.Problem("Email.Empty", "Email must not be empty"); + + public static readonly Error TooLong = + Error.Problem("Email.TooLong", $"Email must not exceed {Email.MaxLength} characters"); + + public static readonly Error InvalidFormat = + Error.Problem("Email.InvalidFormat", "Email format is invalid"); +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/IUserRepository.cs b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/IUserRepository.cs new file mode 100644 index 0000000..f86b131 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/IUserRepository.cs @@ -0,0 +1,12 @@ +namespace Eternelle.Modules.Users.Domain.Users; + +public interface IUserRepository +{ + Task GetAsync(UserId id, CancellationToken cancellationToken = default); + + Task GetByIdentityIdAsync(string identityId, CancellationToken cancellationToken = default); + + void Insert(User user); + + void Update(User user); +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/Permission.cs b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/Permission.cs new file mode 100644 index 0000000..990ffad --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/Permission.cs @@ -0,0 +1,16 @@ +namespace Eternelle.Modules.Users.Domain.Users; + +public sealed class Permission +{ + public const int CodeMaxLength = 100; + + public static readonly Permission GetUser = new("users:read"); + public static readonly Permission ModifyUser = new("users:update"); + + private Permission(string code) + { + Code = code; + } + + public string Code { get; } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PersonName.cs b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PersonName.cs new file mode 100644 index 0000000..ba5a58e --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PersonName.cs @@ -0,0 +1,33 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Users.Domain.Users; + +public sealed record PersonName +{ + public const int MaxLength = 200; + + private PersonName(string value) => Value = value; + + public string Value { get; } + + public static Result Create(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return Result.Failure(PersonNameErrors.Empty); + } + + string trimmed = raw.Trim(); + + if (trimmed.Length > MaxLength) + { + return Result.Failure(PersonNameErrors.TooLong); + } + + return Result.Success(new PersonName(trimmed)); + } + + public override string ToString() => Value; + + internal static PersonName FromPersistence(string value) => new(value); +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PersonNameErrors.cs b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PersonNameErrors.cs new file mode 100644 index 0000000..ac0e3a5 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PersonNameErrors.cs @@ -0,0 +1,12 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Users.Domain.Users; + +public static class PersonNameErrors +{ + public static readonly Error Empty = + Error.Problem("PersonName.Empty", "Name must not be empty"); + + public static readonly Error TooLong = + Error.Problem("PersonName.TooLong", $"Name must not exceed {PersonName.MaxLength} characters"); +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PhoneNumber.cs b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PhoneNumber.cs new file mode 100644 index 0000000..4a18597 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PhoneNumber.cs @@ -0,0 +1,38 @@ +using System.Text.RegularExpressions; +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Users.Domain.Users; + +public sealed partial record PhoneNumber +{ + // E.164: '+' followed by a country code (first digit 1-9) and up to 14 more digits. + public const int MaxLength = 16; // '+' + 15 digits + + private PhoneNumber(string value) => Value = value; + + public string Value { get; } + + public static Result Create(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return Result.Failure(PhoneNumberErrors.Empty); + } + + string trimmed = raw.Trim(); + + if (!E164Regex().IsMatch(trimmed)) + { + return Result.Failure(PhoneNumberErrors.InvalidFormat); + } + + return Result.Success(new PhoneNumber(trimmed)); + } + + public override string ToString() => Value; + + internal static PhoneNumber FromPersistence(string value) => new(value); + + [GeneratedRegex(@"^\+[1-9]\d{7,14}$")] + private static partial Regex E164Regex(); +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PhoneNumberErrors.cs b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PhoneNumberErrors.cs new file mode 100644 index 0000000..30b6528 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/PhoneNumberErrors.cs @@ -0,0 +1,14 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Users.Domain.Users; + +public static class PhoneNumberErrors +{ + public static readonly Error Empty = + Error.Problem("PhoneNumber.Empty", "Phone number must not be empty"); + + public static readonly Error InvalidFormat = + Error.Problem( + "PhoneNumber.InvalidFormat", + "Phone number must be in E.164 format, e.g. +27831234567"); +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/Role.cs b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/Role.cs new file mode 100644 index 0000000..0eb7885 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/Role.cs @@ -0,0 +1,22 @@ +namespace Eternelle.Modules.Users.Domain.Users; + +public sealed class Role +{ + public const int NameMaxLength = 50; + + public static readonly Role Admin = new("Admin"); + public static readonly Role Couple = new("Couple"); + public static readonly Role Guest = new("Guest"); + + private Role(string name) + { + Name = name; + } + + private Role() + { + Name = string.Empty; + } + + public string Name { get; private set; } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/User.cs b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/User.cs new file mode 100644 index 0000000..d4ee4f8 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/User.cs @@ -0,0 +1,68 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Users.Domain.Users; + +public sealed class User : Entity +{ + private readonly List _roles = []; + + private User() + { + } + + public UserId Id { get; private set; } + + public string IdentityId { get; private set; } + + public Email Email { get; private set; } + + public PersonName FirstName { get; private set; } + + public PersonName LastName { get; private set; } + + public PhoneNumber? PhoneNumber { get; private set; } + + public IReadOnlyCollection Roles => _roles.AsReadOnly(); + + public static User Create(string identityId, Email email, PersonName firstName, PersonName lastName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(identityId); + + var user = new User + { + Id = UserId.New(), + IdentityId = identityId, + Email = email, + FirstName = firstName, + LastName = lastName, + }; + + user._roles.Add(Role.Couple); + + user.Raise(new UserRegisteredDomainEvent( + user.Id.Value, + user.Email.Value, + user.FirstName.Value, + user.LastName.Value)); + + return user; + } + + public void UpdateProfile(PersonName firstName, PersonName lastName, PhoneNumber? phoneNumber) + { + if (FirstName == firstName && LastName == lastName && PhoneNumber == phoneNumber) + { + return; + } + + FirstName = firstName; + LastName = lastName; + PhoneNumber = phoneNumber; + + Raise(new UserProfileUpdatedDomainEvent( + Id.Value, + FirstName.Value, + LastName.Value, + PhoneNumber?.Value)); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserErrors.cs b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserErrors.cs new file mode 100644 index 0000000..7eff79c --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserErrors.cs @@ -0,0 +1,12 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Users.Domain.Users; + +public static class UserErrors +{ + public static Error NotFound(Guid userId) => + Error.NotFound("Users.NotFound", $"The user with the identifier {userId} not found"); + + public static Error NotFound(string identityId) => + Error.NotFound("Users.NotFound", $"The user with the IDP identifier {identityId} not found"); +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserId.cs b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserId.cs new file mode 100644 index 0000000..4383e16 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserId.cs @@ -0,0 +1,10 @@ +namespace Eternelle.Modules.Users.Domain.Users; + +public readonly record struct UserId(Guid Value) +{ + public static UserId New() => new(Guid.CreateVersion7()); + + public static UserId Empty => new(Guid.Empty); + + public override string ToString() => Value.ToString(); +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserProfileUpdatedDomainEvent.cs b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserProfileUpdatedDomainEvent.cs new file mode 100644 index 0000000..0ccb1de --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserProfileUpdatedDomainEvent.cs @@ -0,0 +1,18 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Users.Domain.Users; + +public sealed class UserProfileUpdatedDomainEvent( + Guid userId, + string firstName, + string lastName, + string? phoneNumber) : DomainEvent +{ + public Guid UserId { get; init; } = userId; + + public string FirstName { get; init; } = firstName; + + public string LastName { get; init; } = lastName; + + public string? PhoneNumber { get; init; } = phoneNumber; +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserRegisteredDomainEvent.cs b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserRegisteredDomainEvent.cs new file mode 100644 index 0000000..c803ad3 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Domain/Users/UserRegisteredDomainEvent.cs @@ -0,0 +1,18 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Users.Domain.Users; + +public sealed class UserRegisteredDomainEvent( + Guid userId, + string email, + string firstName, + string lastName) : DomainEvent +{ + public Guid UserId { get; init; } = userId; + + public string Email { get; init; } = email; + + public string FirstName { get; init; } = firstName; + + public string LastName { get; init; } = lastName; +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Authorization/PermissionService.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Authorization/PermissionService.cs new file mode 100644 index 0000000..f3ff80e --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Authorization/PermissionService.cs @@ -0,0 +1,14 @@ +using Eternelle.Common.Application.Authorization; +using Eternelle.Common.Domain; +using Eternelle.Modules.Users.Application.Users.GetUserPermissions; +using MediatR; + +namespace Eternelle.Modules.Users.Infrastructure.Authorization; + +internal sealed class PermissionService(ISender sender) : IPermissionService +{ + public async Task> GetUserPermissionsAsync(string identityId) + { + return await sender.Send(new GetUserPermissionsQuery(identityId)); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Converters/UserIdConverter.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Converters/UserIdConverter.cs new file mode 100644 index 0000000..b33e8f6 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Converters/UserIdConverter.cs @@ -0,0 +1,7 @@ +using Eternelle.Modules.Users.Domain.Users; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Eternelle.Modules.Users.Infrastructure.Database.Converters; + +internal sealed class UserIdConverter() + : ValueConverter(id => id.Value, value => new UserId(value)); diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Migrations/20260521171549_AddUserAggregates.Designer.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Migrations/20260521171549_AddUserAggregates.Designer.cs new file mode 100644 index 0000000..0eb66e0 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Migrations/20260521171549_AddUserAggregates.Designer.cs @@ -0,0 +1,334 @@ +// +using System; +using Eternelle.Modules.Users.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Eternelle.Modules.Users.Infrastructure.Database.Migrations +{ + [DbContext(typeof(UsersDbContext))] + [Migration("20260521171549_AddUserAggregates")] + partial class AddUserAggregates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("users") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Inbox.InboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("jsonb") + .HasColumnName("content"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("OccurredOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on_utc"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_on_utc"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_inbox_messages"); + + b.ToTable("inbox_messages", "users"); + }); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Inbox.InboxMessageConsumer", b => + { + b.Property("InboxMessageId") + .HasColumnType("uuid") + .HasColumnName("inbox_message_id"); + + b.Property("Name") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.HasKey("InboxMessageId", "Name") + .HasName("pk_inbox_message_consumers"); + + b.ToTable("inbox_message_consumers", "users"); + }); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("jsonb") + .HasColumnName("content"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("OccurredOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on_utc"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_on_utc"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_outbox_messages"); + + b.ToTable("outbox_messages", "users"); + }); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Outbox.OutboxMessageConsumer", b => + { + b.Property("OutboxMessageId") + .HasColumnType("uuid") + .HasColumnName("outbox_message_id"); + + b.Property("Name") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.HasKey("OutboxMessageId", "Name") + .HasName("pk_outbox_message_consumers"); + + b.ToTable("outbox_message_consumers", "users"); + }); + + modelBuilder.Entity("Eternelle.Modules.Users.Domain.Users.Permission", b => + { + b.Property("Code") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.HasKey("Code") + .HasName("pk_permissions"); + + b.ToTable("permissions", "users"); + + b.HasData( + new + { + Code = "users:read" + }, + new + { + Code = "users:update" + }); + }); + + modelBuilder.Entity("Eternelle.Modules.Users.Domain.Users.Role", b => + { + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Name") + .HasName("pk_roles"); + + b.ToTable("roles", "users"); + + b.HasData( + new + { + Name = "Admin" + }, + new + { + Name = "Couple" + }, + new + { + Name = "Guest" + }); + }); + + modelBuilder.Entity("Eternelle.Modules.Users.Domain.Users.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("email"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("first_name"); + + b.Property("IdentityId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("identity_id"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("last_name"); + + b.Property("PhoneNumber") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("phone_number"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_users_email"); + + b.HasIndex("IdentityId") + .IsUnique() + .HasDatabaseName("ix_users_identity_id"); + + b.ToTable("users", "users"); + }); + + modelBuilder.Entity("PermissionRole", b => + { + b.Property("PermissionCode") + .HasColumnType("character varying(100)") + .HasColumnName("permission_code"); + + b.Property("RoleName") + .HasColumnType("character varying(50)") + .HasColumnName("role_name"); + + b.HasKey("PermissionCode", "RoleName") + .HasName("pk_role_permissions"); + + b.HasIndex("RoleName") + .HasDatabaseName("ix_role_permissions_role_name"); + + b.ToTable("role_permissions", "users"); + + b.HasData( + new + { + PermissionCode = "users:read", + RoleName = "Couple" + }, + new + { + PermissionCode = "users:update", + RoleName = "Couple" + }, + new + { + PermissionCode = "users:read", + RoleName = "Admin" + }, + new + { + PermissionCode = "users:update", + RoleName = "Admin" + }, + new + { + PermissionCode = "users:read", + RoleName = "Guest" + }); + }); + + modelBuilder.Entity("RoleUser", b => + { + b.Property("RolesName") + .HasColumnType("character varying(50)") + .HasColumnName("role_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("RolesName", "UserId") + .HasName("pk_user_roles"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_roles_user_id"); + + b.ToTable("user_roles", "users"); + }); + + modelBuilder.Entity("PermissionRole", b => + { + b.HasOne("Eternelle.Modules.Users.Domain.Users.Permission", null) + .WithMany() + .HasForeignKey("PermissionCode") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_permissions_permissions_permission_code"); + + b.HasOne("Eternelle.Modules.Users.Domain.Users.Role", null) + .WithMany() + .HasForeignKey("RoleName") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_permissions_roles_role_name"); + }); + + modelBuilder.Entity("RoleUser", b => + { + b.HasOne("Eternelle.Modules.Users.Domain.Users.Role", null) + .WithMany() + .HasForeignKey("RolesName") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_roles_roles_name"); + + b.HasOne("Eternelle.Modules.Users.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_users_user_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Migrations/20260521171549_AddUserAggregates.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Migrations/20260521171549_AddUserAggregates.cs new file mode 100644 index 0000000..1844032 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Migrations/20260521171549_AddUserAggregates.cs @@ -0,0 +1,276 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Eternelle.Modules.Users.Infrastructure.Database.Migrations +{ + /// + public partial class AddUserAggregates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "users"); + + migrationBuilder.CreateTable( + name: "inbox_message_consumers", + schema: "users", + columns: table => new + { + inbox_message_id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(500)", maxLength: 500, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_inbox_message_consumers", x => new { x.inbox_message_id, x.name }); + }); + + migrationBuilder.CreateTable( + name: "inbox_messages", + schema: "users", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + type = table.Column(type: "text", nullable: false), + content = table.Column(type: "jsonb", maxLength: 2000, nullable: false), + occurred_on_utc = table.Column(type: "timestamp with time zone", nullable: false), + processed_on_utc = table.Column(type: "timestamp with time zone", nullable: true), + error = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_inbox_messages", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "outbox_message_consumers", + schema: "users", + columns: table => new + { + outbox_message_id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(500)", maxLength: 500, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_outbox_message_consumers", x => new { x.outbox_message_id, x.name }); + }); + + migrationBuilder.CreateTable( + name: "outbox_messages", + schema: "users", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + type = table.Column(type: "text", nullable: false), + content = table.Column(type: "jsonb", maxLength: 2000, nullable: false), + occurred_on_utc = table.Column(type: "timestamp with time zone", nullable: false), + processed_on_utc = table.Column(type: "timestamp with time zone", nullable: true), + error = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_outbox_messages", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "permissions", + schema: "users", + columns: table => new + { + code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_permissions", x => x.code); + }); + + migrationBuilder.CreateTable( + name: "roles", + schema: "users", + columns: table => new + { + name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_roles", x => x.name); + }); + + migrationBuilder.CreateTable( + name: "users", + schema: "users", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + identity_id = table.Column(type: "text", nullable: false), + email = table.Column(type: "character varying(300)", maxLength: 300, nullable: false), + first_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + last_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + phone_number = table.Column(type: "character varying(16)", maxLength: 16, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_users", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "role_permissions", + schema: "users", + columns: table => new + { + permission_code = table.Column(type: "character varying(100)", nullable: false), + role_name = table.Column(type: "character varying(50)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_role_permissions", x => new { x.permission_code, x.role_name }); + table.ForeignKey( + name: "fk_role_permissions_permissions_permission_code", + column: x => x.permission_code, + principalSchema: "users", + principalTable: "permissions", + principalColumn: "code", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_role_permissions_roles_role_name", + column: x => x.role_name, + principalSchema: "users", + principalTable: "roles", + principalColumn: "name", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_roles", + schema: "users", + columns: table => new + { + role_name = table.Column(type: "character varying(50)", nullable: false), + user_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_roles", x => new { x.role_name, x.user_id }); + table.ForeignKey( + name: "fk_user_roles_roles_roles_name", + column: x => x.role_name, + principalSchema: "users", + principalTable: "roles", + principalColumn: "name", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_user_roles_users_user_id", + column: x => x.user_id, + principalSchema: "users", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + schema: "users", + table: "permissions", + column: "code", + values: new object[] + { + "users:read", + "users:update" + }); + + migrationBuilder.InsertData( + schema: "users", + table: "roles", + column: "name", + values: new object[] + { + "Admin", + "Couple", + "Guest" + }); + + migrationBuilder.InsertData( + schema: "users", + table: "role_permissions", + columns: new[] { "permission_code", "role_name" }, + values: new object[,] + { + { "users:read", "Admin" }, + { "users:read", "Couple" }, + { "users:read", "Guest" }, + { "users:update", "Admin" }, + { "users:update", "Couple" } + }); + + migrationBuilder.CreateIndex( + name: "ix_role_permissions_role_name", + schema: "users", + table: "role_permissions", + column: "role_name"); + + migrationBuilder.CreateIndex( + name: "ix_user_roles_user_id", + schema: "users", + table: "user_roles", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_users_email", + schema: "users", + table: "users", + column: "email", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_users_identity_id", + schema: "users", + table: "users", + column: "identity_id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "inbox_message_consumers", + schema: "users"); + + migrationBuilder.DropTable( + name: "inbox_messages", + schema: "users"); + + migrationBuilder.DropTable( + name: "outbox_message_consumers", + schema: "users"); + + migrationBuilder.DropTable( + name: "outbox_messages", + schema: "users"); + + migrationBuilder.DropTable( + name: "role_permissions", + schema: "users"); + + migrationBuilder.DropTable( + name: "user_roles", + schema: "users"); + + migrationBuilder.DropTable( + name: "permissions", + schema: "users"); + + migrationBuilder.DropTable( + name: "roles", + schema: "users"); + + migrationBuilder.DropTable( + name: "users", + schema: "users"); + } + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Migrations/UsersDbContextModelSnapshot.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Migrations/UsersDbContextModelSnapshot.cs new file mode 100644 index 0000000..8b89134 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Migrations/UsersDbContextModelSnapshot.cs @@ -0,0 +1,331 @@ +// +using System; +using Eternelle.Modules.Users.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Eternelle.Modules.Users.Infrastructure.Database.Migrations +{ + [DbContext(typeof(UsersDbContext))] + partial class UsersDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("users") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Inbox.InboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("jsonb") + .HasColumnName("content"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("OccurredOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on_utc"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_on_utc"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_inbox_messages"); + + b.ToTable("inbox_messages", "users"); + }); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Inbox.InboxMessageConsumer", b => + { + b.Property("InboxMessageId") + .HasColumnType("uuid") + .HasColumnName("inbox_message_id"); + + b.Property("Name") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.HasKey("InboxMessageId", "Name") + .HasName("pk_inbox_message_consumers"); + + b.ToTable("inbox_message_consumers", "users"); + }); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("jsonb") + .HasColumnName("content"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("OccurredOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on_utc"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_on_utc"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_outbox_messages"); + + b.ToTable("outbox_messages", "users"); + }); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Outbox.OutboxMessageConsumer", b => + { + b.Property("OutboxMessageId") + .HasColumnType("uuid") + .HasColumnName("outbox_message_id"); + + b.Property("Name") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.HasKey("OutboxMessageId", "Name") + .HasName("pk_outbox_message_consumers"); + + b.ToTable("outbox_message_consumers", "users"); + }); + + modelBuilder.Entity("Eternelle.Modules.Users.Domain.Users.Permission", b => + { + b.Property("Code") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.HasKey("Code") + .HasName("pk_permissions"); + + b.ToTable("permissions", "users"); + + b.HasData( + new + { + Code = "users:read" + }, + new + { + Code = "users:update" + }); + }); + + modelBuilder.Entity("Eternelle.Modules.Users.Domain.Users.Role", b => + { + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Name") + .HasName("pk_roles"); + + b.ToTable("roles", "users"); + + b.HasData( + new + { + Name = "Admin" + }, + new + { + Name = "Couple" + }, + new + { + Name = "Guest" + }); + }); + + modelBuilder.Entity("Eternelle.Modules.Users.Domain.Users.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("email"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("first_name"); + + b.Property("IdentityId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("identity_id"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("last_name"); + + b.Property("PhoneNumber") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("phone_number"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_users_email"); + + b.HasIndex("IdentityId") + .IsUnique() + .HasDatabaseName("ix_users_identity_id"); + + b.ToTable("users", "users"); + }); + + modelBuilder.Entity("PermissionRole", b => + { + b.Property("PermissionCode") + .HasColumnType("character varying(100)") + .HasColumnName("permission_code"); + + b.Property("RoleName") + .HasColumnType("character varying(50)") + .HasColumnName("role_name"); + + b.HasKey("PermissionCode", "RoleName") + .HasName("pk_role_permissions"); + + b.HasIndex("RoleName") + .HasDatabaseName("ix_role_permissions_role_name"); + + b.ToTable("role_permissions", "users"); + + b.HasData( + new + { + PermissionCode = "users:read", + RoleName = "Couple" + }, + new + { + PermissionCode = "users:update", + RoleName = "Couple" + }, + new + { + PermissionCode = "users:read", + RoleName = "Admin" + }, + new + { + PermissionCode = "users:update", + RoleName = "Admin" + }, + new + { + PermissionCode = "users:read", + RoleName = "Guest" + }); + }); + + modelBuilder.Entity("RoleUser", b => + { + b.Property("RolesName") + .HasColumnType("character varying(50)") + .HasColumnName("role_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("RolesName", "UserId") + .HasName("pk_user_roles"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_roles_user_id"); + + b.ToTable("user_roles", "users"); + }); + + modelBuilder.Entity("PermissionRole", b => + { + b.HasOne("Eternelle.Modules.Users.Domain.Users.Permission", null) + .WithMany() + .HasForeignKey("PermissionCode") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_permissions_permissions_permission_code"); + + b.HasOne("Eternelle.Modules.Users.Domain.Users.Role", null) + .WithMany() + .HasForeignKey("RoleName") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_permissions_roles_role_name"); + }); + + modelBuilder.Entity("RoleUser", b => + { + b.HasOne("Eternelle.Modules.Users.Domain.Users.Role", null) + .WithMany() + .HasForeignKey("RolesName") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_roles_roles_name"); + + b.HasOne("Eternelle.Modules.Users.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_users_user_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Schemas.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Schemas.cs new file mode 100644 index 0000000..896d1d2 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/Schemas.cs @@ -0,0 +1,6 @@ +namespace Eternelle.Modules.Users.Infrastructure.Database; + +internal static class Schemas +{ + internal const string Users = "users"; +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/UsersDbContext.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/UsersDbContext.cs new file mode 100644 index 0000000..21866c7 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Database/UsersDbContext.cs @@ -0,0 +1,33 @@ +using Eternelle.Common.Infrastructure.Inbox; +using Eternelle.Common.Infrastructure.Outbox; +using Eternelle.Modules.Users.Application.Abstractions.Data; +using Eternelle.Modules.Users.Domain.Users; +using Eternelle.Modules.Users.Infrastructure.Database.Converters; +using Eternelle.Modules.Users.Infrastructure.Users; +using Microsoft.EntityFrameworkCore; + +namespace Eternelle.Modules.Users.Infrastructure.Database; + +public sealed class UsersDbContext(DbContextOptions options) : DbContext(options), IUnitOfWork +{ + internal DbSet Users { get; set; } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.Properties() + .HaveConversion(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(Schemas.Users); + + modelBuilder.ApplyConfiguration(new OutboxMessageConfiguration()); + modelBuilder.ApplyConfiguration(new OutboxMessageConsumerConfiguration()); + modelBuilder.ApplyConfiguration(new InboxMessageConfiguration()); + modelBuilder.ApplyConfiguration(new InboxMessageConsumerConfiguration()); + modelBuilder.ApplyConfiguration(new UserConfiguration()); + modelBuilder.ApplyConfiguration(new RoleConfiguration()); + modelBuilder.ApplyConfiguration(new PermissionConfiguration()); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Eternelle.Modules.Users.Infrastructure.csproj b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Eternelle.Modules.Users.Infrastructure.csproj new file mode 100644 index 0000000..2f19678 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Eternelle.Modules.Users.Infrastructure.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/ConfigureProcessInboxJob.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/ConfigureProcessInboxJob.cs new file mode 100644 index 0000000..6055a13 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/ConfigureProcessInboxJob.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Options; +using Quartz; + +namespace Eternelle.Modules.Users.Infrastructure.Inbox; + +internal sealed class ConfigureProcessInboxJob(IOptions inboxOptions) + : IConfigureOptions +{ + private readonly InboxOptions _inboxOptions = inboxOptions.Value; + + public void Configure(QuartzOptions options) + { + string jobName = typeof(ProcessInboxJob).FullName!; + + options + .AddJob(configure => configure.WithIdentity(jobName)) + .AddTrigger(configure => + configure + .ForJob(jobName) + .WithSimpleSchedule(schedule => + schedule.WithIntervalInSeconds(_inboxOptions.IntervalInSeconds).RepeatForever())); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs new file mode 100644 index 0000000..a76eda5 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs @@ -0,0 +1,62 @@ +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.EventBus; +using Eternelle.Common.Infrastructure.Inbox; + +namespace Eternelle.Modules.Users.Infrastructure.Inbox; + +internal sealed class IdempotentIntegrationEventHandler( + IIntegrationEventHandler decorated, + IDbConnectionFactory dbConnectionFactory) + : IntegrationEventHandler + where TIntegrationEvent : IIntegrationEvent +{ + public override async Task Handle( + TIntegrationEvent integrationEvent, + CancellationToken cancellationToken = default) + { + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + + var inboxMessageConsumer = new InboxMessageConsumer(integrationEvent.Id, decorated.GetType().Name); + + if (await InboxConsumerExistsAsync(connection, inboxMessageConsumer)) + { + return; + } + + await decorated.Handle(integrationEvent, cancellationToken); + + await InsertInboxConsumerAsync(connection, inboxMessageConsumer); + } + + private static async Task InboxConsumerExistsAsync( + DbConnection dbConnection, + InboxMessageConsumer inboxMessageConsumer) + { + const string sql = + """ + SELECT EXISTS( + SELECT 1 + FROM users.inbox_message_consumers + WHERE inbox_message_id = @InboxMessageId AND + name = @Name + ) + """; + + return await dbConnection.ExecuteScalarAsync(sql, inboxMessageConsumer); + } + + private static async Task InsertInboxConsumerAsync( + DbConnection dbConnection, + InboxMessageConsumer inboxMessageConsumer) + { + const string sql = + """ + INSERT INTO users.inbox_message_consumers(inbox_message_id, name) + VALUES (@InboxMessageId, @Name) + """; + + await dbConnection.ExecuteAsync(sql, inboxMessageConsumer); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/InboxOptions.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/InboxOptions.cs new file mode 100644 index 0000000..ea61b93 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/InboxOptions.cs @@ -0,0 +1,8 @@ +namespace Eternelle.Modules.Users.Infrastructure.Inbox; + +internal sealed class InboxOptions +{ + public int IntervalInSeconds { get; init; } + + public int BatchSize { get; init; } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/IntegrationEventConsumer.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/IntegrationEventConsumer.cs new file mode 100644 index 0000000..3abbf06 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/IntegrationEventConsumer.cs @@ -0,0 +1,39 @@ +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.EventBus; +using Eternelle.Common.Infrastructure.Inbox; +using Eternelle.Common.Infrastructure.Serialization; +using MassTransit; +using Newtonsoft.Json; + +namespace Eternelle.Modules.Users.Infrastructure.Inbox; + +internal sealed class IntegrationEventConsumer(IDbConnectionFactory dbConnectionFactory) + : IConsumer + where TIntegrationEvent : IntegrationEvent +{ + public async Task Consume(ConsumeContext context) + { + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + + TIntegrationEvent integrationEvent = context.Message; + + var inboxMessage = new InboxMessage + { + Id = integrationEvent.Id, + Type = integrationEvent.GetType().Name, + Content = JsonConvert.SerializeObject(integrationEvent, SerializerSettings.Instance), + OccurredOnUtc = integrationEvent.OccurredOnUtc + }; + + const string sql = + """ + INSERT INTO users.inbox_messages(id, type, content, occurred_on_utc) + VALUES (@Id, @Type, @Content::json, @OccurredOnUtc) + ON CONFLICT (id) DO NOTHING + """; + + await connection.ExecuteAsync(sql, inboxMessage); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/ProcessInboxJob.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/ProcessInboxJob.cs new file mode 100644 index 0000000..92d553f --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/ProcessInboxJob.cs @@ -0,0 +1,143 @@ +using System.Data; +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.EventBus; +using Eternelle.Common.Infrastructure.Inbox; +using Eternelle.Common.Infrastructure.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Quartz; + +namespace Eternelle.Modules.Users.Infrastructure.Inbox; + +[DisallowConcurrentExecution] +internal sealed partial class ProcessInboxJob( + IDbConnectionFactory dbConnectionFactory, + IServiceScopeFactory serviceScopeFactory, + IDateTimeProvider dateTimeProvider, + IOptions inboxOptions, + ILogger logger) : IJob +{ + private const string ModuleName = "Users"; + + public async Task Execute(IJobExecutionContext context) + { + LogBeginProcessing(logger, ModuleName); + + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbTransaction transaction = await connection.BeginTransactionAsync(); + + IReadOnlyList inboxMessages = await GetInboxMessagesAsync(connection, transaction); + + foreach (InboxMessageResponse inboxMessage in inboxMessages) + { + Exception? exception = null; + + try + { + IIntegrationEvent integrationEvent = JsonConvert.DeserializeObject( + inboxMessage.Content, + SerializerSettings.Instance)!; + + using IServiceScope scope = serviceScopeFactory.CreateScope(); + + IEnumerable handlers = IntegrationEventHandlersFactory.GetHandlers( + integrationEvent.GetType(), + scope.ServiceProvider, + Presentation.AssemblyReference.Assembly); + + foreach (IIntegrationEventHandler integrationEventHandler in handlers) + { + await integrationEventHandler.Handle(integrationEvent, context.CancellationToken); + } + } + catch (Exception caughtException) + { + LogProcessingError(logger, caughtException, ModuleName, inboxMessage.Id); + + exception = caughtException; + } + + await UpdateInboxMessageAsync(connection, transaction, inboxMessage, exception); + } + + await transaction.CommitAsync(); + + LogCompleted(logger, ModuleName); + } + + private async Task> GetInboxMessagesAsync( + IDbConnection connection, + IDbTransaction transaction) + { + string sql = + $""" + SELECT + id AS {nameof(InboxMessageResponse.Id)}, + content AS {nameof(InboxMessageResponse.Content)} + FROM users.inbox_messages + WHERE processed_on_utc IS NULL + ORDER BY occurred_on_utc + LIMIT {inboxOptions.Value.BatchSize} + FOR UPDATE SKIP LOCKED + """; + + IEnumerable inboxMessages = await connection.QueryAsync( + sql, + transaction: transaction); + + return inboxMessages.AsList(); + } + + private async Task UpdateInboxMessageAsync( + IDbConnection connection, + IDbTransaction transaction, + InboxMessageResponse inboxMessage, + Exception? exception) + { + if (exception is null) + { + const string sql = + """ + UPDATE users.inbox_messages + SET processed_on_utc = @ProcessedOnUtc, + error = NULL + WHERE id = @Id + """; + + await connection.ExecuteAsync( + sql, + new { inboxMessage.Id, ProcessedOnUtc = dateTimeProvider.UtcNow }, + transaction: transaction); + } + else + { + const string sql = + """ + UPDATE users.inbox_messages + SET error = @Error + WHERE id = @Id + """; + + await connection.ExecuteAsync( + sql, + new { inboxMessage.Id, Error = exception.ToString() }, + transaction: transaction); + } + } + + internal sealed record InboxMessageResponse(Guid Id, string Content); + + [LoggerMessage(LogLevel.Information, "{module} - Beginning to process inbox messages")] + private static partial void LogBeginProcessing(ILogger logger, string module); + + [LoggerMessage(LogLevel.Information, "{module} - Completed processing inbox messages")] + private static partial void LogCompleted(ILogger logger, string module); + + [LoggerMessage(LogLevel.Error, "{module} - Exception while processing inbox message {messageId}")] + private static partial void LogProcessingError(ILogger logger, Exception exception, string module, Guid messageId); +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/ConfigureProcessOutboxJob.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/ConfigureProcessOutboxJob.cs new file mode 100644 index 0000000..f0e94e6 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/ConfigureProcessOutboxJob.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Options; +using Quartz; + +namespace Eternelle.Modules.Users.Infrastructure.Outbox; + +internal sealed class ConfigureProcessOutboxJob(IOptions outboxOptions) + : IConfigureOptions +{ + private readonly OutboxOptions _outboxOptions = outboxOptions.Value; + + public void Configure(QuartzOptions options) + { + string jobName = typeof(ProcessOutboxJob).FullName!; + + options + .AddJob(configure => configure.WithIdentity(jobName)) + .AddTrigger(configure => + configure + .ForJob(jobName) + .WithSimpleSchedule(schedule => + schedule.WithIntervalInSeconds(_outboxOptions.IntervalInSeconds).RepeatForever())); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/IdempotentDomainEventHandler.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/IdempotentDomainEventHandler.cs new file mode 100644 index 0000000..774927e --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/IdempotentDomainEventHandler.cs @@ -0,0 +1,61 @@ +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Common.Infrastructure.Outbox; + +namespace Eternelle.Modules.Users.Infrastructure.Outbox; + +internal sealed class IdempotentDomainEventHandler( + IDomainEventHandler decorated, + IDbConnectionFactory dbConnectionFactory) + : DomainEventHandler + where TDomainEvent : IDomainEvent +{ + public override async Task Handle(TDomainEvent domainEvent, CancellationToken cancellationToken = default) + { + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + + var outboxMessageConsumer = new OutboxMessageConsumer(domainEvent.Id, decorated.GetType().Name); + + if (await OutboxConsumerExistsAsync(connection, outboxMessageConsumer)) + { + return; + } + + await decorated.Handle(domainEvent, cancellationToken); + + await InsertOutboxConsumerAsync(connection, outboxMessageConsumer); + } + + private static async Task OutboxConsumerExistsAsync( + DbConnection dbConnection, + OutboxMessageConsumer outboxMessageConsumer) + { + const string sql = + """ + SELECT EXISTS( + SELECT 1 + FROM users.outbox_message_consumers + WHERE outbox_message_id = @OutboxMessageId AND + name = @Name + ) + """; + + return await dbConnection.ExecuteScalarAsync(sql, outboxMessageConsumer); + } + + private static async Task InsertOutboxConsumerAsync( + DbConnection dbConnection, + OutboxMessageConsumer outboxMessageConsumer) + { + const string sql = + """ + INSERT INTO users.outbox_message_consumers(outbox_message_id, name) + VALUES (@OutboxMessageId, @Name) + """; + + await dbConnection.ExecuteAsync(sql, outboxMessageConsumer); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/OutboxOptions.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/OutboxOptions.cs new file mode 100644 index 0000000..4d501fe --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/OutboxOptions.cs @@ -0,0 +1,8 @@ +namespace Eternelle.Modules.Users.Infrastructure.Outbox; + +internal sealed class OutboxOptions +{ + public int IntervalInSeconds { get; init; } + + public int BatchSize { get; init; } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/ProcessOutboxJob.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/ProcessOutboxJob.cs new file mode 100644 index 0000000..e97b585 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/ProcessOutboxJob.cs @@ -0,0 +1,144 @@ +using System.Data; +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Common.Infrastructure.Outbox; +using Eternelle.Common.Infrastructure.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Quartz; + +namespace Eternelle.Modules.Users.Infrastructure.Outbox; + +[DisallowConcurrentExecution] +internal sealed partial class ProcessOutboxJob( + IDbConnectionFactory dbConnectionFactory, + IServiceScopeFactory serviceScopeFactory, + IDateTimeProvider dateTimeProvider, + IOptions outboxOptions, + ILogger logger) : IJob +{ + private const string ModuleName = "Users"; + + public async Task Execute(IJobExecutionContext context) + { + LogBeginProcessing(logger, ModuleName); + + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbTransaction transaction = await connection.BeginTransactionAsync(); + + IReadOnlyList outboxMessages = await GetOutboxMessagesAsync(connection, transaction); + + foreach (OutboxMessageResponse outboxMessage in outboxMessages) + { + Exception? exception = null; + + try + { + IDomainEvent domainEvent = JsonConvert.DeserializeObject( + outboxMessage.Content, + SerializerSettings.Instance)!; + + using IServiceScope scope = serviceScopeFactory.CreateScope(); + + IEnumerable handlers = DomainEventHandlersFactory.GetHandlers( + domainEvent.GetType(), + scope.ServiceProvider, + Application.AssemblyReference.Assembly); + + foreach (IDomainEventHandler domainEventHandler in handlers) + { + await domainEventHandler.Handle(domainEvent, context.CancellationToken); + } + } + catch (Exception caughtException) + { + LogProcessingError(logger, caughtException, ModuleName, outboxMessage.Id); + + exception = caughtException; + } + + await UpdateOutboxMessageAsync(connection, transaction, outboxMessage, exception); + } + + await transaction.CommitAsync(); + + LogCompleted(logger, ModuleName); + } + + private async Task> GetOutboxMessagesAsync( + IDbConnection connection, + IDbTransaction transaction) + { + string sql = + $""" + SELECT + id AS {nameof(OutboxMessageResponse.Id)}, + content AS {nameof(OutboxMessageResponse.Content)} + FROM users.outbox_messages + WHERE processed_on_utc IS NULL + ORDER BY occurred_on_utc + LIMIT {outboxOptions.Value.BatchSize} + FOR UPDATE SKIP LOCKED + """; + + IEnumerable outboxMessages = await connection.QueryAsync( + sql, + transaction: transaction); + + return outboxMessages.ToList(); + } + + private async Task UpdateOutboxMessageAsync( + IDbConnection connection, + IDbTransaction transaction, + OutboxMessageResponse outboxMessage, + Exception? exception) + { + if (exception is null) + { + const string sql = + """ + UPDATE users.outbox_messages + SET processed_on_utc = @ProcessedOnUtc, + error = NULL + WHERE id = @Id + """; + + await connection.ExecuteAsync( + sql, + new { outboxMessage.Id, ProcessedOnUtc = dateTimeProvider.UtcNow }, + transaction: transaction); + } + else + { + const string sql = + """ + UPDATE users.outbox_messages + SET error = @Error + WHERE id = @Id + """; + + await connection.ExecuteAsync( + sql, + new { outboxMessage.Id, Error = exception.ToString() }, + transaction: transaction); + } + } + + internal sealed record OutboxMessageResponse(Guid Id, string Content); + + [LoggerMessage(LogLevel.Information, "{module} - Beginning to process outbox messages")] + private static partial void LogBeginProcessing(ILogger logger, string module); + + [LoggerMessage(LogLevel.Information, "{module} - Completed processing outbox messages")] + private static partial void LogCompleted(ILogger logger, string module); + + [LoggerMessage(LogLevel.Error, "{module} - Exception while processing outbox message {messageId}")] + private static partial void LogProcessingError(ILogger logger, Exception exception, string module, Guid messageId); +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/PermissionConfiguration.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/PermissionConfiguration.cs new file mode 100644 index 0000000..63b50e4 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/PermissionConfiguration.cs @@ -0,0 +1,48 @@ +using Eternelle.Modules.Users.Domain.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Eternelle.Modules.Users.Infrastructure.Users; + +internal sealed class PermissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("permissions"); + + builder.HasKey(p => p.Code); + + builder.Property(p => p.Code).HasMaxLength(Permission.CodeMaxLength); + + builder.HasData( + Permission.GetUser, + Permission.ModifyUser); + + builder + .HasMany() + .WithMany() + .UsingEntity(joinBuilder => + { + joinBuilder.ToTable("role_permissions"); + + joinBuilder.HasData( + // Couple — a wedding owner manages their own profile. + CreateRolePermission(Role.Couple, Permission.GetUser), + CreateRolePermission(Role.Couple, Permission.ModifyUser), + // Admin — full user management. + CreateRolePermission(Role.Admin, Permission.GetUser), + CreateRolePermission(Role.Admin, Permission.ModifyUser), + // Guest — read-only profile. + CreateRolePermission(Role.Guest, Permission.GetUser)); + }); + } + + private static object CreateRolePermission(Role role, Permission permission) + { + return new + { + RoleName = role.Name, + PermissionCode = permission.Code + }; + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/RoleConfiguration.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/RoleConfiguration.cs new file mode 100644 index 0000000..6023644 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/RoleConfiguration.cs @@ -0,0 +1,32 @@ +using Eternelle.Modules.Users.Domain.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Eternelle.Modules.Users.Infrastructure.Users; + +internal sealed class RoleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("roles"); + + builder.HasKey(r => r.Name); + + builder.Property(r => r.Name).HasMaxLength(Role.NameMaxLength); + + builder + .HasMany() + .WithMany(u => u.Roles) + .UsingEntity(joinBuilder => + { + joinBuilder.ToTable("user_roles"); + + joinBuilder.Property("RolesName").HasColumnName("role_name"); + }); + + builder.HasData( + Role.Admin, + Role.Couple, + Role.Guest); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/UserConfiguration.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/UserConfiguration.cs new file mode 100644 index 0000000..8ddd3cb --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/UserConfiguration.cs @@ -0,0 +1,47 @@ +using Eternelle.Modules.Users.Domain.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Eternelle.Modules.Users.Infrastructure.Users; + +internal sealed class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users"); + + builder.HasKey(u => u.Id); + + builder.Property(u => u.Id) + .ValueGeneratedNever(); + + builder.Property(u => u.IdentityId).IsRequired(); + + builder.Property(u => u.Email) + .HasConversion(v => v.Value, v => Email.FromPersistence(v)) + .IsRequired() + .HasMaxLength(Email.MaxLength); + + builder.Property(u => u.FirstName) + .HasConversion(v => v.Value, v => PersonName.FromPersistence(v)) + .IsRequired() + .HasMaxLength(PersonName.MaxLength); + + builder.Property(u => u.LastName) + .HasConversion(v => v.Value, v => PersonName.FromPersistence(v)) + .IsRequired() + .HasMaxLength(PersonName.MaxLength); + + // PhoneNumber is an optional VO. ?. is not allowed in expression-tree lambdas, + // so use explicit null checks (same approach as WeddingConfiguration.Hashtag). + builder.Property(u => u.PhoneNumber) + .HasConversion( + v => v != null ? v.Value : null, + v => v != null ? PhoneNumber.FromPersistence(v) : null) + .HasMaxLength(PhoneNumber.MaxLength); + + builder.HasIndex(u => u.Email).IsUnique(); + + builder.HasIndex(u => u.IdentityId).IsUnique(); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/UserRepository.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/UserRepository.cs new file mode 100644 index 0000000..80ae658 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Users/UserRepository.cs @@ -0,0 +1,37 @@ +using Eternelle.Modules.Users.Domain.Users; +using Eternelle.Modules.Users.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace Eternelle.Modules.Users.Infrastructure.Users; + +internal sealed class UserRepository(UsersDbContext context) : IUserRepository +{ + public async Task GetAsync(UserId id, CancellationToken cancellationToken = default) + { + return await context.Users + .Include(u => u.Roles) + .SingleOrDefaultAsync(u => u.Id == id, cancellationToken); + } + + public async Task GetByIdentityIdAsync(string identityId, CancellationToken cancellationToken = default) + { + return await context.Users + .Include(u => u.Roles) + .SingleOrDefaultAsync(u => u.IdentityId == identityId, cancellationToken); + } + + public void Insert(User user) + { + foreach (Role role in user.Roles) + { + context.Attach(role); + } + + context.Users.Add(user); + } + + public void Update(User user) + { + context.Users.Update(user); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/UsersModule.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/UsersModule.cs new file mode 100644 index 0000000..bc1c717 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/UsersModule.cs @@ -0,0 +1,118 @@ +using Eternelle.Common.Application.Authorization; +using Eternelle.Common.Application.EventBus; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Infrastructure.Outbox; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Modules.Users.Application.Abstractions.Data; +using Eternelle.Modules.Users.Domain.Users; +using Eternelle.Modules.Users.Infrastructure.Authorization; +using Eternelle.Modules.Users.Infrastructure.Database; +using Eternelle.Modules.Users.Infrastructure.Inbox; +using Eternelle.Modules.Users.Infrastructure.Outbox; +using Eternelle.Modules.Users.Infrastructure.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Eternelle.Modules.Users.Infrastructure; + +public static class UsersModule +{ + public static IServiceCollection AddUsersModule( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddDomainEventHandlers(); + + services.AddIntegrationEventHandlers(); + + services.AddInfrastructure(configuration); + + services.AddEndpoints(Presentation.AssemblyReference.Assembly); + + return services; + } + + private static void AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + + services.AddDbContext((sp, options) => + options + .UseNpgsql( + configuration.GetConnectionString("Database"), + npgsqlOptions => npgsqlOptions + .MigrationsHistoryTable(HistoryRepository.DefaultTableName, Schemas.Users)) + .AddInterceptors(sp.GetRequiredService()) + .UseSnakeCaseNamingConvention()); + + services.AddScoped(); + + services.AddScoped(sp => sp.GetRequiredService()); + + services.AddOptions() + .Bind(configuration.GetSection("Users:Outbox")) + .Validate(o => o.IntervalInSeconds > 0 && o.BatchSize > 0, + "Users:Outbox: IntervalInSeconds and BatchSize must be greater than 0.") + .ValidateOnStart(); + + services.ConfigureOptions(); + + services.AddOptions() + .Bind(configuration.GetSection("Users:Inbox")) + .Validate(o => o.IntervalInSeconds > 0 && o.BatchSize > 0, + "Users:Inbox: IntervalInSeconds and BatchSize must be greater than 0.") + .ValidateOnStart(); + + services.ConfigureOptions(); + } + + private static void AddDomainEventHandlers(this IServiceCollection services) + { + Type[] domainEventHandlers = Application.AssemblyReference.Assembly + .GetTypes() + .Where(t => t.IsAssignableTo(typeof(IDomainEventHandler))) + .ToArray(); + + foreach (Type domainEventHandler in domainEventHandlers) + { + services.TryAddScoped(domainEventHandler); + + Type domainEvent = domainEventHandler + .GetInterfaces() + .Single(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDomainEventHandler<>)) + .GetGenericArguments() + .Single(); + + Type closedIdempotentHandler = typeof(IdempotentDomainEventHandler<>).MakeGenericType(domainEvent); + + services.Decorate(domainEventHandler, closedIdempotentHandler); + } + } + + private static void AddIntegrationEventHandlers(this IServiceCollection services) + { + Type[] integrationEventHandlers = Presentation.AssemblyReference.Assembly + .GetTypes() + .Where(t => t.IsAssignableTo(typeof(IIntegrationEventHandler))) + .ToArray(); + + foreach (Type integrationEventHandler in integrationEventHandlers) + { + services.TryAddScoped(integrationEventHandler); + + Type integrationEvent = integrationEventHandler + .GetInterfaces() + .Single(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IIntegrationEventHandler<>)) + .GetGenericArguments() + .Single(); + + Type closedIdempotentHandler = + typeof(IdempotentIntegrationEventHandler<>).MakeGenericType(integrationEvent); + + services.Decorate(integrationEventHandler, closedIdempotentHandler); + } + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.IntegrationEvents/Eternelle.Modules.Users.IntegrationEvents.csproj b/src/Modules/Users/Eternelle.Modules.Users.IntegrationEvents/Eternelle.Modules.Users.IntegrationEvents.csproj new file mode 100644 index 0000000..b9e5a29 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.IntegrationEvents/Eternelle.Modules.Users.IntegrationEvents.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Modules/Users/Eternelle.Modules.Users.IntegrationEvents/UserProfileUpdatedIntegrationEvent.cs b/src/Modules/Users/Eternelle.Modules.Users.IntegrationEvents/UserProfileUpdatedIntegrationEvent.cs new file mode 100644 index 0000000..7e93df0 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.IntegrationEvents/UserProfileUpdatedIntegrationEvent.cs @@ -0,0 +1,29 @@ +using Eternelle.Common.Application.EventBus; + +namespace Eternelle.Modules.Users.IntegrationEvents; + +public sealed class UserProfileUpdatedIntegrationEvent : IntegrationEvent +{ + public UserProfileUpdatedIntegrationEvent( + Guid id, + DateTime occurredOnUtc, + Guid userId, + string firstName, + string lastName, + string? phoneNumber) + : base(id, occurredOnUtc) + { + UserId = userId; + FirstName = firstName; + LastName = lastName; + PhoneNumber = phoneNumber; + } + + public Guid UserId { get; init; } + + public string FirstName { get; init; } + + public string LastName { get; init; } + + public string? PhoneNumber { get; init; } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.IntegrationEvents/UserRegisteredIntegrationEvent.cs b/src/Modules/Users/Eternelle.Modules.Users.IntegrationEvents/UserRegisteredIntegrationEvent.cs new file mode 100644 index 0000000..cbb3168 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.IntegrationEvents/UserRegisteredIntegrationEvent.cs @@ -0,0 +1,29 @@ +using Eternelle.Common.Application.EventBus; + +namespace Eternelle.Modules.Users.IntegrationEvents; + +public sealed class UserRegisteredIntegrationEvent : IntegrationEvent +{ + public UserRegisteredIntegrationEvent( + Guid id, + DateTime occurredOnUtc, + Guid userId, + string email, + string firstName, + string lastName) + : base(id, occurredOnUtc) + { + UserId = userId; + Email = email; + FirstName = firstName; + LastName = lastName; + } + + public Guid UserId { get; init; } + + public string Email { get; init; } + + public string FirstName { get; init; } + + public string LastName { get; init; } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Presentation/AssemblyReference.cs b/src/Modules/Users/Eternelle.Modules.Users.Presentation/AssemblyReference.cs new file mode 100644 index 0000000..4917447 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Presentation/AssemblyReference.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace Eternelle.Modules.Users.Presentation; + +public static class AssemblyReference +{ + public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly; +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Presentation/Eternelle.Modules.Users.Presentation.csproj b/src/Modules/Users/Eternelle.Modules.Users.Presentation/Eternelle.Modules.Users.Presentation.csproj new file mode 100644 index 0000000..18d3bdd --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Presentation/Eternelle.Modules.Users.Presentation.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Modules/Users/Eternelle.Modules.Users.Presentation/Permissions.cs b/src/Modules/Users/Eternelle.Modules.Users.Presentation/Permissions.cs new file mode 100644 index 0000000..c3b1c9f --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Presentation/Permissions.cs @@ -0,0 +1,7 @@ +namespace Eternelle.Modules.Users.Presentation; + +internal static class Permissions +{ + internal const string GetUser = "users:read"; + internal const string ModifyUser = "users:update"; +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Presentation/Tags.cs b/src/Modules/Users/Eternelle.Modules.Users.Presentation/Tags.cs new file mode 100644 index 0000000..024cf26 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Presentation/Tags.cs @@ -0,0 +1,6 @@ +namespace Eternelle.Modules.Users.Presentation; + +internal static class Tags +{ + internal const string Users = "Users"; +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Presentation/Users/GetUserProfile.cs b/src/Modules/Users/Eternelle.Modules.Users.Presentation/Users/GetUserProfile.cs new file mode 100644 index 0000000..3b6599d --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Presentation/Users/GetUserProfile.cs @@ -0,0 +1,27 @@ +using System.Security.Claims; +using Eternelle.Common.Domain; +using Eternelle.Common.Application.Authentication; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Users.Application.Users.GetUser; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Users.Presentation.Users; + +internal sealed class GetUserProfile : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("users/profile", async (ClaimsPrincipal claims, ISender sender) => + { + Result result = await sender.Send(new GetUserQuery(claims.GetUserId())); + + return result.Match(Results.Ok, ApiResults.Problem); + }) + .RequireAuthorization(Permissions.GetUser) + .WithTags(Tags.Users); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Presentation/Users/RegisterUser.cs b/src/Modules/Users/Eternelle.Modules.Users.Presentation/Users/RegisterUser.cs new file mode 100644 index 0000000..0e8ba35 --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Presentation/Users/RegisterUser.cs @@ -0,0 +1,40 @@ +using System.Security.Claims; +using Eternelle.Common.Domain; +using Eternelle.Common.Application.Authentication; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Users.Application.Users.RegisterUser; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Users.Presentation.Users; + +internal sealed class RegisterUser : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPost("users/register", async (ClaimsPrincipal claims, ISender sender) => + { + string? email = claims.FindFirstValue(ClaimTypes.Email); + string? firstName = claims.FindFirstValue(ClaimTypes.GivenName); + string? lastName = claims.FindFirstValue(ClaimTypes.Surname); + + if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(firstName) || string.IsNullOrWhiteSpace(lastName)) + { + return Results.Problem("Required identity claims are missing.", statusCode: StatusCodes.Status400BadRequest); + } + + Result result = await sender.Send(new RegisterUserCommand( + claims.GetIdentityId(), + email, + firstName, + lastName)); + + return result.Match(Results.Ok, ApiResults.Problem); + }) + .RequireAuthorization() + .WithTags(Tags.Users); + } +} diff --git a/src/Modules/Users/Eternelle.Modules.Users.Presentation/Users/UpdateUserProfile.cs b/src/Modules/Users/Eternelle.Modules.Users.Presentation/Users/UpdateUserProfile.cs new file mode 100644 index 0000000..c20960b --- /dev/null +++ b/src/Modules/Users/Eternelle.Modules.Users.Presentation/Users/UpdateUserProfile.cs @@ -0,0 +1,40 @@ +using System.Security.Claims; +using Eternelle.Common.Domain; +using Eternelle.Common.Application.Authentication; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Users.Application.Users.UpdateUser; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Users.Presentation.Users; + +internal sealed class UpdateUserProfile : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPut("users/profile", async (Request request, ClaimsPrincipal claims, ISender sender) => + { + Result result = await sender.Send(new UpdateUserCommand( + claims.GetUserId(), + request.FirstName, + request.LastName, + request.PhoneNumber)); + + return result.Match(Results.NoContent, ApiResults.Problem); + }) + .RequireAuthorization(Permissions.ModifyUser) + .WithTags(Tags.Users); + } + + internal sealed class Request + { + public required string FirstName { get; init; } + + public required string LastName { get; init; } + + public string? PhoneNumber { get; init; } + } +} diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Weddings/Wedding.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Weddings/Wedding.cs index c4e49ba..efcaabb 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Weddings/Wedding.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Weddings/Wedding.cs @@ -126,7 +126,9 @@ public Result UpdateDetails( public void ChangePlan(WeddingPlan plan, DateTime utcNow) { if (plan == Plan) + { return; + } WeddingPlan previousPlan = Plan; Plan = plan; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs index 935002c..a516f84 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs @@ -19,7 +19,7 @@ public override async Task Handle( { await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); - var inboxMessageConsumer = new InboxMessageConsumer(integrationEvent.Id, decorated.GetType().FullName!); + var inboxMessageConsumer = new InboxMessageConsumer(integrationEvent.Id, decorated.GetType().Name); if (await InboxConsumerExistsAsync(connection, inboxMessageConsumer)) { diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Inbox/ProcessInboxJob.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Inbox/ProcessInboxJob.cs index ed8516d..a8b2004 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Inbox/ProcessInboxJob.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Inbox/ProcessInboxJob.cs @@ -83,7 +83,7 @@ FROM wedding.inbox_messages WHERE processed_on_utc IS NULL ORDER BY occurred_on_utc LIMIT {inboxOptions.Value.BatchSize} - FOR UPDATE + FOR UPDATE SKIP LOCKED """; IEnumerable inboxMessages = await connection.QueryAsync( @@ -99,23 +99,35 @@ private async Task UpdateInboxMessageAsync( InboxMessageResponse inboxMessage, Exception? exception) { - const string sql = - """ - UPDATE wedding.inbox_messages - SET processed_on_utc = @ProcessedOnUtc, - error = @Error - WHERE id = @Id - """; - - await connection.ExecuteAsync( - sql, - new - { - inboxMessage.Id, - ProcessedOnUtc = dateTimeProvider.UtcNow, - Error = exception?.ToString() - }, - transaction: transaction); + if (exception is null) + { + const string sql = + """ + UPDATE wedding.inbox_messages + SET processed_on_utc = @ProcessedOnUtc, + error = NULL + WHERE id = @Id + """; + + await connection.ExecuteAsync( + sql, + new { inboxMessage.Id, ProcessedOnUtc = dateTimeProvider.UtcNow }, + transaction: transaction); + } + else + { + const string sql = + """ + UPDATE wedding.inbox_messages + SET error = @Error + WHERE id = @Id + """; + + await connection.ExecuteAsync( + sql, + new { inboxMessage.Id, Error = exception.ToString() }, + transaction: transaction); + } } internal sealed record InboxMessageResponse(Guid Id, string Content); diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Outbox/IdempotentDomainEventHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Outbox/IdempotentDomainEventHandler.cs index 59f5a08..1d74f65 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Outbox/IdempotentDomainEventHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Outbox/IdempotentDomainEventHandler.cs @@ -18,7 +18,7 @@ public override async Task Handle(TDomainEvent domainEvent, CancellationToken ca { await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); - var outboxMessageConsumer = new OutboxMessageConsumer(domainEvent.Id, decorated.GetType().FullName!); + var outboxMessageConsumer = new OutboxMessageConsumer(domainEvent.Id, decorated.GetType().Name); if (await OutboxConsumerExistsAsync(connection, outboxMessageConsumer)) { diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Outbox/ProcessOutboxJob.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Outbox/ProcessOutboxJob.cs index f9164db..9237f80 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Outbox/ProcessOutboxJob.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Outbox/ProcessOutboxJob.cs @@ -84,7 +84,7 @@ FROM wedding.outbox_messages WHERE processed_on_utc IS NULL ORDER BY occurred_on_utc LIMIT {outboxOptions.Value.BatchSize} - FOR UPDATE + FOR UPDATE SKIP LOCKED """; IEnumerable outboxMessages = await connection.QueryAsync( @@ -100,23 +100,35 @@ private async Task UpdateOutboxMessageAsync( OutboxMessageResponse outboxMessage, Exception? exception) { - const string sql = - """ - UPDATE wedding.outbox_messages - SET processed_on_utc = @ProcessedOnUtc, - error = @Error - WHERE id = @Id - """; - - await connection.ExecuteAsync( - sql, - new - { - outboxMessage.Id, - ProcessedOnUtc = dateTimeProvider.UtcNow, - Error = exception?.ToString() - }, - transaction: transaction); + if (exception is null) + { + const string sql = + """ + UPDATE wedding.outbox_messages + SET processed_on_utc = @ProcessedOnUtc, + error = NULL + WHERE id = @Id + """; + + await connection.ExecuteAsync( + sql, + new { outboxMessage.Id, ProcessedOnUtc = dateTimeProvider.UtcNow }, + transaction: transaction); + } + else + { + const string sql = + """ + UPDATE wedding.outbox_messages + SET error = @Error + WHERE id = @Id + """; + + await connection.ExecuteAsync( + sql, + new { outboxMessage.Id, Error = exception.ToString() }, + transaction: transaction); + } } internal sealed record OutboxMessageResponse(Guid Id, string Content); diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/WeddingsModule.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/WeddingsModule.cs index 616603c..63f6cc1 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/WeddingsModule.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/WeddingsModule.cs @@ -105,11 +105,19 @@ private static void AddInfrastructure(this IServiceCollection services, IConfigu .Validate(opts => !string.IsNullOrWhiteSpace(opts.BucketName), "PhotoStorage:BucketName must be provided") .ValidateOnStart(); - services.Configure(configuration.GetSection("Weddings:Outbox")); + services.AddOptions() + .Bind(configuration.GetSection("Weddings:Outbox")) + .Validate(o => o.IntervalInSeconds > 0 && o.BatchSize > 0, + "Weddings:Outbox: IntervalInSeconds and BatchSize must be greater than 0.") + .ValidateOnStart(); services.ConfigureOptions(); - services.Configure(configuration.GetSection("Weddings:Inbox")); + services.AddOptions() + .Bind(configuration.GetSection("Weddings:Inbox")) + .Validate(o => o.IntervalInSeconds > 0 && o.BatchSize > 0, + "Weddings:Inbox: IntervalInSeconds and BatchSize must be greater than 0.") + .ValidateOnStart(); services.ConfigureOptions(); } @@ -127,7 +135,7 @@ private static void AddDomainEventHandlers(this IServiceCollection services) Type domainEvent = domainEventHandler .GetInterfaces() - .Single(i => i.IsGenericType) + .Single(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDomainEventHandler<>)) .GetGenericArguments() .Single(); @@ -150,7 +158,7 @@ private static void AddIntegrationEventHandlers(this IServiceCollection services Type integrationEvent = integrationEventHandler .GetInterfaces() - .Single(i => i.IsGenericType) + .Single(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IIntegrationEventHandler<>)) .GetGenericArguments() .Single(); From 1b819b6f593a5f834c6adf73cbbbc32fd26d197d Mon Sep 17 00:00:00 2001 From: Carl Marion Date: Wed, 27 May 2026 12:27:28 +0800 Subject: [PATCH 003/155] feat(catalog): scaffold five-project module structure --- Eternelle.slnx | 22 +++++++++++++++++++ .../AssemblyReference.cs | 8 +++++++ ...ernelle.Modules.Catalog.Application.csproj | 9 ++++++++ .../Eternelle.Modules.Catalog.Domain.csproj | 7 ++++++ .../Database/Schemas.cs | 6 +++++ ...elle.Modules.Catalog.Infrastructure.csproj | 16 ++++++++++++++ ...e.Modules.Catalog.IntegrationEvents.csproj | 7 ++++++ .../AssemblyReference.cs | 8 +++++++ ...rnelle.Modules.Catalog.Presentation.csproj | 8 +++++++ 9 files changed, 91 insertions(+) create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Application/AssemblyReference.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Eternelle.Modules.Catalog.Application.csproj create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Eternelle.Modules.Catalog.Domain.csproj create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Schemas.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Eternelle.Modules.Catalog.Infrastructure.csproj create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.IntegrationEvents/Eternelle.Modules.Catalog.IntegrationEvents.csproj create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Presentation/AssemblyReference.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Presentation/Eternelle.Modules.Catalog.Presentation.csproj 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/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.Domain/Eternelle.Modules.Catalog.Domain.csproj b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Eternelle.Modules.Catalog.Domain.csproj new file mode 100644 index 0000000..06763b3 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Eternelle.Modules.Catalog.Domain.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Schemas.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Schemas.cs new file mode 100644 index 0000000..ee3561e --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Schemas.cs @@ -0,0 +1,6 @@ +namespace Eternelle.Modules.Catalog.Infrastructure.Database; + +internal static class Schemas +{ + internal const string Catalog = "catalog"; +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Eternelle.Modules.Catalog.Infrastructure.csproj b/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Eternelle.Modules.Catalog.Infrastructure.csproj new file mode 100644 index 0000000..6e1754e --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Eternelle.Modules.Catalog.Infrastructure.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.IntegrationEvents/Eternelle.Modules.Catalog.IntegrationEvents.csproj b/src/Modules/Catalog/Eternelle.Modules.Catalog.IntegrationEvents/Eternelle.Modules.Catalog.IntegrationEvents.csproj new file mode 100644 index 0000000..b9e5a29 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.IntegrationEvents/Eternelle.Modules.Catalog.IntegrationEvents.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Presentation/AssemblyReference.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Presentation/AssemblyReference.cs new file mode 100644 index 0000000..711bf77 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Presentation/AssemblyReference.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace Eternelle.Modules.Catalog.Presentation; + +public static class AssemblyReference +{ + public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly; +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Presentation/Eternelle.Modules.Catalog.Presentation.csproj b/src/Modules/Catalog/Eternelle.Modules.Catalog.Presentation/Eternelle.Modules.Catalog.Presentation.csproj new file mode 100644 index 0000000..297b70e --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Presentation/Eternelle.Modules.Catalog.Presentation.csproj @@ -0,0 +1,8 @@ + + + + + + + + From 75139b7f873938bbfb6609450d3125897158dedb Mon Sep 17 00:00:00 2001 From: Carl Marion Date: Wed, 27 May 2026 12:43:49 +0800 Subject: [PATCH 004/155] feat(catalog): add TemplateTier, PriceClass, SectionId enums --- .../Templates/PriceClass.cs | 8 ++++++++ .../Templates/SectionId.cs | 20 +++++++++++++++++++ .../Templates/TemplateTier.cs | 7 +++++++ 3 files changed, 35 insertions(+) create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/PriceClass.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/SectionId.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateTier.cs diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/PriceClass.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/PriceClass.cs new file mode 100644 index 0000000..21ef21e --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/PriceClass.cs @@ -0,0 +1,8 @@ +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public enum PriceClass +{ + Free = 1, + Pro = 2, + Plus = 3 +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/SectionId.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/SectionId.cs new file mode 100644 index 0000000..d87cdaa --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/SectionId.cs @@ -0,0 +1,20 @@ +namespace Eternelle.Modules.Catalog.Domain.Templates; + +// Persisted as the enum NAME (string) so the Tenancy module can match selections +// against this catalog by a stable identifier. Do not renumber/rename without a migration. +public enum SectionId +{ + Hero, + Story, + Entourage, + DressCode, + Gallery, + GiftGuide, + Guestbook, + Venue, + Reminders, + Rsvp, + VendorCredits, + SnapShare, + GuestUploads +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateTier.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateTier.cs new file mode 100644 index 0000000..1c94a31 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateTier.cs @@ -0,0 +1,7 @@ +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public enum TemplateTier +{ + FirstClass = 1, + Signature = 2 +} From ab649c539670a632fb93ff0e94e0ccf19f4b8298 Mon Sep 17 00:00:00 2001 From: Carl Marion Date: Wed, 27 May 2026 13:02:47 +0800 Subject: [PATCH 005/155] feat(catalog): add strongly-typed ids for Template and children --- .../Templates/TemplateId.cs | 7 +++++++ .../Templates/TemplateSectionVariantId.cs | 7 +++++++ .../Templates/TemplateSupportedSectionId.cs | 7 +++++++ .../Templates/TemplateThemeId.cs | 7 +++++++ 4 files changed, 28 insertions(+) create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateId.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSectionVariantId.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSupportedSectionId.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateThemeId.cs diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateId.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateId.cs new file mode 100644 index 0000000..619eb4c --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateId.cs @@ -0,0 +1,7 @@ +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public readonly record struct TemplateId(Guid Value) +{ + public static TemplateId New() => new(Guid.CreateVersion7()); + public override string ToString() => Value.ToString(); +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSectionVariantId.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSectionVariantId.cs new file mode 100644 index 0000000..c585487 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSectionVariantId.cs @@ -0,0 +1,7 @@ +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public readonly record struct TemplateSectionVariantId(Guid Value) +{ + public static TemplateSectionVariantId New() => new(Guid.CreateVersion7()); + public override string ToString() => Value.ToString(); +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSupportedSectionId.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSupportedSectionId.cs new file mode 100644 index 0000000..0f50248 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSupportedSectionId.cs @@ -0,0 +1,7 @@ +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public readonly record struct TemplateSupportedSectionId(Guid Value) +{ + public static TemplateSupportedSectionId New() => new(Guid.CreateVersion7()); + public override string ToString() => Value.ToString(); +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateThemeId.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateThemeId.cs new file mode 100644 index 0000000..d0c1508 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateThemeId.cs @@ -0,0 +1,7 @@ +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public readonly record struct TemplateThemeId(Guid Value) +{ + public static TemplateThemeId New() => new(Guid.CreateVersion7()); + public override string ToString() => Value.ToString(); +} From e88b1bdfd48a4e51abe275cb54e5853e59d879a4 Mon Sep 17 00:00:00 2001 From: Carl Marion Date: Wed, 27 May 2026 13:04:19 +0800 Subject: [PATCH 006/155] feat(catalog): add value objects and errors --- .../Shared/ImageUrl.cs | 38 +++++++++++++++++++ .../Shared/ImageUrlErrors.cs | 15 ++++++++ .../Templates/TemplateDescription.cs | 33 ++++++++++++++++ .../Templates/TemplateDescriptionErrors.cs | 12 ++++++ .../Templates/TemplateName.cs | 33 ++++++++++++++++ .../Templates/TemplateNameErrors.cs | 12 ++++++ .../Templates/TemplateThemeName.cs | 33 ++++++++++++++++ .../Templates/TemplateThemeNameErrors.cs | 12 ++++++ 8 files changed, 188 insertions(+) create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Shared/ImageUrl.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Shared/ImageUrlErrors.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateDescription.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateDescriptionErrors.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateName.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateNameErrors.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateThemeName.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateThemeNameErrors.cs diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Shared/ImageUrl.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Shared/ImageUrl.cs new file mode 100644 index 0000000..917e306 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Shared/ImageUrl.cs @@ -0,0 +1,38 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Catalog.Domain.Shared; + +public sealed record ImageUrl +{ + public const int MaxLength = 2048; + + private ImageUrl(string value) => Value = value; + + public string Value { get; } + + public static Result Create(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return Result.Failure(ImageUrlErrors.Empty); + } + + string trimmed = raw.Trim(); + + if (trimmed.Length > MaxLength) + { + return Result.Failure(ImageUrlErrors.TooLong); + } + + if (!Uri.TryCreate(trimmed, UriKind.RelativeOrAbsolute, out _)) + { + return Result.Failure(ImageUrlErrors.InvalidFormat); + } + + return Result.Success(new ImageUrl(trimmed)); + } + + public override string ToString() => Value; + + internal static ImageUrl FromPersistence(string value) => new(value); +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Shared/ImageUrlErrors.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Shared/ImageUrlErrors.cs new file mode 100644 index 0000000..df5ecce --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Shared/ImageUrlErrors.cs @@ -0,0 +1,15 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Catalog.Domain.Shared; + +internal static class ImageUrlErrors +{ + public static readonly Error Empty = + Error.Problem("ImageUrl.Empty", "Image URL must not be empty"); + + public static readonly Error TooLong = + Error.Problem("ImageUrl.TooLong", $"Image URL must not exceed {ImageUrl.MaxLength} characters"); + + public static readonly Error InvalidFormat = + Error.Problem("ImageUrl.InvalidFormat", "Image URL must be a valid URI"); +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateDescription.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateDescription.cs new file mode 100644 index 0000000..0956f2e --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateDescription.cs @@ -0,0 +1,33 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public sealed record TemplateDescription +{ + public const int MaxLength = 1000; + + private TemplateDescription(string value) => Value = value; + + public string Value { get; } + + public static Result Create(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return Result.Failure(TemplateDescriptionErrors.Empty); + } + + string trimmed = raw.Trim(); + + if (trimmed.Length > MaxLength) + { + return Result.Failure(TemplateDescriptionErrors.TooLong); + } + + return Result.Success(new TemplateDescription(trimmed)); + } + + public override string ToString() => Value; + + internal static TemplateDescription FromPersistence(string value) => new(value); +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateDescriptionErrors.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateDescriptionErrors.cs new file mode 100644 index 0000000..f10497f --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateDescriptionErrors.cs @@ -0,0 +1,12 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Catalog.Domain.Templates; + +internal static class TemplateDescriptionErrors +{ + public static readonly Error Empty = + Error.Problem("TemplateDescription.Empty", "Template description must not be empty when provided"); + + public static readonly Error TooLong = + Error.Problem("TemplateDescription.TooLong", $"Template description must not exceed {TemplateDescription.MaxLength} characters"); +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateName.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateName.cs new file mode 100644 index 0000000..7f7a9c6 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateName.cs @@ -0,0 +1,33 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public sealed record TemplateName +{ + public const int MaxLength = 150; + + private TemplateName(string value) => Value = value; + + public string Value { get; } + + public static Result Create(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return Result.Failure(TemplateNameErrors.Empty); + } + + string trimmed = raw.Trim(); + + if (trimmed.Length > MaxLength) + { + return Result.Failure(TemplateNameErrors.TooLong); + } + + return Result.Success(new TemplateName(trimmed)); + } + + public override string ToString() => Value; + + internal static TemplateName FromPersistence(string value) => new(value); +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateNameErrors.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateNameErrors.cs new file mode 100644 index 0000000..cd2ef8b --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateNameErrors.cs @@ -0,0 +1,12 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Catalog.Domain.Templates; + +internal static class TemplateNameErrors +{ + public static readonly Error Empty = + Error.Problem("TemplateName.Empty", "Template name must not be empty"); + + public static readonly Error TooLong = + Error.Problem("TemplateName.TooLong", $"Template name must not exceed {TemplateName.MaxLength} characters"); +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateThemeName.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateThemeName.cs new file mode 100644 index 0000000..6a14a29 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateThemeName.cs @@ -0,0 +1,33 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public sealed record TemplateThemeName +{ + public const int MaxLength = 100; + + private TemplateThemeName(string value) => Value = value; + + public string Value { get; } + + public static Result Create(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return Result.Failure(TemplateThemeNameErrors.Empty); + } + + string trimmed = raw.Trim(); + + if (trimmed.Length > MaxLength) + { + return Result.Failure(TemplateThemeNameErrors.TooLong); + } + + return Result.Success(new TemplateThemeName(trimmed)); + } + + public override string ToString() => Value; + + internal static TemplateThemeName FromPersistence(string value) => new(value); +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateThemeNameErrors.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateThemeNameErrors.cs new file mode 100644 index 0000000..2fcf177 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateThemeNameErrors.cs @@ -0,0 +1,12 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Catalog.Domain.Templates; + +internal static class TemplateThemeNameErrors +{ + public static readonly Error Empty = + Error.Problem("TemplateThemeName.Empty", "Theme name must not be empty"); + + public static readonly Error TooLong = + Error.Problem("TemplateThemeName.TooLong", $"Theme name must not exceed {TemplateThemeName.MaxLength} characters"); +} From e159b63b6aa774e209e1e2a504be2d0fde7f8e84 Mon Sep 17 00:00:00 2001 From: Carl Marion Date: Wed, 27 May 2026 13:05:18 +0800 Subject: [PATCH 007/155] feat(catalog): add jsonb theme styling records --- .../Templates/ThemeStyling.cs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/ThemeStyling.cs diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/ThemeStyling.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/ThemeStyling.cs new file mode 100644 index 0000000..5301c1f --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/ThemeStyling.cs @@ -0,0 +1,9 @@ +namespace Eternelle.Modules.Catalog.Domain.Templates; + +// Serialized to jsonb columns. Dictionaries keep the schema open for design tokens +// (e.g. { "primary": "#1a1a1a", "accent": "#c8a96a" }) without a migration per token. +public sealed record ThemeColors(IReadOnlyDictionary Tokens); + +public sealed record ThemeTypography(IReadOnlyDictionary Tokens); + +public sealed record ThemeTextures(IReadOnlyDictionary Tokens); From 5567a653858678d20c66b2496297dbf4816ace35 Mon Sep 17 00:00:00 2001 From: Carl Marion Date: Wed, 27 May 2026 13:06:15 +0800 Subject: [PATCH 008/155] feat(catalog): add Template child entities with variant upload limit --- .../Templates/TemplateSectionVariant.cs | 36 ++++++++++++++++++ .../Templates/TemplateSupportedSection.cs | 25 +++++++++++++ .../Templates/TemplateTheme.cs | 37 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSectionVariant.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSupportedSection.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateTheme.cs diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSectionVariant.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSectionVariant.cs new file mode 100644 index 0000000..0f719e7 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSectionVariant.cs @@ -0,0 +1,36 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public sealed class TemplateSectionVariant : Entity +{ + private TemplateSectionVariant() { } + + public TemplateSectionVariantId Id { get; private set; } + public TemplateId TemplateId { get; private set; } + public SectionId SectionId { get; private set; } + public string Variant { get; private set; } = null!; + public bool IsDefault { get; private set; } + public PriceClass PriceClass { get; private set; } + + // Upload cap for upload-capable sections (e.g. GuestUploads). null = unlimited / N/A. + public int? UploadLimit { get; private set; } + + internal static TemplateSectionVariant Create( + TemplateId templateId, + SectionId sectionId, + string variant, + bool isDefault, + PriceClass priceClass, + int? uploadLimit) => + new() + { + Id = TemplateSectionVariantId.New(), + TemplateId = templateId, + SectionId = sectionId, + Variant = variant, + IsDefault = isDefault, + PriceClass = priceClass, + UploadLimit = uploadLimit + }; +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSupportedSection.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSupportedSection.cs new file mode 100644 index 0000000..d63c398 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateSupportedSection.cs @@ -0,0 +1,25 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public sealed class TemplateSupportedSection : Entity +{ + private TemplateSupportedSection() { } + + public TemplateSupportedSectionId Id { get; private set; } + public TemplateId TemplateId { get; private set; } + public SectionId SectionId { get; private set; } + public int DisplayOrder { get; private set; } + + internal static TemplateSupportedSection Create( + TemplateId templateId, + SectionId sectionId, + int displayOrder) => + new() + { + Id = TemplateSupportedSectionId.New(), + TemplateId = templateId, + SectionId = sectionId, + DisplayOrder = displayOrder + }; +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateTheme.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateTheme.cs new file mode 100644 index 0000000..15c66df --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateTheme.cs @@ -0,0 +1,37 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public sealed class TemplateTheme : Entity +{ + private TemplateTheme() { } + + public TemplateThemeId Id { get; private set; } + public TemplateId TemplateId { get; private set; } + public int ThemeIndex { get; private set; } + public TemplateThemeName Name { get; private set; } = null!; + public ThemeColors Colors { get; private set; } = null!; + public ThemeTypography Typography { get; private set; } = null!; + public ThemeTextures? Textures { get; private set; } + public bool IsDark { get; private set; } + + internal static TemplateTheme Create( + TemplateId templateId, + int themeIndex, + TemplateThemeName name, + ThemeColors colors, + ThemeTypography typography, + ThemeTextures? textures, + bool isDark) => + new() + { + Id = TemplateThemeId.New(), + TemplateId = templateId, + ThemeIndex = themeIndex, + Name = name, + Colors = colors, + Typography = typography, + Textures = textures, + IsDark = isDark + }; +} From d503809e03a13ad7edcdfc48a37f9a2ba9642599 Mon Sep 17 00:00:00 2001 From: Carl Marion Date: Wed, 27 May 2026 13:09:31 +0800 Subject: [PATCH 009/155] feat(catalog): add Template domain events and manifest payload --- .../Templates/TemplateCreatedDomainEvent.cs | 13 +++++++++++++ .../TemplateManifestChangedDomainEvent.cs | 17 +++++++++++++++++ .../Templates/TemplateManifestItem.cs | 8 ++++++++ 3 files changed, 38 insertions(+) create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateCreatedDomainEvent.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateManifestChangedDomainEvent.cs create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateManifestItem.cs diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateCreatedDomainEvent.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateCreatedDomainEvent.cs new file mode 100644 index 0000000..f42b3e9 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateCreatedDomainEvent.cs @@ -0,0 +1,13 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public sealed class TemplateCreatedDomainEvent( + TemplateId templateId, + string name, + string tier) : DomainEvent +{ + public TemplateId TemplateId { get; init; } = templateId; + public string Name { get; init; } = name; + public string Tier { get; init; } = tier; +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateManifestChangedDomainEvent.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateManifestChangedDomainEvent.cs new file mode 100644 index 0000000..9e5d4fc --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateManifestChangedDomainEvent.cs @@ -0,0 +1,17 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public sealed class TemplateManifestChangedDomainEvent( + TemplateId templateId, + string name, + string tier, + IReadOnlyList sectionIds, + IReadOnlyList variants) : DomainEvent +{ + public TemplateId TemplateId { get; init; } = templateId; + public string Name { get; init; } = name; + public string Tier { get; init; } = tier; + public IReadOnlyList SectionIds { get; init; } = sectionIds; + public IReadOnlyList Variants { get; init; } = variants; +} diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateManifestItem.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateManifestItem.cs new file mode 100644 index 0000000..15f0c9b --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateManifestItem.cs @@ -0,0 +1,8 @@ +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public sealed record TemplateManifestItem( + string SectionId, + string Variant, + string PriceClass, + bool IsDefault, + int? UploadLimit); From f1ca0cead11432ba4f623d781c57091b3956cb10 Mon Sep 17 00:00:00 2001 From: Carl Marion Date: Wed, 27 May 2026 13:10:23 +0800 Subject: [PATCH 010/155] feat(catalog): add TemplateErrors --- .../Templates/TemplateErrors.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateErrors.cs diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateErrors.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateErrors.cs new file mode 100644 index 0000000..f53d8ec --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/TemplateErrors.cs @@ -0,0 +1,27 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public static class TemplateErrors +{ + public static Error NotFound(TemplateId id) => + Error.NotFound("Catalog.Template.NotFound", $"Template '{id.Value}' was not found."); + + public static readonly Error NameTaken = + Error.Conflict("Catalog.Template.NameTaken", "A template with this name already exists."); + + public static readonly Error DuplicateThemeIndex = + Error.Conflict("Catalog.Template.DuplicateThemeIndex", "A theme with this index already exists for the template."); + + public static readonly Error SectionAlreadySupported = + Error.Conflict("Catalog.Template.SectionAlreadySupported", "This section is already supported by the template."); + + public static readonly Error SectionNotSupported = + Error.Problem("Catalog.Template.SectionNotSupported", "A variant cannot be added for a section the template does not support."); + + public static readonly Error DuplicateDefaultVariant = + Error.Conflict("Catalog.Template.DuplicateDefaultVariant", "Only one default variant is allowed per section."); + + public static readonly Error DuplicateVariant = + Error.Conflict("Catalog.Template.DuplicateVariant", "This variant already exists for the section."); +} From 8b245d68c1d3b257b9861d8fa959786f9dbf52fd Mon Sep 17 00:00:00 2001 From: Carl Marion Date: Wed, 27 May 2026 13:11:55 +0800 Subject: [PATCH 011/155] feat(catalog): add Template aggregate root --- .../Templates/Template.cs | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/Template.cs diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/Template.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/Template.cs new file mode 100644 index 0000000..8115272 --- /dev/null +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Domain/Templates/Template.cs @@ -0,0 +1,152 @@ +using Eternelle.Common.Domain; +using Eternelle.Modules.Catalog.Domain.Shared; + +namespace Eternelle.Modules.Catalog.Domain.Templates; + +public sealed class Template : Entity +{ + private readonly List _themes = []; + private readonly List _supportedSections = []; + private readonly List _sectionVariants = []; + + private Template() { } + + public TemplateId Id { get; private set; } + public TemplateName Name { get; private set; } = null!; + public TemplateDescription? Description { get; private set; } + public TemplateTier Tier { get; private set; } + public ImageUrl? PreviewImageUrl { get; private set; } + public IReadOnlyList Features { get; private set; } = []; + public DateTime CreatedAtUtc { get; private set; } + public DateTime UpdatedAtUtc { get; private set; } + + public IReadOnlyCollection Themes => _themes.AsReadOnly(); + public IReadOnlyCollection SupportedSections => _supportedSections.AsReadOnly(); + public IReadOnlyCollection SectionVariants => _sectionVariants.AsReadOnly(); + + public static Result