From 21b29a9b684b69cc81ce4da48a369f96e55eea90 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 27 Mar 2026 08:27:41 +0000 Subject: [PATCH 01/14] Add module design review identifying 14 issues Key findings: 3 modules violate contracts pattern by referencing Users implementation directly, unified HostDbContext creates tight database coupling, event bus lacks background dispatch, and Admin module has mixed responsibilities. --- tasks/module-design-review.md | 178 ++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 tasks/module-design-review.md diff --git a/tasks/module-design-review.md b/tasks/module-design-review.md new file mode 100644 index 00000000..1b75fcbe --- /dev/null +++ b/tasks/module-design-review.md @@ -0,0 +1,178 @@ +# Module Design Review + +## Critical Issues + +### 1. Three Modules Violate the Contracts Pattern (Direct Implementation References) + +The framework enforces that modules should only depend on other modules through their `.Contracts` projects. The source generator even has diagnostic `SM0011` to catch this. However, three modules directly reference `SimpleModule.Users` (the implementation) instead of `SimpleModule.Users.Contracts`: + +| Module | References | Should Reference | +|--------|-----------|-----------------| +| **Admin** | `SimpleModule.Users.csproj` | `SimpleModule.Users.Contracts.csproj` | +| **OpenIddict** | `SimpleModule.Users.csproj` | `SimpleModule.Users.Contracts.csproj` | +| **Permissions** | `SimpleModule.Users.csproj` | `SimpleModule.Users.Contracts.csproj` | + +**Why this happens:** All three modules need `ApplicationUser` and/or `ApplicationRole` (Identity entities defined in the Users implementation project) to use `UserManager`, `RoleManager`, and `SignInManager`. + +**Why it matters:** +- Defeats module isolation. Changing Users internals can break Admin, OpenIddict, and Permissions at compile time. +- Makes it impossible to swap the Users module implementation without touching 3 other modules. +- The source generator's SM0011 diagnostic should flag this, but these projects may be suppressing it or the generator isn't catching ProjectReference-based violations. + +**Root cause:** `ApplicationUser` and `ApplicationRole` are implementation details of the Users module but are treated as shared types. They should be in the contracts layer or in a shared identity abstractions project. + +**Fix options:** +- Move `ApplicationUser`, `ApplicationRole`, and Identity type registrations to `SimpleModule.Users.Contracts` (or a new `SimpleModule.Identity.Contracts` project) +- Create an `IIdentityContracts` interface that wraps the UserManager/RoleManager operations these modules need, eliminating the need for direct Identity type access + +--- + +### 2. `IEndpoint` and `IViewEndpoint` Are Identical Interfaces With No Semantic Enforcement + +Both interfaces have the exact same signature: + +```csharp +public interface IEndpoint { void Map(IEndpointRouteBuilder app); } +public interface IViewEndpoint { void Map(IEndpointRouteBuilder app); } +``` + +The distinction exists only for the source generator to route-group them differently. But nothing prevents: +- An `IViewEndpoint` that returns JSON instead of calling `Inertia.Render()` +- An `IEndpoint` that calls `Inertia.Render()` accidentally + +Since the `Pages/index.ts` registry must match `IViewEndpoint` routes (and missing entries silently 404 on the client), this is error-prone. Consider: +- Adding a compile-time analyzer that verifies `IViewEndpoint` implementations call `Inertia.Render()` +- Or adding a `string Component { get; }` property to `IViewEndpoint` so the generator can validate page registry entries + +--- + +### 3. Event Bus Has No Async/Background Dispatch Option + +The `EventBus.PublishAsync` executes all handlers **sequentially in the request pipeline**. Every handler adds latency to the HTTP response. The CLAUDE.md documents this and recommends "use background jobs for expensive operations," but: + +- There's no built-in mechanism for fire-and-forget events +- The AuditLogs module works around this by decorating `IEventBus` with `AuditingEventBus` that writes to a Channel, then a background `AuditWriterService` drains it +- Other modules that need async dispatch must reinvent this pattern + +Consider adding `IEventBus.PublishInBackgroundAsync()` or a `[BackgroundHandler]` attribute that the framework queues automatically. + +--- + +## Significant Design Concerns + +### 4. Admin Module is a "God Module" With Mixed Responsibilities + +The Admin module: +- Manages users (CRUD via `UserManager`) +- Manages roles (CRUD via `RoleManager`) +- Has its own `AdminDbContext` for activity tracking +- References the Users implementation directly +- References Permissions contracts + +This module duplicates user/role management that arguably belongs in the Users and Permissions modules respectively. The Admin module should be a **UI aggregator** (composing views from other modules' contracts) rather than reimplementing user management with direct Identity access. + +--- + +### 5. Unified HostDbContext Creates Tight Coupling at the Database Level + +All module entities are merged into a single `HostDbContext` by the source generator. While this simplifies migrations, it means: + +- **All modules share one database connection and one migration history.** A schema change in Products blocks Orders deployments. +- **Only one module can own IdentityDbContext** (SM0003 enforces this). This is a hard architectural constraint that limits identity extensibility. +- **Schema isolation is provider-dependent.** SQLite uses table prefixes, PostgreSQL uses schemas. The abstraction leaks when writing raw SQL or cross-module queries. +- **No path to independent module databases.** The modular monolith can't evolve toward microservices without replacing the entire data layer. + +For a framework that emphasizes module independence, the database is the strongest coupling point. + +--- + +### 6. Contract Interface Design Inconsistencies + +**Varying abstraction levels across contracts:** + +| Contract | Methods | Abstraction Level | +|----------|---------|-------------------| +| `IUserContracts` | 6 | DTO-based, clean | +| `IProductContracts` | 5 | DTO-based, clean | +| `IOrderContracts` | 5 | DTO-based, clean | +| `IPageBuilderContracts` | 5 | DTO-based, clean | +| `IPageBuilderTemplateContracts` | 4 | Separate interface, good | +| `IPageBuilderTagContracts` | 3 | Separate interface, good | +| `IPermissionContracts` | 8+ | Mixes role management with permission queries | +| `IAuditLogContracts` | 5+ | Includes export/stats alongside CRUD | + +The PageBuilder module does well by splitting into three focused interfaces. Other modules should follow this pattern rather than growing a single interface. + +--- + +### 7. No Module Lifecycle or Health Reporting + +`IModule` has configuration hooks but no runtime lifecycle: +- No `OnStartAsync()` / `OnStopAsync()` for graceful startup/shutdown +- No `HealthCheckAsync()` for module-level health (the framework has app-level health checks via ASP.NET, but modules can't report their own status) +- No way for a module to declare "I'm degraded but still serving requests" + +Modules like OpenIddict (certificate loading), AuditLogs (background writer), and FileStorage (external storage provider) would benefit from lifecycle hooks. + +--- + +### 8. Permission System Has No Hierarchical Support + +Permissions are flat strings (`"Products.View"`, `"Products.Create"`). There's no: +- Wildcard support (`"Products.*"` grants all Products permissions) +- Hierarchical inheritance (`"Admin"` implies all sub-permissions) +- Permission grouping beyond the module name prefix + +The `PermissionAuthorizationHandler` only checks exact string matches against claims (with an Admin role bypass). As the number of modules grows, managing individual permissions per role becomes unwieldy. + +--- + +### 9. Inertia Version Strategy is Fragile + +```csharp +// InertiaMiddleware.cs +private static readonly string CacheBuster = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); +``` + +The Inertia version is set once at app startup. In a multi-instance deployment: +- Each instance gets a different version (different startup times) +- Requests load-balanced between instances will trigger constant 409 Conflict responses +- The `DEPLOYMENT_VERSION` env var override exists but isn't enforced or documented prominently + +--- + +## Minor Issues + +### 10. Dashboard Module Has No Contracts Project + +Dashboard is the only module without a contracts project. If other modules ever need to contribute dashboard widgets or data, there's no extension point. This is fine for now but breaks the pattern. + +### 11. Orders.Contracts References Other Contracts + +`Orders.Contracts` depends on both `Users.Contracts` and `Products.Contracts`. This means consuming the Orders contract transitively pulls in two other contracts. For a contracts layer meant to be lightweight, this creates a dependency chain. Consider using primitive types (IDs as `Guid` or value objects) in contract DTOs rather than referencing other module contracts. + +### 12. No Standardized Error/Result Types in Contracts + +Modules use exceptions (`NotFoundException`, `ValidationException`) for error signaling. The `IOrderContracts.CreateOrderAsync()` throws if the user doesn't exist instead of returning a result type. This forces all consumers to catch exceptions for expected business logic failures, mixing control flow with error handling. + +### 13. `IModule` Uses Default Interface Methods (C# 8+) + +All methods on `IModule` have empty default implementations. While convenient, this means: +- A module can implement `IModule` and do nothing (the generator still discovers it) +- No compile-time signal when a new hook is added to `IModule` - existing modules silently ignore it +- Static analysis tools can't determine which hooks a module uses without reading the implementation + +### 14. Source Generator Threshold for Contract Size is Hardcoded + +SM0012 warns at 15 methods, SM0013 errors at 20 methods. The diagnostic message claims these are "configurable in .editorconfig" but the thresholds appear hardcoded in the generator. This should either be actually configurable or the message should be corrected. + +--- + +## Summary + +The framework has a strong foundation: compile-time discovery, contracts pattern enforcement, and clean separation of concerns. The most impactful issues to address are: + +1. **Fix the Users implementation leak** (issues #1 and #4) - this is the single biggest modularity violation +2. **Add IViewEndpoint validation** (issue #2) - prevents silent client-side failures +3. **Consider database decoupling strategy** (issue #5) - the current unified DbContext is the tightest coupling +4. **Add background event dispatch** (issue #3) - prevents modules from reinventing the AuditLogs pattern From 85bf7b44d4230a3392a07fd77ba2a0e7ff5ddb2d Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 27 Mar 2026 09:13:52 +0000 Subject: [PATCH 02/14] Fix three critical module design issues 1. Extract ApplicationUser/ApplicationRole to Users.Contracts - Moved Identity entities from SimpleModule.Users.Entities to SimpleModule.Users.Contracts namespace - Admin, OpenIddict, Permissions now reference Users.Contracts instead of Users implementation (fixes SM0011 violations) - Added GetRoleIdsByNamesAsync to IUserContracts to eliminate OpenIddict's direct UsersDbContext dependency - Updated all migration snapshots for new entity namespace 2. Add duplicate view page name detection (SM0015) - Source generator now detects when two IViewEndpoint classes produce the same page name, preventing silent client-side 404s 3. Add background event dispatch to IEventBus - New PublishInBackground method for fire-and-forget events - BackgroundEventChannel + BackgroundEventDispatcher hosted service - Eliminates need for modules to reinvent the pattern (as AuditLogs did) - AuditingEventBus updated to forward background dispatch --- .../Events/BackgroundEventChannel.cs | 44 ++++++++++++++++++ .../Events/BackgroundEventDispatcher.cs | 40 ++++++++++++++++ .../SimpleModule.Core/Events/EventBus.cs | 21 ++++++++- .../SimpleModule.Core/Events/IEventBus.cs | 13 ++++++ .../Emitters/DiagnosticEmitter.cs | 46 +++++++++++++++++++ .../SimpleModuleHostExtensions.cs | 2 + .../Endpoints/Admin/AdminRolesEndpoint.cs | 3 +- .../Endpoints/Admin/AdminUsersEndpoint.cs | 2 - .../SimpleModule.Admin.csproj | 2 +- .../Views/Admin/RolesEditEndpoint.cs | 3 +- .../Views/Admin/RolesEndpoint.cs | 3 +- .../Views/Admin/UsersActivityEndpoint.cs | 2 +- .../Views/Admin/UsersCreateEndpoint.cs | 2 +- .../Views/Admin/UsersEditEndpoint.cs | 2 - .../Views/Admin/UsersEndpoint.cs | 2 +- .../Integration/AdminPermissionsTests.cs | 1 - .../Integration/AdminRolesEndpointTests.cs | 2 +- .../Integration/AdminUsersEndpointTests.cs | 2 +- .../Pipeline/AuditingEventBus.cs | 6 +++ .../Connect/AuthorizationEndpoint.cs | 14 ++---- .../Endpoints/Connect/LogoutEndpoint.cs | 2 +- .../Endpoints/Connect/UserinfoEndpoint.cs | 2 +- .../SimpleModule.OpenIddict.csproj | 2 +- .../Services/PermissionSeedService.cs | 2 +- .../SimpleModule.Permissions.csproj | 2 +- .../ApplicationRole.cs | 4 +- .../ApplicationUser.cs | 4 +- .../IUserContracts.cs | 1 + .../SimpleModule.Users.Contracts.csproj | 1 + .../Components/_Imports.razor | 2 +- .../Account/AccountSecurityEndpoint.cs | 2 +- .../Users/DownloadPersonalDataEndpoint.cs | 2 +- .../Services/ConsoleEmailSender.cs | 2 +- .../Services/UserSeedService.cs | 2 +- .../src/SimpleModule.Users/UserService.cs | 13 +++++- .../src/SimpleModule.Users/UsersDbContext.cs | 2 +- .../src/SimpleModule.Users/UsersModule.cs | 2 +- .../Views/Account/Disable2faEndpoint.cs | 2 +- .../Account/EnableAuthenticatorEndpoint.cs | 2 +- .../Account/GenerateRecoveryCodesEndpoint.cs | 2 +- .../Account/ResetAuthenticatorEndpoint.cs | 2 +- .../TwoFactorAuthenticationEndpoint.cs | 2 +- .../Unit/UserServiceTests.cs | 11 ++++- .../20260319120412_InitialCreate.Designer.cs | 16 +++---- ...319162704_AddPageBuilderModule.Designer.cs | 16 +++---- ...0319183039_AddPageDraftContent.Designer.cs | 16 +++---- ...0319190530_AddPageEnhancements.Designer.cs | 16 +++---- ...260319223657_AddSettingsModule.Designer.cs | 16 +++---- ...60320140954_AddPublicMenuItems.Designer.cs | 16 +++---- ...60321151649_AddAuditLogsModule.Designer.cs | 16 +++---- ...45_AddAuditLogDashboardIndexes.Designer.cs | 16 +++---- ...326163320_AddFileStorageModule.Designer.cs | 16 +++---- .../Migrations/HostDbContextModelSnapshot.cs | 16 +++---- .../Fakes/FakeUserContracts.cs | 9 ++++ 54 files changed, 316 insertions(+), 133 deletions(-) create mode 100644 framework/SimpleModule.Core/Events/BackgroundEventChannel.cs create mode 100644 framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs rename modules/Users/src/{SimpleModule.Users/Entities => SimpleModule.Users.Contracts}/ApplicationRole.cs (66%) rename modules/Users/src/{SimpleModule.Users/Entities => SimpleModule.Users.Contracts}/ApplicationUser.cs (77%) diff --git a/framework/SimpleModule.Core/Events/BackgroundEventChannel.cs b/framework/SimpleModule.Core/Events/BackgroundEventChannel.cs new file mode 100644 index 00000000..2257a4c2 --- /dev/null +++ b/framework/SimpleModule.Core/Events/BackgroundEventChannel.cs @@ -0,0 +1,44 @@ +using System.Threading.Channels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace SimpleModule.Core.Events; + +/// +/// Unbounded channel for queuing events to be dispatched by a background service. +/// Register as a singleton. The reads from this channel. +/// +public sealed partial class BackgroundEventChannel(ILogger logger) +{ + private readonly Channel> _channel = + Channel.CreateUnbounded>( + new UnboundedChannelOptions { SingleReader = true } + ); + + internal ChannelReader> Reader => + _channel.Reader; + + internal void Enqueue(T @event) + where T : IEvent + { + // Capture the event in a closure that will be executed by the background dispatcher. + // The dispatcher provides a scoped IServiceProvider with an EventBus for proper + // exception isolation semantics. + Func dispatch = (sp, ct) => + { + var bus = sp.GetRequiredService(); + return bus.PublishAsync(@event, ct); + }; + + if (!_channel.Writer.TryWrite(dispatch)) + { + LogEventDropped(logger, typeof(T).Name); + } + } + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Background event '{EventName}' dropped — channel closed" + )] + private static partial void LogEventDropped(ILogger logger, string eventName); +} diff --git a/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs b/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs new file mode 100644 index 00000000..8b350a89 --- /dev/null +++ b/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace SimpleModule.Core.Events; + +/// +/// Background service that drains the and dispatches +/// events to their handlers in a scoped DI context. +/// +public sealed partial class BackgroundEventDispatcher( + BackgroundEventChannel channel, + IServiceProvider serviceProvider, + ILogger logger +) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var dispatch in channel.Reader.ReadAllAsync(stoppingToken)) + { + try + { + using var scope = serviceProvider.CreateScope(); + await dispatch(scope.ServiceProvider, stoppingToken); + } +#pragma warning disable CA1031 // Background dispatcher must not crash on handler failures + catch (Exception ex) +#pragma warning restore CA1031 + { + LogDispatchFailed(logger, ex); + } + } + } + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Background event dispatch failed" + )] + private static partial void LogDispatchFailed(ILogger logger, Exception exception); +} diff --git a/framework/SimpleModule.Core/Events/EventBus.cs b/framework/SimpleModule.Core/Events/EventBus.cs index 000944de..edb1cd14 100644 --- a/framework/SimpleModule.Core/Events/EventBus.cs +++ b/framework/SimpleModule.Core/Events/EventBus.cs @@ -72,8 +72,11 @@ namespace SimpleModule.Core.Events; /// to know which handlers failed, inspect the collection. /// /// -public sealed partial class EventBus(IServiceProvider serviceProvider, ILogger logger) - : IEventBus +public sealed partial class EventBus( + IServiceProvider serviceProvider, + ILogger logger, + BackgroundEventChannel? backgroundChannel = null +) : IEventBus { /// /// Publishes an event to all registered handlers, ensuring all handlers execute even if some fail. @@ -138,6 +141,20 @@ public async Task PublishAsync(T @event, CancellationToken cancellationToken } } + /// + public void PublishInBackground(T @event) + where T : IEvent + { + if (backgroundChannel is null) + { + throw new InvalidOperationException( + "Background event dispatch is not configured. Register BackgroundEventChannel and BackgroundEventDispatcher in DI." + ); + } + + backgroundChannel.Enqueue(@event); + } + [LoggerMessage( Level = LogLevel.Error, Message = "Event handler {HandlerName} failed for event {EventName}" diff --git a/framework/SimpleModule.Core/Events/IEventBus.cs b/framework/SimpleModule.Core/Events/IEventBus.cs index 711ddfe8..805eb336 100644 --- a/framework/SimpleModule.Core/Events/IEventBus.cs +++ b/framework/SimpleModule.Core/Events/IEventBus.cs @@ -30,4 +30,17 @@ public interface IEventBus /// Task PublishAsync(T @event, CancellationToken cancellationToken = default) where T : IEvent; + + /// + /// Enqueues an event for background dispatch, returning immediately without waiting for handlers. + /// + /// The event type, must implement . + /// The event to publish in the background. + /// + /// The event is dispatched asynchronously by a background service. Handler exceptions are + /// logged but do not propagate to the caller. Use this for fire-and-forget events where + /// the caller does not need to know if handlers succeeded (e.g., audit logging, notifications). + /// + void PublishInBackground(T @event) + where T : IEvent; } diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index 4a10e46c..89c5daa0 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -204,6 +204,24 @@ internal sealed class DiagnosticEmitter : IEmitter isEnabledByDefault: true ); + internal static readonly DiagnosticDescriptor DuplicateViewPageName = new( + id: "SM0015", + title: "Duplicate view page name across modules", + messageFormat: "View page name '{0}' is registered by multiple endpoints: '{1}' (module {2}) and '{3}' (module {4}). Each IViewEndpoint must map to a unique page name. Rename one of the endpoint classes or move it to a different module.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ViewEndpointMissingPageRegistration = new( + id: "SM0016", + title: "View endpoint page not in Pages/index.ts", + messageFormat: "IViewEndpoint '{0}' in module '{1}' maps to page '{2}'. Ensure a matching entry exists in the module's Pages/index.ts: \"{2}\": () => import(\"...\"). Run 'npm run validate-pages' to verify.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true + ); + internal static readonly DiagnosticDescriptor InterceptorDependsOnDbContext = new( id: "SM0039", title: "SaveChanges interceptor has transitive DbContext dependency", @@ -658,6 +676,34 @@ public void Emit(SourceProductionContext context, DiscoveryData data) } } + // SM0015: Duplicate view page name across modules + var seenPages = + new Dictionary(); + foreach (var module in data.Modules) + { + foreach (var view in module.Views) + { + if (seenPages.TryGetValue(view.Page, out var existing)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DuplicateViewPageName, + Location.None, + view.Page, + Strip(existing.EndpointFqn), + existing.ModuleName, + Strip(view.FullyQualifiedName), + module.ModuleName + ) + ); + } + else + { + seenPages[view.Page] = (view.FullyQualifiedName, module.ModuleName); + } + } + } + // SM0039: Interceptor depends on contract whose implementation takes a DbContext foreach (var interceptor in data.Interceptors) { diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 3bd506b5..debca8f5 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -63,6 +63,8 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( builder.Services.AddSimpleModuleBlazor(); } + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs index 53ae403b..7815eedf 100644 --- a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs @@ -7,8 +7,7 @@ using SimpleModule.Admin.Services; using SimpleModule.Core; using SimpleModule.Permissions.Contracts; -using SimpleModule.Users; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Admin.Endpoints.Admin; diff --git a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs index fb08b2f4..8ba7b5d8 100644 --- a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs @@ -7,9 +7,7 @@ using SimpleModule.Admin.Services; using SimpleModule.Core; using SimpleModule.Permissions.Contracts; -using SimpleModule.Users; using SimpleModule.Users.Contracts; -using SimpleModule.Users.Entities; namespace SimpleModule.Admin.Endpoints.Admin; diff --git a/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj b/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj index a7fb309c..bcdfbdc3 100644 --- a/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj +++ b/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj @@ -7,7 +7,7 @@ - + diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEditEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEditEndpoint.cs index f6b851c5..57895540 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEditEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEditEndpoint.cs @@ -7,8 +7,7 @@ using SimpleModule.Core.Authorization; using SimpleModule.Core.Inertia; using SimpleModule.Permissions.Contracts; -using SimpleModule.Users; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Admin.Views.Admin; diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEndpoint.cs index d8b30255..c9894246 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEndpoint.cs @@ -5,8 +5,7 @@ using SimpleModule.Core; using SimpleModule.Core.Inertia; using SimpleModule.Permissions.Contracts; -using SimpleModule.Users; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Admin.Views.Admin; diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersActivityEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersActivityEndpoint.cs index e77851d3..42b6798d 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersActivityEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersActivityEndpoint.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using SimpleModule.Core; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Admin.Views.Admin; diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersCreateEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersCreateEndpoint.cs index 119487a8..859985a0 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersCreateEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersCreateEndpoint.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore; using SimpleModule.Core; using SimpleModule.Core.Inertia; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Admin.Views.Admin; diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs index 0f90ef9a..ab4f63a6 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs @@ -7,9 +7,7 @@ using SimpleModule.Core.Authorization; using SimpleModule.Core.Inertia; using SimpleModule.Permissions.Contracts; -using SimpleModule.Users; using SimpleModule.Users.Contracts; -using SimpleModule.Users.Entities; namespace SimpleModule.Admin.Views.Admin; diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs index 301c66eb..6f0442ba 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore; using SimpleModule.Core; using SimpleModule.Core.Inertia; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Admin.Views.Admin; diff --git a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminPermissionsTests.cs b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminPermissionsTests.cs index ecee536c..97ab622e 100644 --- a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminPermissionsTests.cs +++ b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminPermissionsTests.cs @@ -7,7 +7,6 @@ using SimpleModule.Permissions.Contracts; using SimpleModule.Tests.Shared.Fixtures; using SimpleModule.Users.Contracts; -using SimpleModule.Users.Entities; namespace Admin.Tests.Integration; diff --git a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminRolesEndpointTests.cs b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminRolesEndpointTests.cs index 49999370..261aba6b 100644 --- a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminRolesEndpointTests.cs +++ b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminRolesEndpointTests.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Tests.Shared.Fixtures; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace Admin.Tests.Integration; diff --git a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs index 29ae9837..088bd276 100644 --- a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs +++ b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Tests.Shared.Fixtures; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace Admin.Tests.Integration; diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingEventBus.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingEventBus.cs index 48389586..d58cc7c6 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingEventBus.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditingEventBus.cs @@ -58,6 +58,12 @@ settings is null } } + public void PublishInBackground(T @event) + where T : IEvent + { + inner.PublishInBackground(@event); + } + private AuditEntry ExtractAuditEntry(T @event) where T : IEvent { diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/AuthorizationEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/AuthorizationEndpoint.cs index ae3d98c4..f21b56e7 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/AuthorizationEndpoint.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/AuthorizationEndpoint.cs @@ -5,16 +5,13 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Routing; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; using SimpleModule.Core; using SimpleModule.OpenIddict.Contracts; using SimpleModule.Permissions.Contracts; -using SimpleModule.Users; using SimpleModule.Users.Contracts; -using SimpleModule.Users.Entities; using static OpenIddict.Abstractions.OpenIddictConstants; namespace SimpleModule.OpenIddict.Endpoints.Connect; @@ -87,18 +84,15 @@ await userManager.GetUserAsync(result.Principal) // Load permissions via IPermissionContracts var permissionContracts = context.RequestServices.GetRequiredService(); + var userContracts = context.RequestServices.GetRequiredService(); var userId = UserId.From(await userManager.GetUserIdAsync(user)); - // Get role IDs for permission lookup - var usersDb = context.RequestServices.GetRequiredService(); - var roleIds = await usersDb - .Roles.Where(r => roles.Contains(r.Name!)) - .Select(r => r.Id) - .ToListAsync(); + // Get role IDs for permission lookup via contracts + var roleIdMap = await userContracts.GetRoleIdsByNamesAsync(roles); var allPermissions = await permissionContracts.GetAllPermissionsForUserAsync( userId, - roleIds.Select(id => RoleId.From(id)) + roleIdMap.Values.Select(id => RoleId.From(id)) ); foreach (var permission in allPermissions) diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/LogoutEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/LogoutEndpoint.cs index 9a9325e6..5e4d136f 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/LogoutEndpoint.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/LogoutEndpoint.cs @@ -6,7 +6,7 @@ using OpenIddict.Server.AspNetCore; using SimpleModule.Core; using SimpleModule.OpenIddict.Contracts; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.OpenIddict.Endpoints.Connect; diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/UserinfoEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/UserinfoEndpoint.cs index ac734349..bc12fc7f 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/UserinfoEndpoint.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/UserinfoEndpoint.cs @@ -7,7 +7,7 @@ using OpenIddict.Server.AspNetCore; using SimpleModule.Core; using SimpleModule.OpenIddict.Contracts; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; using static OpenIddict.Abstractions.OpenIddictConstants; namespace SimpleModule.OpenIddict.Endpoints.Connect; diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/SimpleModule.OpenIddict.csproj b/modules/OpenIddict/src/SimpleModule.OpenIddict/SimpleModule.OpenIddict.csproj index 26e31ca6..906f279d 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/SimpleModule.OpenIddict.csproj +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/SimpleModule.OpenIddict.csproj @@ -12,7 +12,7 @@ - + diff --git a/modules/Permissions/src/SimpleModule.Permissions/Services/PermissionSeedService.cs b/modules/Permissions/src/SimpleModule.Permissions/Services/PermissionSeedService.cs index d3bc46da..b28b3a7b 100644 --- a/modules/Permissions/src/SimpleModule.Permissions/Services/PermissionSeedService.cs +++ b/modules/Permissions/src/SimpleModule.Permissions/Services/PermissionSeedService.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging; using SimpleModule.Core.Authorization; using SimpleModule.Permissions.Contracts; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; using RolePermission = SimpleModule.Permissions.Entities.RolePermission; namespace SimpleModule.Permissions.Services; diff --git a/modules/Permissions/src/SimpleModule.Permissions/SimpleModule.Permissions.csproj b/modules/Permissions/src/SimpleModule.Permissions/SimpleModule.Permissions.csproj index 2896c6c7..a74a01fa 100644 --- a/modules/Permissions/src/SimpleModule.Permissions/SimpleModule.Permissions.csproj +++ b/modules/Permissions/src/SimpleModule.Permissions/SimpleModule.Permissions.csproj @@ -7,6 +7,6 @@ - + diff --git a/modules/Users/src/SimpleModule.Users/Entities/ApplicationRole.cs b/modules/Users/src/SimpleModule.Users.Contracts/ApplicationRole.cs similarity index 66% rename from modules/Users/src/SimpleModule.Users/Entities/ApplicationRole.cs rename to modules/Users/src/SimpleModule.Users.Contracts/ApplicationRole.cs index 43bae2df..863cd257 100644 --- a/modules/Users/src/SimpleModule.Users/Entities/ApplicationRole.cs +++ b/modules/Users/src/SimpleModule.Users.Contracts/ApplicationRole.cs @@ -1,6 +1,6 @@ -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity; -namespace SimpleModule.Users.Entities; +namespace SimpleModule.Users.Contracts; public class ApplicationRole : IdentityRole { diff --git a/modules/Users/src/SimpleModule.Users/Entities/ApplicationUser.cs b/modules/Users/src/SimpleModule.Users.Contracts/ApplicationUser.cs similarity index 77% rename from modules/Users/src/SimpleModule.Users/Entities/ApplicationUser.cs rename to modules/Users/src/SimpleModule.Users.Contracts/ApplicationUser.cs index 51e570f2..89c3766c 100644 --- a/modules/Users/src/SimpleModule.Users/Entities/ApplicationUser.cs +++ b/modules/Users/src/SimpleModule.Users.Contracts/ApplicationUser.cs @@ -1,6 +1,6 @@ -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity; -namespace SimpleModule.Users.Entities; +namespace SimpleModule.Users.Contracts; public class ApplicationUser : IdentityUser { diff --git a/modules/Users/src/SimpleModule.Users.Contracts/IUserContracts.cs b/modules/Users/src/SimpleModule.Users.Contracts/IUserContracts.cs index e673d078..8684a02c 100644 --- a/modules/Users/src/SimpleModule.Users.Contracts/IUserContracts.cs +++ b/modules/Users/src/SimpleModule.Users.Contracts/IUserContracts.cs @@ -8,4 +8,5 @@ public interface IUserContracts Task CreateUserAsync(CreateUserRequest request); Task UpdateUserAsync(UserId id, UpdateUserRequest request); Task DeleteUserAsync(UserId id); + Task> GetRoleIdsByNamesAsync(IEnumerable roleNames); } diff --git a/modules/Users/src/SimpleModule.Users.Contracts/SimpleModule.Users.Contracts.csproj b/modules/Users/src/SimpleModule.Users.Contracts/SimpleModule.Users.Contracts.csproj index 97fdfa07..b7da6831 100644 --- a/modules/Users/src/SimpleModule.Users.Contracts/SimpleModule.Users.Contracts.csproj +++ b/modules/Users/src/SimpleModule.Users.Contracts/SimpleModule.Users.Contracts.csproj @@ -5,6 +5,7 @@ $(DefineConstants);VOGEN_NO_VALIDATION + diff --git a/modules/Users/src/SimpleModule.Users/Components/_Imports.razor b/modules/Users/src/SimpleModule.Users/Components/_Imports.razor index 411b6cb7..39e00729 100644 --- a/modules/Users/src/SimpleModule.Users/Components/_Imports.razor +++ b/modules/Users/src/SimpleModule.Users/Components/_Imports.razor @@ -6,6 +6,6 @@ @using Microsoft.AspNetCore.Http @using Microsoft.AspNetCore.Identity @using Microsoft.Extensions.Logging -@using SimpleModule.Users.Entities +@using SimpleModule.Users.Contracts @using SimpleModule.Blazor.Components @using SimpleModule.Blazor.Components.Layout diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs index 1286542b..2284fb5e 100644 --- a/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using SimpleModule.Core; using SimpleModule.Core.Inertia; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Users.Endpoints.Account; diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Users/DownloadPersonalDataEndpoint.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Users/DownloadPersonalDataEndpoint.cs index 03e2fe9c..75656c3b 100644 --- a/modules/Users/src/SimpleModule.Users/Endpoints/Users/DownloadPersonalDataEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Users/DownloadPersonalDataEndpoint.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using SimpleModule.Core; using SimpleModule.Users.Constants; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Users.Endpoints.Users; diff --git a/modules/Users/src/SimpleModule.Users/Services/ConsoleEmailSender.cs b/modules/Users/src/SimpleModule.Users/Services/ConsoleEmailSender.cs index c42b9066..cc938dde 100644 --- a/modules/Users/src/SimpleModule.Users/Services/ConsoleEmailSender.cs +++ b/modules/Users/src/SimpleModule.Users/Services/ConsoleEmailSender.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Users.Services; diff --git a/modules/Users/src/SimpleModule.Users/Services/UserSeedService.cs b/modules/Users/src/SimpleModule.Users/Services/UserSeedService.cs index 1b56f692..ba62d3f5 100644 --- a/modules/Users/src/SimpleModule.Users/Services/UserSeedService.cs +++ b/modules/Users/src/SimpleModule.Users/Services/UserSeedService.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using SimpleModule.Users.Constants; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Users.Services; diff --git a/modules/Users/src/SimpleModule.Users/UserService.cs b/modules/Users/src/SimpleModule.Users/UserService.cs index b2c81725..da6bcf59 100644 --- a/modules/Users/src/SimpleModule.Users/UserService.cs +++ b/modules/Users/src/SimpleModule.Users/UserService.cs @@ -2,12 +2,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SimpleModule.Users.Contracts; -using SimpleModule.Users.Entities; namespace SimpleModule.Users; public partial class UserService( UserManager userManager, + RoleManager roleManager, ILogger logger ) : IUserContracts { @@ -97,6 +97,17 @@ public async Task DeleteUserAsync(UserId id) LogUserDeleted(logger, id); } + public async Task> GetRoleIdsByNamesAsync( + IEnumerable roleNames + ) + { + var nameList = roleNames.ToList(); + var roles = await roleManager + .Roles.Where(r => nameList.Contains(r.Name!)) + .ToDictionaryAsync(r => r.Name!, r => r.Id); + return roles; + } + [LoggerMessage(Level = LogLevel.Warning, Message = "User with ID {UserId} not found")] private static partial void LogUserNotFound(ILogger logger, UserId userId); diff --git a/modules/Users/src/SimpleModule.Users/UsersDbContext.cs b/modules/Users/src/SimpleModule.Users/UsersDbContext.cs index dfd8cc67..43abe937 100644 --- a/modules/Users/src/SimpleModule.Users/UsersDbContext.cs +++ b/modules/Users/src/SimpleModule.Users/UsersDbContext.cs @@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SimpleModule.Database; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Users; diff --git a/modules/Users/src/SimpleModule.Users/UsersModule.cs b/modules/Users/src/SimpleModule.Users/UsersModule.cs index d5641a68..2ab386ed 100644 --- a/modules/Users/src/SimpleModule.Users/UsersModule.cs +++ b/modules/Users/src/SimpleModule.Users/UsersModule.cs @@ -4,7 +4,7 @@ using SimpleModule.Core; using SimpleModule.Core.Menu; using SimpleModule.Database; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; using SimpleModule.Users.Services; namespace SimpleModule.Users; diff --git a/modules/Users/src/SimpleModule.Users/Views/Account/Disable2faEndpoint.cs b/modules/Users/src/SimpleModule.Users/Views/Account/Disable2faEndpoint.cs index 30466416..3e5208d4 100644 --- a/modules/Users/src/SimpleModule.Users/Views/Account/Disable2faEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Views/Account/Disable2faEndpoint.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Inertia; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Users.Views.Account; diff --git a/modules/Users/src/SimpleModule.Users/Views/Account/EnableAuthenticatorEndpoint.cs b/modules/Users/src/SimpleModule.Users/Views/Account/EnableAuthenticatorEndpoint.cs index 750e7d5a..df43a90b 100644 --- a/modules/Users/src/SimpleModule.Users/Views/Account/EnableAuthenticatorEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Views/Account/EnableAuthenticatorEndpoint.cs @@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Inertia; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Users.Views.Account; diff --git a/modules/Users/src/SimpleModule.Users/Views/Account/GenerateRecoveryCodesEndpoint.cs b/modules/Users/src/SimpleModule.Users/Views/Account/GenerateRecoveryCodesEndpoint.cs index 87337d4d..b27fa558 100644 --- a/modules/Users/src/SimpleModule.Users/Views/Account/GenerateRecoveryCodesEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Views/Account/GenerateRecoveryCodesEndpoint.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Inertia; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Users.Views.Account; diff --git a/modules/Users/src/SimpleModule.Users/Views/Account/ResetAuthenticatorEndpoint.cs b/modules/Users/src/SimpleModule.Users/Views/Account/ResetAuthenticatorEndpoint.cs index 41751981..2cff375b 100644 --- a/modules/Users/src/SimpleModule.Users/Views/Account/ResetAuthenticatorEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Views/Account/ResetAuthenticatorEndpoint.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Inertia; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Users.Views.Account; diff --git a/modules/Users/src/SimpleModule.Users/Views/Account/TwoFactorAuthenticationEndpoint.cs b/modules/Users/src/SimpleModule.Users/Views/Account/TwoFactorAuthenticationEndpoint.cs index a4a65b79..c9e96e44 100644 --- a/modules/Users/src/SimpleModule.Users/Views/Account/TwoFactorAuthenticationEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Views/Account/TwoFactorAuthenticationEndpoint.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Inertia; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Users.Views.Account; diff --git a/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserServiceTests.cs b/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserServiceTests.cs index 8ffb36a8..dc318c05 100644 --- a/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserServiceTests.cs +++ b/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserServiceTests.cs @@ -4,13 +4,13 @@ using NSubstitute; using SimpleModule.Users; using SimpleModule.Users.Contracts; -using SimpleModule.Users.Entities; namespace Users.Tests.Unit; public sealed class UserServiceTests { private readonly UserManager _userManager; + private readonly RoleManager _roleManager; private readonly UserService _sut; public UserServiceTests() @@ -26,7 +26,14 @@ public UserServiceTests() null, null ); - _sut = new UserService(_userManager, NullLogger.Instance); + _roleManager = Substitute.For>( + Substitute.For>(), + null, + null, + null, + null + ); + _sut = new UserService(_userManager, _roleManager, NullLogger.Instance); } [Fact] diff --git a/template/SimpleModule.Host/Migrations/20260319120412_InitialCreate.Designer.cs b/template/SimpleModule.Host/Migrations/20260319120412_InitialCreate.Designer.cs index 02df6ce9..57d5d93f 100644 --- a/template/SimpleModule.Host/Migrations/20260319120412_InitialCreate.Designer.cs +++ b/template/SimpleModule.Host/Migrations/20260319120412_InitialCreate.Designer.cs @@ -601,7 +601,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationRole", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -633,7 +633,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Users_AspNetRoles", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationUser", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationUser", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -712,7 +712,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -721,7 +721,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -730,7 +730,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -739,13 +739,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -754,7 +754,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/template/SimpleModule.Host/Migrations/20260319162704_AddPageBuilderModule.Designer.cs b/template/SimpleModule.Host/Migrations/20260319162704_AddPageBuilderModule.Designer.cs index 9b4664d7..aa3bdac4 100644 --- a/template/SimpleModule.Host/Migrations/20260319162704_AddPageBuilderModule.Designer.cs +++ b/template/SimpleModule.Host/Migrations/20260319162704_AddPageBuilderModule.Designer.cs @@ -645,7 +645,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationRole", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -677,7 +677,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Users_AspNetRoles", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationUser", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationUser", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -756,7 +756,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -765,7 +765,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -774,7 +774,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -783,13 +783,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -798,7 +798,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/template/SimpleModule.Host/Migrations/20260319183039_AddPageDraftContent.Designer.cs b/template/SimpleModule.Host/Migrations/20260319183039_AddPageDraftContent.Designer.cs index bc76e62c..b15b5c8c 100644 --- a/template/SimpleModule.Host/Migrations/20260319183039_AddPageDraftContent.Designer.cs +++ b/template/SimpleModule.Host/Migrations/20260319183039_AddPageDraftContent.Designer.cs @@ -648,7 +648,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationRole", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -680,7 +680,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Users_AspNetRoles", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationUser", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationUser", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -759,7 +759,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -768,7 +768,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -777,7 +777,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -786,13 +786,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -801,7 +801,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/template/SimpleModule.Host/Migrations/20260319190530_AddPageEnhancements.Designer.cs b/template/SimpleModule.Host/Migrations/20260319190530_AddPageEnhancements.Designer.cs index 12ca3e60..5ab93555 100644 --- a/template/SimpleModule.Host/Migrations/20260319190530_AddPageEnhancements.Designer.cs +++ b/template/SimpleModule.Host/Migrations/20260319190530_AddPageEnhancements.Designer.cs @@ -713,7 +713,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationRole", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -745,7 +745,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Users_AspNetRoles", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationUser", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationUser", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -824,7 +824,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -833,7 +833,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -842,7 +842,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -851,13 +851,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -866,7 +866,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/template/SimpleModule.Host/Migrations/20260319223657_AddSettingsModule.Designer.cs b/template/SimpleModule.Host/Migrations/20260319223657_AddSettingsModule.Designer.cs index 03a5521e..427bfafd 100644 --- a/template/SimpleModule.Host/Migrations/20260319223657_AddSettingsModule.Designer.cs +++ b/template/SimpleModule.Host/Migrations/20260319223657_AddSettingsModule.Designer.cs @@ -745,7 +745,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Settings_Settings", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationRole", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -777,7 +777,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Users_AspNetRoles", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationUser", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationUser", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -856,7 +856,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -865,7 +865,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -874,7 +874,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -883,13 +883,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -898,7 +898,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/template/SimpleModule.Host/Migrations/20260320140954_AddPublicMenuItems.Designer.cs b/template/SimpleModule.Host/Migrations/20260320140954_AddPublicMenuItems.Designer.cs index 1fdd9421..dc9ec000 100644 --- a/template/SimpleModule.Host/Migrations/20260320140954_AddPublicMenuItems.Designer.cs +++ b/template/SimpleModule.Host/Migrations/20260320140954_AddPublicMenuItems.Designer.cs @@ -801,7 +801,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Settings_Settings", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationRole", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -833,7 +833,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Users_AspNetRoles", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationUser", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationUser", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -912,7 +912,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -921,7 +921,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -930,7 +930,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -939,13 +939,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -954,7 +954,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/template/SimpleModule.Host/Migrations/20260321151649_AddAuditLogsModule.Designer.cs b/template/SimpleModule.Host/Migrations/20260321151649_AddAuditLogsModule.Designer.cs index a5b823ea..feac89df 100644 --- a/template/SimpleModule.Host/Migrations/20260321151649_AddAuditLogsModule.Designer.cs +++ b/template/SimpleModule.Host/Migrations/20260321151649_AddAuditLogsModule.Designer.cs @@ -894,7 +894,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Settings_Settings", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationRole", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -926,7 +926,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Users_AspNetRoles", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationUser", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationUser", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -1005,7 +1005,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -1014,7 +1014,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -1023,7 +1023,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -1032,13 +1032,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -1047,7 +1047,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/template/SimpleModule.Host/Migrations/20260326093345_AddAuditLogDashboardIndexes.Designer.cs b/template/SimpleModule.Host/Migrations/20260326093345_AddAuditLogDashboardIndexes.Designer.cs index 8bd45500..fb56ad05 100644 --- a/template/SimpleModule.Host/Migrations/20260326093345_AddAuditLogDashboardIndexes.Designer.cs +++ b/template/SimpleModule.Host/Migrations/20260326093345_AddAuditLogDashboardIndexes.Designer.cs @@ -902,7 +902,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Settings_Settings", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationRole", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -934,7 +934,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Users_AspNetRoles", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationUser", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationUser", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -1013,7 +1013,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -1022,7 +1022,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -1031,7 +1031,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -1040,13 +1040,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -1055,7 +1055,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/template/SimpleModule.Host/Migrations/20260326163320_AddFileStorageModule.Designer.cs b/template/SimpleModule.Host/Migrations/20260326163320_AddFileStorageModule.Designer.cs index 503eab60..1e01ee4d 100644 --- a/template/SimpleModule.Host/Migrations/20260326163320_AddFileStorageModule.Designer.cs +++ b/template/SimpleModule.Host/Migrations/20260326163320_AddFileStorageModule.Designer.cs @@ -943,7 +943,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Settings_Settings", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationRole", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -975,7 +975,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Users_AspNetRoles", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationUser", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationUser", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -1054,7 +1054,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -1063,7 +1063,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -1072,7 +1072,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -1081,13 +1081,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -1096,7 +1096,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs index f441b148..b1fd452f 100644 --- a/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs +++ b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs @@ -940,7 +940,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Settings_Settings", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationRole", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -972,7 +972,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Users_AspNetRoles", (string)null); }); - modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationUser", b => + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationUser", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -1051,7 +1051,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) @@ -1060,7 +1060,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -1069,7 +1069,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -1078,13 +1078,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) .WithMany() .HasForeignKey("RoleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -1093,7 +1093,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs index b92218dc..d76afac7 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs @@ -56,4 +56,13 @@ public Task DeleteUserAsync(UserId id) Users.Remove(user); return Task.CompletedTask; } + + public Task> GetRoleIdsByNamesAsync( + IEnumerable roleNames + ) + { + // Fake: return role name as both key and ID + var dict = roleNames.ToDictionary(name => name, name => name); + return Task.FromResult>(dict); + } } From 00ce15665f965555afc665cb44bba4241d67fbc2 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 27 Mar 2026 09:16:12 +0000 Subject: [PATCH 03/14] Remove unused SM0016 diagnostic descriptor The TypeScript page registry validation cannot be done at compile time by the Roslyn generator (it has no access to .ts files). This check is already handled by `npm run validate-pages`. --- .../SimpleModule.Generator/Emitters/DiagnosticEmitter.cs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index 89c5daa0..5abd1df7 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -213,15 +213,6 @@ internal sealed class DiagnosticEmitter : IEmitter isEnabledByDefault: true ); - internal static readonly DiagnosticDescriptor ViewEndpointMissingPageRegistration = new( - id: "SM0016", - title: "View endpoint page not in Pages/index.ts", - messageFormat: "IViewEndpoint '{0}' in module '{1}' maps to page '{2}'. Ensure a matching entry exists in the module's Pages/index.ts: \"{2}\": () => import(\"...\"). Run 'npm run validate-pages' to verify.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Info, - isEnabledByDefault: true - ); - internal static readonly DiagnosticDescriptor InterceptorDependsOnDbContext = new( id: "SM0039", title: "SaveChanges interceptor has transitive DbContext dependency", From f5e6c70707861f8432a58b87ecd12f6140c4be1b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 27 Mar 2026 09:33:25 +0000 Subject: [PATCH 04/14] Simplify: clean up event bus and fix minor inefficiencies - Remove dead null check in EventBus.PublishInBackground (BackgroundEventChannel is now required) - Make BackgroundEventDispatcher dispatch events concurrently to avoid head-of-line blocking - Cache GetUserIdAsync result in AuthorizationEndpoint to avoid duplicate async call - Use ICollection pattern check in UserService.GetRoleIdsByNamesAsync to avoid unnecessary ToList - Remove unnecessary comments from BackgroundEventChannel --- .../Events/BackgroundEventChannel.cs | 3 -- .../Events/BackgroundEventDispatcher.cs | 29 ++++++++++++------- .../SimpleModule.Core/Events/EventBus.cs | 9 +----- .../Connect/AuthorizationEndpoint.cs | 5 ++-- .../src/SimpleModule.Users/UserService.cs | 4 +-- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/framework/SimpleModule.Core/Events/BackgroundEventChannel.cs b/framework/SimpleModule.Core/Events/BackgroundEventChannel.cs index 2257a4c2..b5df4866 100644 --- a/framework/SimpleModule.Core/Events/BackgroundEventChannel.cs +++ b/framework/SimpleModule.Core/Events/BackgroundEventChannel.cs @@ -21,9 +21,6 @@ public sealed partial class BackgroundEventChannel(ILogger(T @event) where T : IEvent { - // Capture the event in a closure that will be executed by the background dispatcher. - // The dispatcher provides a scoped IServiceProvider with an EventBus for proper - // exception isolation semantics. Func dispatch = (sp, ct) => { var bus = sp.GetRequiredService(); diff --git a/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs b/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs index 8b350a89..b3213bbc 100644 --- a/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs +++ b/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs @@ -6,7 +6,8 @@ namespace SimpleModule.Core.Events; /// /// Background service that drains the and dispatches -/// events to their handlers in a scoped DI context. +/// events to their handlers in a scoped DI context. Events are dispatched concurrently +/// to avoid head-of-line blocking from slow handlers. /// public sealed partial class BackgroundEventDispatcher( BackgroundEventChannel channel, @@ -18,17 +19,25 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await foreach (var dispatch in channel.Reader.ReadAllAsync(stoppingToken)) { - try - { - using var scope = serviceProvider.CreateScope(); - await dispatch(scope.ServiceProvider, stoppingToken); - } + _ = DispatchAsync(dispatch, stoppingToken); + } + } + + private async Task DispatchAsync( + Func dispatch, + CancellationToken stoppingToken + ) + { + try + { + using var scope = serviceProvider.CreateScope(); + await dispatch(scope.ServiceProvider, stoppingToken); + } #pragma warning disable CA1031 // Background dispatcher must not crash on handler failures - catch (Exception ex) + catch (Exception ex) #pragma warning restore CA1031 - { - LogDispatchFailed(logger, ex); - } + { + LogDispatchFailed(logger, ex); } } diff --git a/framework/SimpleModule.Core/Events/EventBus.cs b/framework/SimpleModule.Core/Events/EventBus.cs index edb1cd14..1a6991e6 100644 --- a/framework/SimpleModule.Core/Events/EventBus.cs +++ b/framework/SimpleModule.Core/Events/EventBus.cs @@ -75,7 +75,7 @@ namespace SimpleModule.Core.Events; public sealed partial class EventBus( IServiceProvider serviceProvider, ILogger logger, - BackgroundEventChannel? backgroundChannel = null + BackgroundEventChannel backgroundChannel ) : IEventBus { /// @@ -145,13 +145,6 @@ public async Task PublishAsync(T @event, CancellationToken cancellationToken public void PublishInBackground(T @event) where T : IEvent { - if (backgroundChannel is null) - { - throw new InvalidOperationException( - "Background event dispatch is not configured. Register BackgroundEventChannel and BackgroundEventDispatcher in DI." - ); - } - backgroundChannel.Enqueue(@event); } diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/AuthorizationEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/AuthorizationEndpoint.cs index f21b56e7..ff6a4368 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/AuthorizationEndpoint.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/AuthorizationEndpoint.cs @@ -70,8 +70,9 @@ await userManager.GetUserAsync(result.Principal) var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + var userIdString = await userManager.GetUserIdAsync(user); identity - .SetClaim(Claims.Subject, await userManager.GetUserIdAsync(user)) + .SetClaim(Claims.Subject, userIdString) .SetClaim(Claims.Email, await userManager.GetEmailAsync(user) ?? string.Empty) .SetClaim(Claims.Name, user.DisplayName); @@ -85,7 +86,7 @@ await userManager.GetUserAsync(result.Principal) var permissionContracts = context.RequestServices.GetRequiredService(); var userContracts = context.RequestServices.GetRequiredService(); - var userId = UserId.From(await userManager.GetUserIdAsync(user)); + var userId = UserId.From(userIdString); // Get role IDs for permission lookup via contracts var roleIdMap = await userContracts.GetRoleIdsByNamesAsync(roles); diff --git a/modules/Users/src/SimpleModule.Users/UserService.cs b/modules/Users/src/SimpleModule.Users/UserService.cs index da6bcf59..d2191b8d 100644 --- a/modules/Users/src/SimpleModule.Users/UserService.cs +++ b/modules/Users/src/SimpleModule.Users/UserService.cs @@ -101,9 +101,9 @@ public async Task> GetRoleIdsByNamesAsync( IEnumerable roleNames ) { - var nameList = roleNames.ToList(); + var nameCollection = roleNames as ICollection ?? roleNames.ToList(); var roles = await roleManager - .Roles.Where(r => nameList.Contains(r.Name!)) + .Roles.Where(r => nameCollection.Contains(r.Name!)) .ToDictionaryAsync(r => r.Name!, r => r.Id); return roles; } From 0f3d183119baf6d5a9cf863f2f70c255687fd3f1 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 27 Mar 2026 09:34:16 +0000 Subject: [PATCH 05/14] Use IServiceScopeFactory in BackgroundEventDispatcher for consistency Aligns with the convention established by AuditWriterService. IServiceScopeFactory is the recommended injection point for BackgroundService scope creation. --- .../SimpleModule.Core/Events/BackgroundEventDispatcher.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs b/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs index b3213bbc..3695b9bf 100644 --- a/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs +++ b/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs @@ -11,7 +11,7 @@ namespace SimpleModule.Core.Events; /// public sealed partial class BackgroundEventDispatcher( BackgroundEventChannel channel, - IServiceProvider serviceProvider, + IServiceScopeFactory scopeFactory, ILogger logger ) : BackgroundService { @@ -30,7 +30,7 @@ CancellationToken stoppingToken { try { - using var scope = serviceProvider.CreateScope(); + using var scope = scopeFactory.CreateScope(); await dispatch(scope.ServiceProvider, stoppingToken); } #pragma warning disable CA1031 // Background dispatcher must not crash on handler failures From 67771cd87e54fe9c69ee5eb3409447d923db1585 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 27 Mar 2026 09:49:43 +0000 Subject: [PATCH 06/14] Add SM0040-SM0042 diagnostics to enforce module design rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New compile-time diagnostics: - SM0040 (Error): Duplicate module name — prevents route prefix conflicts, database schema collisions, and ambiguous TypeScript module grouping - SM0041 (Warning): View page prefix mismatch — catches pages whose name doesn't start with the owning module's name (e.g. "Orders/Browse" in the Products module), which breaks React page resolution - SM0042 (Error): Module with IViewEndpoint but no ViewPrefix — view endpoints without a ViewPrefix won't be routed correctly Also adds tests for: - SM0015 (duplicate view page name) — positive and negative cases - SM0040 — two and three duplicate modules, plus valid unique names - SM0041 — matching prefix passes, mismatch warns - SM0042 — missing ViewPrefix errors, present ViewPrefix passes, API-only module without ViewPrefix passes --- .../Emitters/DiagnosticEmitter.cs | 93 ++++++ .../DiagnosticTests.cs | 314 ++++++++++++++++++ 2 files changed, 407 insertions(+) diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index 5abd1df7..3bcb4d74 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -222,6 +222,33 @@ internal sealed class DiagnosticEmitter : IEmitter isEnabledByDefault: true ); + internal static readonly DiagnosticDescriptor DuplicateModuleName = new( + id: "SM0040", + title: "Duplicate module name", + messageFormat: "Module name '{0}' is used by both '{1}' and '{2}'. Each module must have a unique name. Duplicate names cause route prefix conflicts, database schema collisions, and ambiguous TypeScript module grouping.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ViewPagePrefixMismatch = new( + id: "SM0041", + title: "View page name does not match module name prefix", + messageFormat: "View endpoint '{0}' in module '{1}' maps to page '{2}', but page names should start with the module name prefix '{1}/'. This causes the React page resolver to look for the page bundle in the wrong module. Rename the endpoint class or move it to the correct module.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ViewEndpointWithoutViewPrefix = new( + id: "SM0042", + title: "Module has view endpoints but no ViewPrefix", + messageFormat: "Module '{0}' contains {1} IViewEndpoint implementation(s) but does not define a ViewPrefix. View endpoints will not be routed correctly. Add ViewPrefix to the [Module] attribute: [Module(\"{0}\", ViewPrefix = \"/{2}\")].", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + public void Emit(SourceProductionContext context, DiscoveryData data) { // SM0002: Empty module name @@ -239,6 +266,31 @@ public void Emit(SourceProductionContext context, DiscoveryData data) } } + // SM0040: Duplicate module name + var seenModuleNames = new Dictionary(); + foreach (var module in data.Modules) + { + if (string.IsNullOrEmpty(module.ModuleName)) + continue; + + if (seenModuleNames.TryGetValue(module.ModuleName, out var existingFqn)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DuplicateModuleName, + Location.None, + module.ModuleName, + Strip(existingFqn), + Strip(module.FullyQualifiedName) + ) + ); + } + else + { + seenModuleNames[module.ModuleName] = module.FullyQualifiedName; + } + } + // SM0004: DbContext with no DbSets — silently skipped. // Some DbContexts (e.g., OpenIddict) manage tables internally without public DbSet // properties. These are excluded from the unified HostDbContext but are not an error. @@ -695,6 +747,47 @@ public void Emit(SourceProductionContext context, DiscoveryData data) } } + // SM0041: View page prefix must match module name + foreach (var module in data.Modules) + { + if (string.IsNullOrEmpty(module.ModuleName)) + continue; + + var expectedPrefix = module.ModuleName + "/"; + foreach (var view in module.Views) + { + if (!view.Page.StartsWith(expectedPrefix, System.StringComparison.Ordinal)) + { + context.ReportDiagnostic( + Diagnostic.Create( + ViewPagePrefixMismatch, + Location.None, + Strip(view.FullyQualifiedName), + module.ModuleName, + view.Page + ) + ); + } + } + } + + // SM0042: Module with views but no ViewPrefix + foreach (var module in data.Modules) + { + if (module.Views.Length > 0 && string.IsNullOrEmpty(module.ViewPrefix)) + { + context.ReportDiagnostic( + Diagnostic.Create( + ViewEndpointWithoutViewPrefix, + Location.None, + module.ModuleName, + module.Views.Length, + module.ModuleName.ToLowerInvariant() + ) + ); + } + } + // SM0039: Interceptor depends on contract whose implementation takes a DbContext foreach (var interceptor in data.Interceptors) { diff --git a/tests/SimpleModule.Generator.Tests/DiagnosticTests.cs b/tests/SimpleModule.Generator.Tests/DiagnosticTests.cs index b4d614a9..1f64cc75 100644 --- a/tests/SimpleModule.Generator.Tests/DiagnosticTests.cs +++ b/tests/SimpleModule.Generator.Tests/DiagnosticTests.cs @@ -875,4 +875,318 @@ public SomeDbContext(DbContextOptions options, IOptions "browse"); + } + } + } + + namespace TestApp.ModuleB + { + [Module("ModuleB", ViewPrefix = "/module-b")] + public class ModuleBModule : IModule { } + } + + namespace TestApp.ModuleB.Views + { + public class BrowseEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/browse", () => "browse"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + // "ModuleA/Browse" and "ModuleB/Browse" are different page names — no conflict + diagnostics.Should().NotContain(d => d.Id == "SM0015"); + } + + [Fact] + public void SM0015_UniquePageNames_NoDiagnostic() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Test", ViewPrefix = "/test")] + public class TestModule : IModule { } + } + + namespace TestApp.Views + { + public class BrowseEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/browse", () => "browse"); + } + } + + public class CreateEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/create", () => "create"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0015"); + } + + #endregion + + #region SM0040: Duplicate module name + + [Fact] + public void SM0040_DuplicateModuleName_ReportsError() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.ModuleA + { + [Module("Products")] + public class ProductsModuleA : IModule { } + } + + namespace TestApp.ModuleB + { + [Module("Products")] + public class ProductsModuleB : IModule { } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0040"); + var diag = diagnostics.First(d => d.Id == "SM0040"); + var message = diag.GetMessage(System.Globalization.CultureInfo.InvariantCulture); + message.Should().Contain("Products"); + message.Should().Contain("ProductsModuleA"); + message.Should().Contain("ProductsModuleB"); + } + + [Fact] + public void SM0040_UniqueModuleNames_NoDiagnostic() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.ModuleA + { + [Module("Products")] + public class ProductsModule : IModule { } + } + + namespace TestApp.ModuleB + { + [Module("Orders")] + public class OrdersModule : IModule { } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0040"); + } + + [Fact] + public void SM0040_ThreeModulesSameName_ReportsError() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.A { [Module("Catalog")] public class CatalogA : IModule { } } + namespace TestApp.B { [Module("Catalog")] public class CatalogB : IModule { } } + namespace TestApp.C { [Module("Catalog")] public class CatalogC : IModule { } } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0040"); + // At least two diagnostics (B conflicts with A, C conflicts with A) + diagnostics.Where(d => d.Id == "SM0040").Should().HaveCountGreaterThanOrEqualTo(2); + } + + #endregion + + #region SM0041: View page prefix mismatch + + [Fact] + public void SM0041_PagePrefixMatchesModuleName_NoDiagnostic() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products", ViewPrefix = "/products")] + public class ProductsModule : IModule { } + } + + namespace TestApp.Views + { + public class BrowseEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/browse", () => "browse"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + // Page name "Products/Browse" starts with "Products/" — no mismatch + diagnostics.Should().NotContain(d => d.Id == "SM0041"); + } + + #endregion + + #region SM0042: Module with views but no ViewPrefix + + [Fact] + public void SM0042_ViewEndpointWithoutViewPrefix_ReportsError() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products")] + public class ProductsModule : IModule { } + } + + namespace TestApp.Views + { + public class BrowseEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/browse", () => "browse"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0042"); + var diag = diagnostics.First(d => d.Id == "SM0042"); + var message = diag.GetMessage(System.Globalization.CultureInfo.InvariantCulture); + message.Should().Contain("Products"); + message.Should().Contain("/products"); + } + + [Fact] + public void SM0042_ViewEndpointWithViewPrefix_NoDiagnostic() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products", ViewPrefix = "/products")] + public class ProductsModule : IModule { } + } + + namespace TestApp.Views + { + public class BrowseEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/browse", () => "browse"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0042"); + } + + [Fact] + public void SM0042_NoViewEndpoints_NoDiagnostic() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products")] + public class ProductsModule : IModule { } + } + + namespace TestApp.Endpoints + { + public class ListEndpoint : IEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/", () => "list"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + // No IViewEndpoint, so SM0042 should not fire even without ViewPrefix + diagnostics.Should().NotContain(d => d.Id == "SM0042"); + } + + #endregion } From afb65db0f3ee7e6afd82e7109d25311ca67b2187 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 27 Mar 2026 10:05:33 +0000 Subject: [PATCH 07/14] Remove SM0011 suppression now that modules use Contracts references Admin, OpenIddict, and Permissions modules were migrated to reference SimpleModule.Users.Contracts instead of SimpleModule.Users, so the SM0011 (illegal implementation reference) suppression is no longer needed. --- template/SimpleModule.Host/SimpleModule.Host.csproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/template/SimpleModule.Host/SimpleModule.Host.csproj b/template/SimpleModule.Host/SimpleModule.Host.csproj index 2adb087c..9ac32b3f 100644 --- a/template/SimpleModule.Host/SimpleModule.Host.csproj +++ b/template/SimpleModule.Host/SimpleModule.Host.csproj @@ -4,8 +4,6 @@ net10.0 false true - - $(NoWarn);SM0011 $(NoWarn);SM0035;SM0038 From eda720c9fd298150d764f618f3337302e83fe043 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 27 Mar 2026 10:11:52 +0000 Subject: [PATCH 08/14] =?UTF-8?q?Remove=20SM0035/SM0038=20NoWarn=20?= =?UTF-8?q?=E2=80=94=20no=20violations=20exist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All constant classes in Contracts assemblies are `static class` (excluded from DTO discovery). Permission classes are excluded via IModulePermissions check. Vogen value objects are excluded via IsVogenValueObject check. No DbContext types exist in any Contracts assembly. The suppression was preemptive and never needed. --- template/SimpleModule.Host/SimpleModule.Host.csproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/template/SimpleModule.Host/SimpleModule.Host.csproj b/template/SimpleModule.Host/SimpleModule.Host.csproj index 9ac32b3f..98a2a828 100644 --- a/template/SimpleModule.Host/SimpleModule.Host.csproj +++ b/template/SimpleModule.Host/SimpleModule.Host.csproj @@ -4,8 +4,6 @@ net10.0 false true - - $(NoWarn);SM0035;SM0038 From 275bee6bb87c60a401b0628fc44dee7e636cee26 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 27 Mar 2026 13:29:22 +0100 Subject: [PATCH 09/14] Address module design review: 10 architectural issues fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framework core: - Add Result type, ModuleHealthStatus enum, and module lifecycle hooks (OnStartAsync/OnStopAsync/CheckHealthAsync on IModule) - Add ModuleLifecycleHostedService to invoke lifecycle hooks at startup/shutdown - Add IModuleDbContext marker interface for future per-module DB isolation - Add PermissionMatcher with wildcard support and use in authorization handler - Add [ViewPage] attribute for compile-time page name validation Source generator: - Add SM0040-SM0042 diagnostics (empty modules, missing contracts, missing ViewPrefix) - Fix SM0043 to not flag modules with DbContexts as empty - Fix endpoint-to-module matching to use namespace-based discovery - Scan contracts assemblies for permission classes (fixes SM0035 false positives) - Register IModule singletons and lifecycle service in generated AddModules() Module decoupling: - Decouple Orders.Contracts from Vogen types (UserId→string, ProductId→int) - Extract IUserAdminContracts/IRoleAdminContracts from Users module - Refactor Admin module to use contracts instead of direct UserManager/RoleManager - Add Dashboard.Contracts project with IDashboardContracts placeholder Route fixes: - Add ViewPrefix to Admin, Dashboard, OpenIddict, PageBuilder, Users modules - Fix route doubling by making all view endpoint routes relative to ViewPrefix - Fix SM0015 duplicate page name (PageBuilder/ViewerDraft) Tests updated across all affected modules and framework. --- CLAUDE.md | 15 ++ SimpleModule.slnx | 1 + .../PermissionAuthorizationHandler.cs | 13 ++ .../Authorization/PermissionMatcher.cs | 41 ++++ .../Hosting/IModuleDbContext.cs | 27 +++ .../Hosting/ModuleLifecycleHostedService.cs | 79 +++++++ framework/SimpleModule.Core/IModule.cs | 19 ++ .../SimpleModule.Core/ModuleHealthStatus.cs | 11 + framework/SimpleModule.Core/Result.cs | 92 ++++++++ .../SimpleModule.Core/ViewPageAttribute.cs | 15 ++ .../AnalyzerReleases.Unshipped.md | 5 + .../Discovery/DiscoveryData.cs | 3 +- .../Discovery/SymbolDiscovery.cs | 87 ++++++- .../Emitters/DiagnosticEmitter.cs | 44 +++- .../Emitters/ModuleExtensionsEmitter.cs | 14 ++ .../src/SimpleModule.Admin/AdminModule.cs | 2 +- .../Endpoints/Admin/AdminRolesEndpoint.cs | 72 +++--- .../Endpoints/Admin/AdminUsersEndpoint.cs | 160 ++++--------- .../Views/Admin/RolesCreateEndpoint.cs | 3 +- .../Views/Admin/RolesEditEndpoint.cs | 30 +-- .../Views/Admin/RolesEndpoint.cs | 27 +-- .../Views/Admin/UsersActivityEndpoint.cs | 17 +- .../Views/Admin/UsersCreateEndpoint.cs | 17 +- .../Views/Admin/UsersEditEndpoint.cs | 53 ++--- .../Views/Admin/UsersEndpoint.cs | 56 +---- .../Views/BrowseEndpoint.cs | 1 + .../Views/DashboardEndpoint.cs | 1 + .../Views/DetailEndpoint.cs | 1 + .../DashboardConstants.cs | 4 +- .../IDashboardContracts.cs | 12 + .../SimpleModule.Dashboard.Contracts.csproj | 9 + .../DashboardContractsService.cs | 7 + .../SimpleModule.Dashboard/DashboardModule.cs | 8 +- .../SimpleModule.Dashboard.csproj | 1 + .../Views/HomeEndpoint.cs | 1 + .../Views/BrowseEndpoint.cs | 1 + .../OpenIddictModule.cs | 2 +- .../Views/OpenIddict/ClientsCreateEndpoint.cs | 3 +- .../Views/OpenIddict/ClientsEditEndpoint.cs | 3 +- .../Views/OpenIddict/ClientsEndpoint.cs | 3 +- .../CreateOrderRequest.cs | 4 +- .../Events/OrderCreatedEvent.cs | 3 +- .../SimpleModule.Orders.Contracts/Order.cs | 4 +- .../OrderItem.cs | 4 +- .../SimpleModule.Orders.Contracts.csproj | 2 - .../UpdateOrderRequest.cs | 4 +- .../Orders/CreateRequestValidator.cs | 2 +- .../OrderConfiguration.cs | 12 +- .../src/SimpleModule.Orders/OrderService.cs | 18 +- .../SimpleModule.Orders/OrdersDbContext.cs | 8 - .../Views/CreateEndpoint.cs | 6 +- .../SimpleModule.Orders/Views/EditEndpoint.cs | 10 +- .../SimpleModule.Orders/Views/ListEndpoint.cs | 1 + .../Integration/OrdersEndpointTests.cs | 10 +- .../Unit/CreateOrderRequestValidatorTests.cs | 14 +- .../Unit/OrderServiceTests.cs | 34 +-- .../PageBuilderModule.cs | 4 +- .../SimpleModule.PageBuilder/Pages/index.ts | 1 + .../SimpleModule.PageBuilder/Views/Editor.tsx | 8 +- .../Views/EditorEndpoint.cs | 5 +- .../SimpleModule.PageBuilder/Views/Manage.tsx | 12 +- .../Views/ManageEndpoint.cs | 3 +- .../Views/PagesList.tsx | 2 +- .../Views/PagesListEndpoint.cs | 3 +- .../Views/ViewerDraftEndpoint.cs | 5 +- .../Views/ViewerEndpoint.cs | 3 +- .../PageEndpointTests.cs | 4 +- .../Views/BrowseEndpoint.cs | 1 + .../Views/CreateEndpoint.cs | 1 + .../Views/EditEndpoint.cs | 1 + .../Views/ManageEndpoint.cs | 1 + .../Views/AdminSettingsEndpoint.cs | 1 + .../Views/MenuManagerEndpoint.cs | 1 + .../Views/UserSettingsEndpoint.cs | 1 + .../AdminUserDto.cs | 16 ++ .../CreateAdminUserRequest.cs | 10 + .../IRoleAdminContracts.cs | 11 + .../IUserAdminContracts.cs | 19 ++ .../SimpleModule.Users.Contracts/RoleDto.cs | 13 ++ .../UpdateAdminUserRequest.cs | 8 + .../SimpleModule.Users/RoleAdminService.cs | 115 ++++++++++ .../SimpleModule.Users/UserAdminService.cs | 214 ++++++++++++++++++ .../src/SimpleModule.Users/UsersModule.cs | 2 +- .../Views/Account/Disable2faEndpoint.cs | 3 +- .../Account/EnableAuthenticatorEndpoint.cs | 3 +- .../Account/GenerateRecoveryCodesEndpoint.cs | 3 +- .../Account/ResetAuthenticatorEndpoint.cs | 3 +- .../TwoFactorAuthenticationEndpoint.cs | 3 +- modules/Users/src/Users/types.ts | 45 ++++ .../SimpleModule.Core.Tests/EventBusTests.cs | 16 +- .../Events/EventBusPartialFailureTests.cs | 20 +- .../DiagnosticTests.cs | 5 +- .../Fakes/FakeDataGenerators.cs | 8 +- tests/e2e/pages/pagebuilder/editor.page.ts | 6 +- tests/e2e/pages/pagebuilder/manage.page.ts | 2 +- tests/e2e/pages/pagebuilder/viewer.page.ts | 2 +- .../e2e/tests/flows/pagebuilder-crud.spec.ts | 8 +- 97 files changed, 1233 insertions(+), 455 deletions(-) create mode 100644 framework/SimpleModule.Core/Authorization/PermissionMatcher.cs create mode 100644 framework/SimpleModule.Core/Hosting/IModuleDbContext.cs create mode 100644 framework/SimpleModule.Core/Hosting/ModuleLifecycleHostedService.cs create mode 100644 framework/SimpleModule.Core/ModuleHealthStatus.cs create mode 100644 framework/SimpleModule.Core/Result.cs create mode 100644 framework/SimpleModule.Core/ViewPageAttribute.cs rename modules/Dashboard/src/{SimpleModule.Dashboard => SimpleModule.Dashboard.Contracts}/DashboardConstants.cs (53%) create mode 100644 modules/Dashboard/src/SimpleModule.Dashboard.Contracts/IDashboardContracts.cs create mode 100644 modules/Dashboard/src/SimpleModule.Dashboard.Contracts/SimpleModule.Dashboard.Contracts.csproj create mode 100644 modules/Dashboard/src/SimpleModule.Dashboard/DashboardContractsService.cs create mode 100644 modules/Users/src/SimpleModule.Users.Contracts/AdminUserDto.cs create mode 100644 modules/Users/src/SimpleModule.Users.Contracts/CreateAdminUserRequest.cs create mode 100644 modules/Users/src/SimpleModule.Users.Contracts/IRoleAdminContracts.cs create mode 100644 modules/Users/src/SimpleModule.Users.Contracts/IUserAdminContracts.cs create mode 100644 modules/Users/src/SimpleModule.Users.Contracts/RoleDto.cs create mode 100644 modules/Users/src/SimpleModule.Users.Contracts/UpdateAdminUserRequest.cs create mode 100644 modules/Users/src/SimpleModule.Users/RoleAdminService.cs create mode 100644 modules/Users/src/SimpleModule.Users/UserAdminService.cs diff --git a/CLAUDE.md b/CLAUDE.md index e275e1fc..39a1a3bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -209,6 +209,21 @@ sm doctor [--fix] # validate project structure, auto-fix issues - Each module registers `ModuleDbContextInfo` for schema isolation. - Uses `EnsureCreated()` — for production migrations, use EF Core migrations per module. +### Unified HostDbContext (Known Limitation) + +All module entities are currently merged into a single `HostDbContext` by the source generator. This means all modules share one database connection and one migration history. Trade-offs: + +- **Schema changes in one module block deployments of others** — a migration in Products prevents independent Orders deployment. +- **Only one module can own IdentityDbContext** (SM0003 enforces this). +- **Schema isolation is provider-dependent** — SQLite uses table prefixes, PostgreSQL uses schemas. Raw SQL or cross-module queries leak this abstraction. + +**Future path:** The `IModuleDbContext` marker interface (in `SimpleModule.Core.Hosting`) exists as a stepping stone. Modules implementing it declare their schema name. A future generator update can use this to support: +1. Per-module connection strings (opt-in) +2. Independent migration histories per module +3. A path toward separate databases for modules that need microservice-level isolation + +For now, the unified context remains the default. Design modules with minimal cross-entity queries to ease future decoupling. + ### EF Core Interceptor DI Patterns **Problem:** SaveChangesInterceptors registered in DI can cause circular dependencies when they depend on services that themselves depend on DbContext. For example: `SaveChangesInterceptor → ISettings → SettingsService → SettingsDbContext` creates a deadlock during construction. diff --git a/SimpleModule.slnx b/SimpleModule.slnx index 59c8f348..c004fc10 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -21,6 +21,7 @@ + diff --git a/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs b/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs index 6d952791..bb2bc401 100644 --- a/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs +++ b/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs @@ -17,9 +17,22 @@ PermissionRequirement requirement return Task.CompletedTask; } + // Exact match (fast path) if (context.User.HasClaim("permission", requirement.Permission)) { context.Succeed(requirement); + return Task.CompletedTask; + } + + // Wildcard match: check all permission claims for wildcard patterns + foreach (var claim in context.User.Claims) + { + if (claim.Type == "permission" + && PermissionMatcher.IsMatch(claim.Value, requirement.Permission)) + { + context.Succeed(requirement); + return Task.CompletedTask; + } } return Task.CompletedTask; diff --git a/framework/SimpleModule.Core/Authorization/PermissionMatcher.cs b/framework/SimpleModule.Core/Authorization/PermissionMatcher.cs new file mode 100644 index 00000000..7574e82e --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/PermissionMatcher.cs @@ -0,0 +1,41 @@ +namespace SimpleModule.Core.Authorization; + +/// +/// Matches permission claims against requirements, supporting wildcard patterns. +/// +/// +/// Wildcard rules: +/// +/// "Products.*" matches "Products.View", "Products.Create", etc. +/// "*" matches any single-segment permission +/// Exact matches are checked first for performance +/// Only trailing wildcards are supported (no "*.View" or "Pro*.Create") +/// +/// +public static class PermissionMatcher +{ + /// + /// Returns true if the satisfies the . + /// + public static bool IsMatch(string claim, string requirement) + { + // Exact match (fast path) + if (string.Equals(claim, requirement, StringComparison.Ordinal)) + return true; + + // Wildcard: claim ends with ".*" + if (claim.Length > 2 + && claim[^1] == '*' + && claim[^2] == '.' + && requirement.StartsWith(claim[..^1], StringComparison.Ordinal)) + { + return true; + } + + // Global wildcard: claim is just "*" + if (claim is "*") + return true; + + return false; + } +} diff --git a/framework/SimpleModule.Core/Hosting/IModuleDbContext.cs b/framework/SimpleModule.Core/Hosting/IModuleDbContext.cs new file mode 100644 index 00000000..f14fa5ae --- /dev/null +++ b/framework/SimpleModule.Core/Hosting/IModuleDbContext.cs @@ -0,0 +1,27 @@ +namespace SimpleModule.Core.Hosting; + +/// +/// Marker interface for module-owned DbContexts that participate in the unified HostDbContext. +/// Implementing this interface signals that a module's DbContext contributes entities to the +/// shared database and requires schema isolation. +/// +/// +/// +/// Currently, all module entities are merged into a single HostDbContext by the source generator. +/// This means all modules share one database connection and one migration history. Schema +/// isolation is provider-dependent (PostgreSQL/SQL Server use schemas, SQLite uses table prefixes). +/// +/// +/// Future direction: modules implementing this interface may opt into independent DbContext +/// resolution, enabling per-module databases, independent migrations, and a migration path +/// toward microservices. The generator will use this interface to determine which modules +/// participate in the unified context vs. manage their own. +/// +/// +public interface IModuleDbContext +{ + /// + /// The schema name (or table prefix for SQLite) used to isolate this module's entities. + /// + static abstract string SchemaName { get; } +} diff --git a/framework/SimpleModule.Core/Hosting/ModuleLifecycleHostedService.cs b/framework/SimpleModule.Core/Hosting/ModuleLifecycleHostedService.cs new file mode 100644 index 00000000..61e60136 --- /dev/null +++ b/framework/SimpleModule.Core/Hosting/ModuleLifecycleHostedService.cs @@ -0,0 +1,79 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace SimpleModule.Core.Hosting; + +/// +/// Calls and lifecycle hooks +/// on all discovered modules during application startup and shutdown. +/// +public sealed partial class ModuleLifecycleHostedService( + IEnumerable modules, + ILogger logger +) : IHostedLifecycleService +{ + public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public async Task StartAsync(CancellationToken cancellationToken) + { + foreach (var module in modules) + { + var moduleName = module.GetType().Name; + try + { + await module.OnStartAsync(cancellationToken); + LogModuleStarted(logger, moduleName); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + LogModuleStartFailed(logger, moduleName, ex); + throw; + } + } + } + + public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Module stop failures must not prevent other modules from stopping")] + public async Task StopAsync(CancellationToken cancellationToken) + { + foreach (var module in modules) + { + var moduleName = module.GetType().Name; + try + { + await module.OnStopAsync(cancellationToken); + LogModuleStopped(logger, moduleName); + } + catch (OperationCanceledException) + { + // Shutdown requested — continue stopping remaining modules + } + catch (Exception ex) + { + LogModuleStopFailed(logger, moduleName, ex); + } + } + } + + public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + [LoggerMessage(Level = LogLevel.Debug, Message = "Module {ModuleName} started")] + private static partial void LogModuleStarted(ILogger logger, string moduleName); + + [LoggerMessage(Level = LogLevel.Error, Message = "Module {ModuleName} failed during OnStartAsync")] + private static partial void LogModuleStartFailed(ILogger logger, string moduleName, Exception exception); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Module {ModuleName} stopped")] + private static partial void LogModuleStopped(ILogger logger, string moduleName); + + [LoggerMessage(Level = LogLevel.Error, Message = "Module {ModuleName} failed during OnStopAsync")] + private static partial void LogModuleStopFailed(ILogger logger, string moduleName, Exception exception); +} diff --git a/framework/SimpleModule.Core/IModule.cs b/framework/SimpleModule.Core/IModule.cs index 3f575018..00e8e3a1 100644 --- a/framework/SimpleModule.Core/IModule.cs +++ b/framework/SimpleModule.Core/IModule.cs @@ -16,4 +16,23 @@ virtual void ConfigureMiddleware(IApplicationBuilder app) { } virtual void ConfigureMenu(IMenuBuilder menus) { } virtual void ConfigurePermissions(PermissionRegistryBuilder builder) { } virtual void ConfigureSettings(ISettingsBuilder settings) { } + + /// + /// Called once during application startup after all services are registered. + /// Use for one-time initialization such as loading certificates, warming caches, or verifying external dependencies. + /// + virtual Task OnStartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Called during graceful application shutdown. + /// Use for cleanup such as flushing buffers, closing connections, or draining background work. + /// + virtual Task OnStopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Returns the current health status of this module. + /// Called by the module health check endpoint to report per-module health. + /// + virtual Task CheckHealthAsync(CancellationToken cancellationToken) => + Task.FromResult(ModuleHealthStatus.Healthy); } diff --git a/framework/SimpleModule.Core/ModuleHealthStatus.cs b/framework/SimpleModule.Core/ModuleHealthStatus.cs new file mode 100644 index 00000000..e0b69153 --- /dev/null +++ b/framework/SimpleModule.Core/ModuleHealthStatus.cs @@ -0,0 +1,11 @@ +namespace SimpleModule.Core; + +/// +/// Represents the health status of a module. +/// +public enum ModuleHealthStatus +{ + Healthy, + Degraded, + Unhealthy, +} diff --git a/framework/SimpleModule.Core/Result.cs b/framework/SimpleModule.Core/Result.cs new file mode 100644 index 00000000..78968eca --- /dev/null +++ b/framework/SimpleModule.Core/Result.cs @@ -0,0 +1,92 @@ +using System.Diagnostics.CodeAnalysis; + +namespace SimpleModule.Core; + +/// +/// Represents the outcome of an operation that may fail with a structured error. +/// +/// The type of the success value. +[SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Standard Result pattern requires static factory methods")] +public readonly struct Result : IEquatable> +{ + private readonly T? _value; + private readonly ResultError? _error; + + private Result(T value) + { + _value = value; + _error = null; + IsSuccess = true; + } + + private Result(ResultError error) + { + _value = default; + _error = error; + IsSuccess = false; + } + + public bool IsSuccess { get; } + + public bool IsFailure => !IsSuccess; + + public T Value => IsSuccess + ? _value! + : throw new InvalidOperationException("Cannot access Value on a failed Result."); + + public ResultError Error => !IsSuccess + ? _error! + : throw new InvalidOperationException("Cannot access Error on a successful Result."); + + public static Result Ok(T value) => new(value); + + public static Result Fail(string message) => new(new ResultError(message)); + + public static Result Fail(string message, Dictionary validationErrors) => + new(new ResultError(message, validationErrors)); + + /// + /// Creates a from a value (alternate for implicit operator). + /// + [SuppressMessage("Design", "CA2225:Operator overloads have named alternates", Justification = "ToResult provided")] + public static implicit operator Result(T value) => Ok(value); + + /// + /// Named alternate for the implicit conversion operator. + /// + public static Result FromValue(T value) => Ok(value); + + /// + /// Maps the success value to a new type using the provided function. + /// + public Result Map(Func map) => + IsSuccess + ? Result.Ok(map(_value!)) + : Result.Fail(Error.Message, Error.ValidationErrors ?? new Dictionary()); + + /// + /// Returns the value if successful, or the provided fallback value. + /// + public T ValueOr(T fallback) => IsSuccess ? _value! : fallback; + + public bool Equals(Result other) => + IsSuccess == other.IsSuccess + && EqualityComparer.Default.Equals(_value, other._value) + && Equals(_error, other._error); + + public override bool Equals(object? obj) => obj is Result other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(IsSuccess, _value, _error); + + public static bool operator ==(Result left, Result right) => left.Equals(right); + + public static bool operator !=(Result left, Result right) => !left.Equals(right); +} + +/// +/// Describes why an operation failed. +/// +public sealed record ResultError( + string Message, + Dictionary? ValidationErrors = null +); diff --git a/framework/SimpleModule.Core/ViewPageAttribute.cs b/framework/SimpleModule.Core/ViewPageAttribute.cs new file mode 100644 index 00000000..8f565348 --- /dev/null +++ b/framework/SimpleModule.Core/ViewPageAttribute.cs @@ -0,0 +1,15 @@ +namespace SimpleModule.Core; + +/// +/// Declares the Inertia component name for an . +/// The generator uses this to validate page registry entries and enforce naming conventions. +/// +/// +/// The Inertia component name (e.g., "Products/Browse"). Must match the key in Pages/index.ts +/// and the first argument to Inertia.Render(). +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class ViewPageAttribute(string component) : Attribute +{ + public string Component { get; } = component; +} diff --git a/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md index 3064768b..c5223b99 100644 --- a/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md +++ b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md @@ -25,3 +25,8 @@ SM0034 | SimpleModule.Generator | Warning | Permission value prefix does not mat SM0035 | SimpleModule.Generator | Warning | DTO type in contracts has no public properties SM0038 | SimpleModule.Generator | Warning | Infrastructure type in Contracts assembly SM0039 | SimpleModule.Generator | Warning | SaveChanges interceptor has transitive DbContext dependency +SM0015 | SimpleModule.Generator | Error | Duplicate view page name across modules +SM0040 | SimpleModule.Generator | Error | Duplicate module name +SM0041 | SimpleModule.Generator | Warning | View page name does not match module name prefix +SM0042 | SimpleModule.Generator | Error | Module has view endpoints but no ViewPrefix +SM0043 | SimpleModule.Generator | Warning | Module does not override any IModule methods diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs index 5c1016d2..b6fba426 100644 --- a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs @@ -349,7 +349,8 @@ internal sealed class EndpointInfo internal sealed class ViewInfo { public string FullyQualifiedName { get; set; } = ""; - public string Page { get; set; } = ""; + public string? Page { get; set; } + public string InferredClassName { get; set; } = ""; } internal sealed class DtoTypeInfo diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index fddde914..8addf8db 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -60,22 +60,55 @@ is not IAssemblySymbol assemblySymbol // Discover IEndpoint implementors per module assembly. // Classification is by interface type: IViewEndpoint -> view, IEndpoint -> API. + // Scan each assembly once, then match endpoints to the closest module by namespace. if (endpointInterfaceSymbol is not null) { + var endpointScannedAssemblies = new HashSet( + SymbolEqualityComparer.Default + ); foreach (var module in modules) { if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol)) continue; var assembly = typeSymbol.ContainingAssembly; + if (!endpointScannedAssemblies.Add(assembly)) + continue; + + var rawEndpoints = new List(); + var rawViews = new List(); FindEndpointTypes( assembly.GlobalNamespace, endpointInterfaceSymbol, viewEndpointInterfaceSymbol, - module.ModuleName, - module.Endpoints, - module.Views + rawEndpoints, + rawViews ); + + // Match each endpoint/view to the module whose namespace is closest + foreach (var ep in rawEndpoints) + { + var epFqn = TypeMappingHelpers.StripGlobalPrefix(ep.FullyQualifiedName); + var ownerName = FindClosestModuleName(epFqn, modules); + var owner = modules.Find(m => m.ModuleName == ownerName); + if (owner is not null) + owner.Endpoints.Add(ep); + } + + foreach (var v in rawViews) + { + var vFqn = TypeMappingHelpers.StripGlobalPrefix(v.FullyQualifiedName); + var ownerName = FindClosestModuleName(vFqn, modules); + var owner = modules.Find(m => m.ModuleName == ownerName); + if (owner is not null) + { + // Set page name using owner module name if not already set via [ViewPage] + if (v.Page is null) + v.Page = ownerName + "/" + v.InferredClassName; + + owner.Views.Add(v); + } + } } } @@ -276,7 +309,7 @@ is not IAssemblySymbol assemblySymbol ); } - // Step 3c: Find IModulePermissions implementors in module assemblies + // Step 3c: Find IModulePermissions implementors in module and contracts assemblies var permissionClasses = new List(); var modulePermissionsSymbol = compilation.GetTypeByMetadataName( "SimpleModule.Core.Authorization.IModulePermissions" @@ -296,6 +329,20 @@ is not IAssemblySymbol assemblySymbol permissionClasses ); } + + // Also scan contracts assemblies for permission classes + foreach (var kvp in contractsAssemblySymbols) + { + if (contractsAssemblyMap.TryGetValue(kvp.Key, out var moduleName)) + { + FindPermissionClasses( + kvp.Value.GlobalNamespace, + modulePermissionsSymbol, + moduleName, + permissionClasses + ); + } + } } // Step 3d: Find ISaveChangesInterceptor implementors in module assemblies @@ -424,7 +471,7 @@ is not IAssemblySymbol assemblySymbol e.AllowAnonymous )) .ToImmutableArray(), - m.Views.Select(v => new ViewInfoRecord(v.FullyQualifiedName, v.Page)) + m.Views.Select(v => new ViewInfoRecord(v.FullyQualifiedName, v.Page ?? "")) .ToImmutableArray() )) .ToImmutableArray(), @@ -584,7 +631,6 @@ private static void FindEndpointTypes( INamespaceSymbol namespaceSymbol, INamedTypeSymbol endpointInterfaceSymbol, INamedTypeSymbol? viewEndpointInterfaceSymbol, - string moduleName, List endpoints, List views ) @@ -597,7 +643,6 @@ List views childNamespace, endpointInterfaceSymbol, viewEndpointInterfaceSymbol, - moduleName, endpoints, views ); @@ -613,6 +658,26 @@ viewEndpointInterfaceSymbol is not null && ImplementsInterface(typeSymbol, viewEndpointInterfaceSymbol) ) { + // Prefer [ViewPage("Component")] attribute over class name inference + string? page = null; + foreach (var attr in typeSymbol.GetAttributes()) + { + var attrName = attr.AttributeClass?.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ); + if ( + attrName + == "global::SimpleModule.Core.ViewPageAttribute" + && attr.ConstructorArguments.Length > 0 + && attr.ConstructorArguments[0].Value is string component + ) + { + page = component; + break; + } + } + + // Infer class name for deferred page name computation var className = typeSymbol.Name; if (className.EndsWith("Endpoint", StringComparison.Ordinal)) className = className.Substring( @@ -620,13 +685,17 @@ viewEndpointInterfaceSymbol is not null className.Length - "Endpoint".Length ); else if (className.EndsWith("View", StringComparison.Ordinal)) - className = className.Substring(0, className.Length - "View".Length); + className = className.Substring( + 0, + className.Length - "View".Length + ); views.Add( new ViewInfo { FullyQualifiedName = fqn, - Page = moduleName + "/" + className, + Page = page, + InferredClassName = className, } ); } diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index 3bcb4d74..a81ba663 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; using Microsoft.CodeAnalysis; namespace SimpleModule.Generator; @@ -81,7 +83,7 @@ internal sealed class DiagnosticEmitter : IEmitter internal static readonly DiagnosticDescriptor ContractInterfaceTooLargeWarning = new( id: "SM0012", title: "Contract interface has too many methods", - messageFormat: "Contract interface '{0}' has {1} methods, which exceeds the recommended maximum of 15. Large contract interfaces force consuming modules to depend on methods they don't use. Consider splitting into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Thresholds are configurable in .editorconfig: simplemodule.max_contract_methods_warn = 15, simplemodule.max_contract_methods_error = 20. Learn more: https://docs.simplemodule.dev/contract-design.", + messageFormat: "Contract interface '{0}' has {1} methods, which exceeds the recommended maximum of 15. Large contract interfaces force consuming modules to depend on methods they don't use. Consider splitting into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Warning threshold: 15 methods, error threshold: 20 methods. Learn more: https://docs.simplemodule.dev/contract-design.", category: "SimpleModule.Generator", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true @@ -90,7 +92,7 @@ internal sealed class DiagnosticEmitter : IEmitter internal static readonly DiagnosticDescriptor ContractInterfaceTooLargeError = new( id: "SM0013", title: "Contract interface must be split", - messageFormat: "Contract interface '{0}' has {1} methods and must be split before the project will compile. Interfaces with more than 20 methods are not allowed. Split into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Thresholds are configurable in .editorconfig: simplemodule.max_contract_methods_warn = 15, simplemodule.max_contract_methods_error = 20. Learn more: https://docs.simplemodule.dev/contract-design.", + messageFormat: "Contract interface '{0}' has {1} methods and must be split before the project will compile. Interfaces with more than 20 methods are not allowed. Split into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Warning threshold: 15 methods, error threshold: 20 methods. Learn more: https://docs.simplemodule.dev/contract-design.", category: "SimpleModule.Generator", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true @@ -249,6 +251,15 @@ internal sealed class DiagnosticEmitter : IEmitter isEnabledByDefault: true ); + internal static readonly DiagnosticDescriptor EmptyModuleWarning = new( + id: "SM0043", + title: "Module does not override any IModule methods", + messageFormat: "Module '{0}' implements IModule but does not override any configuration methods (ConfigureServices, ConfigureMenu, etc.). This module will be discovered but has no effect. If this is intentional, add at least ConfigureServices with a comment explaining why.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + public void Emit(SourceProductionContext context, DiscoveryData data) { // SM0002: Empty module name @@ -291,6 +302,33 @@ public void Emit(SourceProductionContext context, DiscoveryData data) } } + // SM0043: Empty module (no IModule methods overridden) + var moduleNamesWithDbContext = new HashSet( + data.DbContexts.Select(db => db.ModuleName), + StringComparer.Ordinal + ); + foreach (var module in data.Modules) + { + if (!module.HasConfigureServices + && !module.HasConfigureEndpoints + && !module.HasConfigureMenu + && !module.HasConfigurePermissions + && !module.HasConfigureMiddleware + && !module.HasConfigureSettings + && module.Endpoints.Length == 0 + && module.Views.Length == 0 + && !moduleNamesWithDbContext.Contains(module.ModuleName)) + { + context.ReportDiagnostic( + Diagnostic.Create( + EmptyModuleWarning, + Location.None, + module.ModuleName + ) + ); + } + } + // SM0004: DbContext with no DbSets — silently skipped. // Some DbContexts (e.g., OpenIddict) manage tables internally without public DbSet // properties. These are excluded from the unified HostDbContext but are not an error. @@ -776,6 +814,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { if (module.Views.Length > 0 && string.IsNullOrEmpty(module.ViewPrefix)) { +#pragma warning disable CA1308 // Route prefixes are conventionally lowercase context.ReportDiagnostic( Diagnostic.Create( ViewEndpointWithoutViewPrefix, @@ -785,6 +824,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) module.ModuleName.ToLowerInvariant() ) ); +#pragma warning restore CA1308 } } diff --git a/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs index c8d0a871..40dfe968 100644 --- a/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs @@ -20,6 +20,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) sb.AppendLine("using Microsoft.AspNetCore.Http.Json;"); sb.AppendLine("using Microsoft.Extensions.Configuration;"); sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); + sb.AppendLine("using Microsoft.Extensions.Hosting;"); sb.AppendLine("using Microsoft.AspNetCore.Authorization;"); sb.AppendLine("using SimpleModule.Core.Authorization;"); sb.AppendLine(); @@ -154,6 +155,19 @@ public void Emit(SourceProductionContext context, DiscoveryData data) sb.AppendLine(" });"); } + sb.AppendLine(); + sb.AppendLine(" // Register module instances for lifecycle management"); + foreach (var module in sortedModules) + { + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); + sb.AppendLine( + $" services.AddSingleton({fieldName});" + ); + } + sb.AppendLine( + " services.AddHostedService();" + ); + sb.AppendLine(); sb.AppendLine(" return services;"); sb.AppendLine(" }"); diff --git a/modules/Admin/src/SimpleModule.Admin/AdminModule.cs b/modules/Admin/src/SimpleModule.Admin/AdminModule.cs index cd5eadf7..79cadd4a 100644 --- a/modules/Admin/src/SimpleModule.Admin/AdminModule.cs +++ b/modules/Admin/src/SimpleModule.Admin/AdminModule.cs @@ -8,7 +8,7 @@ namespace SimpleModule.Admin; -[Module(AdminConstants.ModuleName)] +[Module(AdminConstants.ModuleName, ViewPrefix = "/admin")] public class AdminModule : IModule { public void ConfigureServices(IServiceCollection services, IConfiguration configuration) diff --git a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs index 7815eedf..d61a8a90 100644 --- a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs @@ -1,7 +1,6 @@ using System.Security.Claims; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using SimpleModule.Admin.Services; @@ -27,7 +26,7 @@ public void Map(IEndpointRouteBuilder app) [FromForm] string name, [FromForm] string? description, HttpContext context, - RoleManager roleManager, + IRoleAdminContracts roleAdmin, IPermissionContracts permissionContracts, AuditService audit ) => @@ -36,15 +35,8 @@ AuditService audit if (string.IsNullOrEmpty(trimmedName)) return TypedResults.Redirect("/admin/roles/create"); - var role = new ApplicationRole - { - Name = trimmedName, - Description = description?.Trim() is { Length: > 0 } d ? d : null, - }; - - var result = await roleManager.CreateAsync(role); - if (!result.Succeeded) - return TypedResults.Redirect("/admin/roles/create"); + var trimmedDescription = description?.Trim() is { Length: > 0 } d ? d : null; + var role = await roleAdmin.CreateRoleAsync(trimmedName, trimmedDescription); var form = await context.Request.ReadFormAsync(); var filteredPermissions = form["permissions"] @@ -60,7 +52,8 @@ await permissionContracts.SetPermissionsForRoleAsync( ); } - var adminUserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var adminUserId = + context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; await audit.LogAsync( role.Id, adminUserId, @@ -82,24 +75,29 @@ async Task ( [FromForm] string name, [FromForm] string? description, HttpContext context, - RoleManager roleManager, + IRoleAdminContracts roleAdmin, AuditService audit ) => { - var role = await roleManager.FindByIdAsync(id); + var role = await roleAdmin.GetRoleByIdAsync(id); if (role is null) return TypedResults.NotFound(); - role.Name = name.Trim(); - role.Description = description?.Trim() is { Length: > 0 } d ? d : null; - await roleManager.UpdateAsync(role); + var trimmedName = name.Trim(); + var trimmedDescription = description?.Trim() is { Length: > 0 } d ? d : null; + await roleAdmin.UpdateRoleAsync( + id, + trimmedName, + trimmedDescription + ); - var adminUserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var adminUserId = + context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; await audit.LogAsync( - role.Id, + id, adminUserId, "RoleUpdated", - $"Role '{role.Name}' updated" + $"Role '{trimmedName}' updated" ); return TypedResults.Redirect($"/admin/roles/{id}/edit?tab=details"); @@ -114,12 +112,12 @@ await audit.LogAsync( async Task ( string id, HttpContext context, - RoleManager roleManager, + IRoleAdminContracts roleAdmin, IPermissionContracts permissionContracts, AuditService audit ) => { - var role = await roleManager.FindByIdAsync(id); + var role = await roleAdmin.GetRoleByIdAsync(id); if (role is null) return TypedResults.NotFound(); @@ -134,13 +132,14 @@ AuditService audit roleId ); - var adminUserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var adminUserId = + context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; // Audit removed permissions foreach (var perm in currentPermissions.Where(p => !newPermissions.Contains(p))) { await audit.LogAsync( - role.Id, + id, adminUserId, "RolePermissionRemoved", $"Permission '{perm}' removed from role '{role.Name}'" @@ -148,10 +147,12 @@ await audit.LogAsync( } // Audit added permissions - foreach (var perm in newPermissions.Where(p => !currentPermissions.Contains(p))) + foreach ( + var perm in newPermissions.Where(p => !currentPermissions.Contains(p)) + ) { await audit.LogAsync( - role.Id, + id, adminUserId, "RolePermissionAdded", $"Permission '{perm}' added to role '{role.Name}'" @@ -171,34 +172,35 @@ await audit.LogAsync( async Task ( string id, HttpContext context, - RoleManager roleManager, - UserManager userManager, + IRoleAdminContracts roleAdmin, IPermissionContracts permissionContracts, AuditService audit ) => { - var role = await roleManager.FindByIdAsync(id); + var role = await roleAdmin.GetRoleByIdAsync(id); if (role is null) return TypedResults.NotFound(); - var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!); - if (usersInRole.Count > 0) + var hasUsers = await roleAdmin.HasUsersInRoleAsync(id); + if (hasUsers) return TypedResults.BadRequest( new { error = "Cannot delete a role that has users assigned to it." } ); // Remove role permissions first - await permissionContracts.SetPermissionsForRoleAsync(RoleId.From(id), []); + var roleId = RoleId.From(id); + await permissionContracts.SetPermissionsForRoleAsync(roleId, []); - var adminUserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var adminUserId = + context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; await audit.LogAsync( - role.Id, + id, adminUserId, "RoleDeleted", $"Role '{role.Name}' deleted" ); - await roleManager.DeleteAsync(role); + await roleAdmin.DeleteRoleAsync(id); return TypedResults.Redirect("/admin/roles"); } diff --git a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs index 8ba7b5d8..c05a5dd9 100644 --- a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs @@ -1,7 +1,6 @@ using System.Security.Claims; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using SimpleModule.Admin.Services; @@ -29,35 +28,28 @@ public void Map(IEndpointRouteBuilder app) [FromForm] string password, [FromForm] bool emailConfirmed, HttpContext context, - UserManager userManager, + IUserAdminContracts userAdmin, AuditService audit ) => { var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var user = new ApplicationUser - { - UserName = email, - Email = email, - DisplayName = displayName, - EmailConfirmed = emailConfirmed, - }; - - var result = await userManager.CreateAsync(user, password); - if (!result.Succeeded) - { - return TypedResults.Redirect("/admin/users?error=create-failed"); - } - var form = await context.Request.ReadFormAsync(); var filteredRoles = form["roles"] .Where(r => !string.IsNullOrEmpty(r)) .Select(r => r!) .ToList(); - if (filteredRoles.Count > 0) + + var request = new CreateAdminUserRequest { - await userManager.AddToRolesAsync(user, filteredRoles); - } + Email = email, + DisplayName = displayName, + Password = password, + EmailConfirmed = emailConfirmed, + Roles = filteredRoles, + }; + + var user = await userAdmin.CreateUserWithPasswordAsync(request); await audit.LogAsync(user.Id, adminId, "UserCreated", $"Created user {email}"); @@ -74,23 +66,20 @@ async Task ( [FromForm] string email, [FromForm] string? emailConfirmed, HttpContext context, - UserManager userManager, + IUserAdminContracts userAdmin, AuditService audit ) => { var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var user = await userManager.FindByIdAsync(id); - if (user is null) - { - return TypedResults.NotFound(); - } - user.DisplayName = displayName; - user.Email = email; - user.UserName = email; - user.EmailConfirmed = emailConfirmed is not null; + var request = new UpdateAdminUserRequest + { + DisplayName = displayName, + Email = email, + EmailConfirmed = emailConfirmed is not null, + }; - await userManager.UpdateAsync(user); + await userAdmin.UpdateUserDetailsAsync(UserId.From(id), request); await audit.LogAsync( id, adminId, @@ -108,44 +97,25 @@ await audit.LogAsync( async Task ( string id, HttpContext context, - UserManager userManager, + IUserAdminContracts userAdmin, AuditService audit ) => { var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var user = await userManager.FindByIdAsync(id); - if (user is null) - { - return TypedResults.NotFound(); - } var form = await context.Request.ReadFormAsync(); var newRoles = form["roles"] .Where(r => !string.IsNullOrEmpty(r)) .Select(r => r!) - .ToHashSet(); - var currentRoles = (await userManager.GetRolesAsync(user)).ToHashSet(); - - var toRemove = currentRoles.Except(newRoles).ToList(); - var toAdd = newRoles.Except(currentRoles).ToList(); - - if (toRemove.Count > 0) - { - await userManager.RemoveFromRolesAsync(user, toRemove); - foreach (var role in toRemove) - { - await audit.LogAsync(id, adminId, "RoleRemoved", $"Removed role {role}"); - } - } + .ToList(); - if (toAdd.Count > 0) - { - await userManager.AddToRolesAsync(user, toAdd); - foreach (var role in toAdd) - { - await audit.LogAsync(id, adminId, "RoleAdded", $"Added role {role}"); - } - } + await userAdmin.SetUserRolesAsync(UserId.From(id), newRoles); + await audit.LogAsync( + id, + adminId, + "RolesUpdated", + $"Set roles to [{string.Join(", ", newRoles)}]" + ); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=roles"); } @@ -207,19 +177,13 @@ async Task ( string id, [FromForm] string newPassword, HttpContext context, - UserManager userManager, + IUserAdminContracts userAdmin, AuditService audit ) => { var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var user = await userManager.FindByIdAsync(id); - if (user is null) - { - return TypedResults.NotFound(); - } - var token = await userManager.GeneratePasswordResetTokenAsync(user); - await userManager.ResetPasswordAsync(user, token, newPassword); + await userAdmin.ResetPasswordAsync(UserId.From(id), newPassword); await audit.LogAsync(id, adminId, "PasswordReset"); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security"); @@ -232,19 +196,13 @@ AuditService audit async Task ( string id, HttpContext context, - UserManager userManager, + IUserAdminContracts userAdmin, AuditService audit ) => { var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var user = await userManager.FindByIdAsync(id); - if (user is null) - { - return TypedResults.NotFound(); - } - await userManager.SetLockoutEnabledAsync(user, true); - await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)); + await userAdmin.LockAccountAsync(UserId.From(id)); await audit.LogAsync(id, adminId, "AccountLocked"); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security"); @@ -257,19 +215,13 @@ AuditService audit async Task ( string id, HttpContext context, - UserManager userManager, + IUserAdminContracts userAdmin, AuditService audit ) => { var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var user = await userManager.FindByIdAsync(id); - if (user is null) - { - return TypedResults.NotFound(); - } - await userManager.SetLockoutEndDateAsync(user, null); - await userManager.ResetAccessFailedCountAsync(user); + await userAdmin.UnlockAccountAsync(UserId.From(id)); await audit.LogAsync(id, adminId, "AccountUnlocked"); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security"); @@ -282,19 +234,13 @@ AuditService audit async Task ( string id, HttpContext context, - UserManager userManager, + IUserAdminContracts userAdmin, AuditService audit ) => { var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var user = await userManager.FindByIdAsync(id); - if (user is null) - { - return TypedResults.NotFound(); - } - user.EmailConfirmed = false; - await userManager.UpdateAsync(user); + await userAdmin.ForceEmailReverificationAsync(UserId.From(id)); await audit.LogAsync(id, adminId, "EmailReverified"); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security"); @@ -307,19 +253,13 @@ AuditService audit async Task ( string id, HttpContext context, - UserManager userManager, + IUserAdminContracts userAdmin, AuditService audit ) => { var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var user = await userManager.FindByIdAsync(id); - if (user is null) - { - return TypedResults.NotFound(); - } - await userManager.SetTwoFactorEnabledAsync(user, false); - await userManager.ResetAuthenticatorKeyAsync(user); + await userAdmin.DisableTwoFactorAsync(UserId.From(id)); await audit.LogAsync(id, adminId, "TwoFactorDisabled"); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security"); @@ -332,21 +272,13 @@ AuditService audit async Task ( string id, HttpContext context, - UserManager userManager, + IUserAdminContracts userAdmin, AuditService audit ) => { var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var user = await userManager.FindByIdAsync(id); - if (user is null) - { - return TypedResults.NotFound(); - } - user.DeactivatedAt = DateTimeOffset.UtcNow; - await userManager.UpdateAsync(user); - await userManager.SetLockoutEnabledAsync(user, true); - await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)); + await userAdmin.DeactivateAsync(UserId.From(id)); await audit.LogAsync(id, adminId, "UserDeactivated"); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=details"); @@ -359,21 +291,13 @@ AuditService audit async Task ( string id, HttpContext context, - UserManager userManager, + IUserAdminContracts userAdmin, AuditService audit ) => { var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var user = await userManager.FindByIdAsync(id); - if (user is null) - { - return TypedResults.NotFound(); - } - user.DeactivatedAt = null; - await userManager.UpdateAsync(user); - await userManager.SetLockoutEndDateAsync(user, null); - await userManager.ResetAccessFailedCountAsync(user); + await userAdmin.ReactivateAsync(UserId.From(id)); await audit.LogAsync(id, adminId, "UserReactivated"); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=details"); diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesCreateEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesCreateEndpoint.cs index f0ef1ef1..0e94e825 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesCreateEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesCreateEndpoint.cs @@ -6,12 +6,13 @@ namespace SimpleModule.Admin.Views.Admin; +[ViewPage("Admin/Admin/RolesCreate")] public class RolesCreateEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/admin/roles/create", + "/roles/create", (PermissionRegistry permissionRegistry) => { var permissionsByModule = permissionRegistry.ByModule.ToDictionary( diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEditEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEditEndpoint.cs index 57895540..655880eb 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEditEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEditEndpoint.cs @@ -1,8 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Routing; -using Microsoft.EntityFrameworkCore; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Inertia; @@ -11,35 +9,25 @@ namespace SimpleModule.Admin.Views.Admin; +[ViewPage("Admin/Admin/RolesEdit")] public class RolesEditEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/admin/roles/{id}/edit", + "/roles/{id}/edit", async ( string id, - RoleManager roleManager, - UserManager userManager, + IRoleAdminContracts roleAdmin, IPermissionContracts permissionContracts, PermissionRegistry permissionRegistry, string? tab ) => { - var role = await roleManager.FindByIdAsync(id); + var role = await roleAdmin.GetRoleByIdAsync(id); if (role is null) return TypedResults.NotFound(); - var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!); - var users = usersInRole - .Select(u => new - { - id = u.Id, - displayName = u.DisplayName, - email = u.Email, - }) - .ToList(); - var rolePermissions = ( await permissionContracts.GetPermissionsForRoleAsync(RoleId.From(id)) ).ToList(); @@ -53,14 +41,8 @@ await permissionContracts.GetPermissionsForRoleAsync(RoleId.From(id)) "Admin/Admin/RolesEdit", new { - role = new - { - id = role.Id, - name = role.Name, - description = role.Description, - createdAt = role.CreatedAt.ToString("O"), - }, - users, + role, + users = Array.Empty(), rolePermissions, permissionsByModule, tab = tab ?? "details", diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEndpoint.cs index c9894246..1d3d58d2 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEndpoint.cs @@ -1,7 +1,5 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Routing; -using Microsoft.EntityFrameworkCore; using SimpleModule.Core; using SimpleModule.Core.Inertia; using SimpleModule.Permissions.Contracts; @@ -9,38 +7,33 @@ namespace SimpleModule.Admin.Views.Admin; +[ViewPage("Admin/Admin/Roles")] public class RolesEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/admin/roles", - async ( - RoleManager roleManager, - UserManager userManager, - IPermissionContracts permissionContracts - ) => + "/roles", + async (IRoleAdminContracts roleAdmin, IPermissionContracts permissionContracts) => { - var roles = await roleManager.Roles.OrderBy(r => r.Name).ToListAsync(); + var roles = await roleAdmin.GetAllRolesAsync(); var roleList = new List(); foreach (var role in roles) { - var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!); var rolePermissions = await permissionContracts.GetPermissionsForRoleAsync( RoleId.From(role.Id) ); - var permissionCount = rolePermissions.Count; roleList.Add( new { - id = role.Id, - name = role.Name, - description = role.Description, - userCount = usersInRole.Count, - permissionCount, - createdAt = role.CreatedAt.ToString("O"), + role.Id, + role.Name, + role.Description, + role.UserCount, + permissionCount = rolePermissions.Count, + role.CreatedAt, } ); } diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersActivityEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersActivityEndpoint.cs index 42b6798d..90c27c4e 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersActivityEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersActivityEndpoint.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using SimpleModule.Core; @@ -19,7 +18,7 @@ public void Map(IEndpointRouteBuilder app) async ( string id, AdminDbContext adminDb, - UserManager userManager, + IUserAdminContracts userAdmin, int page = 1 ) => { @@ -39,9 +38,17 @@ public void Map(IEndpointRouteBuilder app) .ToListAsync(); var performerIds = entries.Select(e => e.PerformedByUserId).Distinct().ToList(); - var performers = await userManager - .Users.Where(u => performerIds.Contains(u.Id)) - .ToDictionaryAsync(u => u.Id, u => u.DisplayName); + var performers = new Dictionary(); + foreach (var performerId in performerIds) + { + var performer = await userAdmin.GetAdminUserByIdAsync( + UserId.From(performerId) + ); + if (performer is not null) + { + performers[performerId] = performer.DisplayName; + } + } var total = await adminDb.AuditLogEntries.CountAsync(e => e.UserId == id); diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersCreateEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersCreateEndpoint.cs index 859985a0..b7987622 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersCreateEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersCreateEndpoint.cs @@ -1,30 +1,21 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Routing; -using Microsoft.EntityFrameworkCore; using SimpleModule.Core; using SimpleModule.Core.Inertia; using SimpleModule.Users.Contracts; namespace SimpleModule.Admin.Views.Admin; +[ViewPage("Admin/Admin/UsersCreate")] public class UsersCreateEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/admin/users/create", - async (RoleManager roleManager) => + "/users/create", + async (IRoleAdminContracts roleAdmin) => { - var allRoles = await roleManager - .Roles.OrderBy(r => r.Name) - .Select(r => new - { - id = r.Id, - name = r.Name, - description = r.Description, - }) - .ToListAsync(); + var allRoles = await roleAdmin.GetAllRolesAsync(); return Inertia.Render("Admin/Admin/UsersCreate", new { allRoles }); } diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs index ab4f63a6..93f5a9fb 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using SimpleModule.Core; @@ -11,6 +10,7 @@ namespace SimpleModule.Admin.Views.Admin; +[ViewPage("Admin/Admin/UsersEdit")] public class UsersEditEndpoint : IViewEndpoint { private const int ActivityPageSize = 20; @@ -18,23 +18,22 @@ public class UsersEditEndpoint : IViewEndpoint public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/admin/users/{id}/edit", + "/users/{id}/edit", async ( string id, - UserManager userManager, - RoleManager roleManager, + IUserAdminContracts userAdmin, + IRoleAdminContracts roleAdmin, IPermissionContracts permissionContracts, AdminDbContext adminDb, PermissionRegistry permissionRegistry, string? tab ) => { - var user = await userManager.FindByIdAsync(id); + var user = await userAdmin.GetAdminUserByIdAsync(UserId.From(id)); if (user is null) return TypedResults.NotFound(); - var userRoles = await userManager.GetRolesAsync(user); - var allRoles = await roleManager.Roles.OrderBy(r => r.Name).ToListAsync(); + var allRoles = await roleAdmin.GetAllRolesAsync(); // User direct permissions var userPermissions = ( @@ -67,9 +66,17 @@ await permissionContracts.GetPermissionsForUserAsync(UserId.From(id)) .Select(e => e.PerformedByUserId) .Distinct() .ToList(); - var performers = await userManager - .Users.Where(u => performerIds.Contains(u.Id)) - .ToDictionaryAsync(u => u.Id, u => u.DisplayName); + var performers = new Dictionary(); + foreach (var performerId in performerIds) + { + var performer = await userAdmin.GetAdminUserByIdAsync( + UserId.From(performerId) + ); + if (performer is not null) + { + performers[performerId] = performer.DisplayName; + } + } var activityWithNames = activityLog.Select(e => new { @@ -88,30 +95,10 @@ await permissionContracts.GetPermissionsForUserAsync(UserId.From(id)) "Admin/Admin/UsersEdit", new { - user = new - { - id = user.Id, - displayName = user.DisplayName, - email = user.Email, - emailConfirmed = user.EmailConfirmed, - twoFactorEnabled = user.TwoFactorEnabled, - isLockedOut = user.LockoutEnd.HasValue - && user.LockoutEnd > DateTimeOffset.UtcNow, - isDeactivated = user.DeactivatedAt.HasValue, - accessFailedCount = user.AccessFailedCount, - createdAt = user.CreatedAt.ToString("O"), - lastLoginAt = user.LastLoginAt?.ToString("O"), - }, - userRoles = userRoles.ToList(), + user, + userRoles = user.Roles, userPermissions, - allRoles = allRoles - .Select(r => new - { - id = r.Id, - name = r.Name, - description = r.Description, - }) - .ToList(), + allRoles, permissionsByModule, activityLog = activityWithNames, activityTotal, diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs index 6f0442ba..b4450968 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs @@ -1,13 +1,12 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Routing; -using Microsoft.EntityFrameworkCore; using SimpleModule.Core; using SimpleModule.Core.Inertia; using SimpleModule.Users.Contracts; namespace SimpleModule.Admin.Views.Admin; +[ViewPage("Admin/Admin/Users")] public class UsersEndpoint : IViewEndpoint { private const int PageSize = 20; @@ -15,60 +14,21 @@ public class UsersEndpoint : IViewEndpoint public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/admin/users", - async (UserManager userManager, string? search, int page = 1) => + "/users", + async (IUserAdminContracts userAdmin, string? search, int page = 1) => { - var query = userManager.Users.AsQueryable(); - - if (!string.IsNullOrWhiteSpace(search)) - { - var pattern = $"%{search.Trim()}%"; - query = query.Where(u => - (u.Email != null && EF.Functions.Like(u.Email, pattern)) - || EF.Functions.Like(u.DisplayName, pattern) - || (u.UserName != null && EF.Functions.Like(u.UserName, pattern)) - ); - } - - var totalCount = await query.CountAsync(); - var totalPages = (int)Math.Ceiling((double)totalCount / PageSize); - page = Math.Clamp(page, 1, Math.Max(1, totalPages)); - - var users = await query - .OrderBy(u => u.DisplayName) - .Skip((page - 1) * PageSize) - .Take(PageSize) - .ToListAsync(); - - var userList = new List(); - foreach (var user in users) - { - var roles = await userManager.GetRolesAsync(user); - userList.Add( - new - { - id = user.Id, - displayName = user.DisplayName, - email = user.Email, - emailConfirmed = user.EmailConfirmed, - roles = roles.ToList(), - isLockedOut = user.LockoutEnd.HasValue - && user.LockoutEnd > DateTimeOffset.UtcNow, - isDeactivated = user.DeactivatedAt.HasValue, - createdAt = user.CreatedAt.ToString("O"), - } - ); - } + var result = await userAdmin.GetUsersPagedAsync(search, page, PageSize); + var totalPages = (int)Math.Ceiling((double)result.TotalCount / PageSize); return Inertia.Render( "Admin/Admin/Users", new { - users = userList, + users = result.Items, search = search ?? "", - page, + page = result.Page, totalPages, - totalCount, + totalCount = result.TotalCount, } ); } diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/BrowseEndpoint.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/BrowseEndpoint.cs index 98afadd6..2b524f1c 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/BrowseEndpoint.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/BrowseEndpoint.cs @@ -8,6 +8,7 @@ namespace SimpleModule.AuditLogs.Views; +[ViewPage("AuditLogs/Browse")] public class BrowseEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) => diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/DashboardEndpoint.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/DashboardEndpoint.cs index d6a992e2..598f5cfb 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/DashboardEndpoint.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/DashboardEndpoint.cs @@ -8,6 +8,7 @@ namespace SimpleModule.AuditLogs.Views; +[ViewPage("AuditLogs/Dashboard")] public class DashboardEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) => diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/DetailEndpoint.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/DetailEndpoint.cs index 3c4faa51..9b0843b5 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/DetailEndpoint.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Views/DetailEndpoint.cs @@ -8,6 +8,7 @@ namespace SimpleModule.AuditLogs.Views; +[ViewPage("AuditLogs/Detail")] public class DetailEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) => diff --git a/modules/Dashboard/src/SimpleModule.Dashboard/DashboardConstants.cs b/modules/Dashboard/src/SimpleModule.Dashboard.Contracts/DashboardConstants.cs similarity index 53% rename from modules/Dashboard/src/SimpleModule.Dashboard/DashboardConstants.cs rename to modules/Dashboard/src/SimpleModule.Dashboard.Contracts/DashboardConstants.cs index 04eead0c..4a595a91 100644 --- a/modules/Dashboard/src/SimpleModule.Dashboard/DashboardConstants.cs +++ b/modules/Dashboard/src/SimpleModule.Dashboard.Contracts/DashboardConstants.cs @@ -1,6 +1,6 @@ -namespace SimpleModule.Dashboard; +namespace SimpleModule.Dashboard.Contracts; -internal static class DashboardConstants +public static class DashboardConstants { public const string ModuleName = "Dashboard"; public const string RoutePrefix = ""; diff --git a/modules/Dashboard/src/SimpleModule.Dashboard.Contracts/IDashboardContracts.cs b/modules/Dashboard/src/SimpleModule.Dashboard.Contracts/IDashboardContracts.cs new file mode 100644 index 00000000..48bdc5b1 --- /dev/null +++ b/modules/Dashboard/src/SimpleModule.Dashboard.Contracts/IDashboardContracts.cs @@ -0,0 +1,12 @@ +using System.Diagnostics.CodeAnalysis; + +namespace SimpleModule.Dashboard.Contracts; + +/// +/// Contract interface for the Dashboard module. +/// Other modules can depend on this to contribute dashboard widgets or data. +/// +[SuppressMessage("Design", "CA1040:Avoid empty interfaces", Justification = "Contracts placeholder for future dashboard contributions")] +public interface IDashboardContracts +{ +} diff --git a/modules/Dashboard/src/SimpleModule.Dashboard.Contracts/SimpleModule.Dashboard.Contracts.csproj b/modules/Dashboard/src/SimpleModule.Dashboard.Contracts/SimpleModule.Dashboard.Contracts.csproj new file mode 100644 index 00000000..b33451b0 --- /dev/null +++ b/modules/Dashboard/src/SimpleModule.Dashboard.Contracts/SimpleModule.Dashboard.Contracts.csproj @@ -0,0 +1,9 @@ + + + net10.0 + Library + + + + + diff --git a/modules/Dashboard/src/SimpleModule.Dashboard/DashboardContractsService.cs b/modules/Dashboard/src/SimpleModule.Dashboard/DashboardContractsService.cs new file mode 100644 index 00000000..4e977ee2 --- /dev/null +++ b/modules/Dashboard/src/SimpleModule.Dashboard/DashboardContractsService.cs @@ -0,0 +1,7 @@ +using SimpleModule.Dashboard.Contracts; + +namespace SimpleModule.Dashboard; + +public sealed class DashboardContractsService : IDashboardContracts +{ +} diff --git a/modules/Dashboard/src/SimpleModule.Dashboard/DashboardModule.cs b/modules/Dashboard/src/SimpleModule.Dashboard/DashboardModule.cs index 17efc293..b7bc224e 100644 --- a/modules/Dashboard/src/SimpleModule.Dashboard/DashboardModule.cs +++ b/modules/Dashboard/src/SimpleModule.Dashboard/DashboardModule.cs @@ -2,13 +2,17 @@ using Microsoft.Extensions.DependencyInjection; using SimpleModule.Core; using SimpleModule.Core.Menu; +using SimpleModule.Dashboard.Contracts; namespace SimpleModule.Dashboard; -[Module(DashboardConstants.ModuleName, RoutePrefix = DashboardConstants.RoutePrefix)] +[Module(DashboardConstants.ModuleName, RoutePrefix = DashboardConstants.RoutePrefix, ViewPrefix = "/")] public class DashboardModule : IModule { - public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { } + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + } public void ConfigureMenu(IMenuBuilder menus) { diff --git a/modules/Dashboard/src/SimpleModule.Dashboard/SimpleModule.Dashboard.csproj b/modules/Dashboard/src/SimpleModule.Dashboard/SimpleModule.Dashboard.csproj index d38805b3..ee45fc7a 100644 --- a/modules/Dashboard/src/SimpleModule.Dashboard/SimpleModule.Dashboard.csproj +++ b/modules/Dashboard/src/SimpleModule.Dashboard/SimpleModule.Dashboard.csproj @@ -5,5 +5,6 @@ + diff --git a/modules/Dashboard/src/SimpleModule.Dashboard/Views/HomeEndpoint.cs b/modules/Dashboard/src/SimpleModule.Dashboard/Views/HomeEndpoint.cs index 1f4ab88a..0f2f1079 100644 --- a/modules/Dashboard/src/SimpleModule.Dashboard/Views/HomeEndpoint.cs +++ b/modules/Dashboard/src/SimpleModule.Dashboard/Views/HomeEndpoint.cs @@ -9,6 +9,7 @@ namespace SimpleModule.Dashboard.Views; +[ViewPage("Dashboard/Home")] public class HomeEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/Views/BrowseEndpoint.cs b/modules/FileStorage/src/SimpleModule.FileStorage/Views/BrowseEndpoint.cs index 54835d61..0f620f58 100644 --- a/modules/FileStorage/src/SimpleModule.FileStorage/Views/BrowseEndpoint.cs +++ b/modules/FileStorage/src/SimpleModule.FileStorage/Views/BrowseEndpoint.cs @@ -8,6 +8,7 @@ namespace SimpleModule.FileStorage.Views; +[ViewPage("FileStorage/Browse")] public class BrowseEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs index 932d650f..bb8ca116 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs @@ -15,7 +15,7 @@ namespace SimpleModule.OpenIddict; -[Module(OpenIddictModuleConstants.ModuleName)] +[Module(OpenIddictModuleConstants.ModuleName, ViewPrefix = "/openiddict")] public class OpenIddictModule : IModule { public void ConfigureServices(IServiceCollection services, IConfiguration configuration) diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Views/OpenIddict/ClientsCreateEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Views/OpenIddict/ClientsCreateEndpoint.cs index 2ea6c087..eae50da4 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Views/OpenIddict/ClientsCreateEndpoint.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Views/OpenIddict/ClientsCreateEndpoint.cs @@ -5,12 +5,13 @@ namespace SimpleModule.OpenIddict.Views.OpenIddict; +[ViewPage("OpenIddict/OpenIddict/ClientsCreate")] public class ClientsCreateEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/openiddict/clients/create", + "/clients/create", () => Inertia.Render("OpenIddict/OpenIddict/ClientsCreate", new { }) ) .RequireAuthorization(policy => policy.RequireRole("Admin")); diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Views/OpenIddict/ClientsEditEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Views/OpenIddict/ClientsEditEndpoint.cs index 1cb41b49..69e3d219 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Views/OpenIddict/ClientsEditEndpoint.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Views/OpenIddict/ClientsEditEndpoint.cs @@ -7,12 +7,13 @@ namespace SimpleModule.OpenIddict.Views.OpenIddict; +[ViewPage("OpenIddict/OpenIddict/ClientsEdit")] public class ClientsEditEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/openiddict/clients/{id}/edit", + "/clients/{id}/edit", async (string id, IOpenIddictApplicationManager manager, string? tab) => { var application = await manager.FindByIdAsync(id); diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Views/OpenIddict/ClientsEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Views/OpenIddict/ClientsEndpoint.cs index 2c6396d9..1b623555 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Views/OpenIddict/ClientsEndpoint.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Views/OpenIddict/ClientsEndpoint.cs @@ -6,12 +6,13 @@ namespace SimpleModule.OpenIddict.Views.OpenIddict; +[ViewPage("OpenIddict/OpenIddict/Clients")] public class ClientsEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/openiddict/clients", + "/clients", async (IOpenIddictApplicationManager manager) => { var clients = new List(); diff --git a/modules/Orders/src/SimpleModule.Orders.Contracts/CreateOrderRequest.cs b/modules/Orders/src/SimpleModule.Orders.Contracts/CreateOrderRequest.cs index ab070b55..911bbfd8 100644 --- a/modules/Orders/src/SimpleModule.Orders.Contracts/CreateOrderRequest.cs +++ b/modules/Orders/src/SimpleModule.Orders.Contracts/CreateOrderRequest.cs @@ -1,9 +1,7 @@ -using SimpleModule.Users.Contracts; - namespace SimpleModule.Orders.Contracts; public class CreateOrderRequest { - public UserId UserId { get; set; } + public string UserId { get; set; } = string.Empty; public List Items { get; set; } = new(); } diff --git a/modules/Orders/src/SimpleModule.Orders.Contracts/Events/OrderCreatedEvent.cs b/modules/Orders/src/SimpleModule.Orders.Contracts/Events/OrderCreatedEvent.cs index ef0fb96e..c671e6fd 100644 --- a/modules/Orders/src/SimpleModule.Orders.Contracts/Events/OrderCreatedEvent.cs +++ b/modules/Orders/src/SimpleModule.Orders.Contracts/Events/OrderCreatedEvent.cs @@ -1,6 +1,5 @@ using SimpleModule.Core.Events; -using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.Contracts.Events; -public sealed record OrderCreatedEvent(OrderId OrderId, UserId UserId, decimal Total) : IEvent; +public sealed record OrderCreatedEvent(OrderId OrderId, string UserId, decimal Total) : IEvent; diff --git a/modules/Orders/src/SimpleModule.Orders.Contracts/Order.cs b/modules/Orders/src/SimpleModule.Orders.Contracts/Order.cs index 1936f986..265d251d 100644 --- a/modules/Orders/src/SimpleModule.Orders.Contracts/Order.cs +++ b/modules/Orders/src/SimpleModule.Orders.Contracts/Order.cs @@ -1,11 +1,9 @@ -using SimpleModule.Users.Contracts; - namespace SimpleModule.Orders.Contracts; public class Order { public OrderId Id { get; set; } - public UserId UserId { get; set; } + public string UserId { get; set; } = string.Empty; public List Items { get; set; } = new(); public decimal Total { get; set; } public DateTime CreatedAt { get; set; } diff --git a/modules/Orders/src/SimpleModule.Orders.Contracts/OrderItem.cs b/modules/Orders/src/SimpleModule.Orders.Contracts/OrderItem.cs index 4ffde294..b5d46120 100644 --- a/modules/Orders/src/SimpleModule.Orders.Contracts/OrderItem.cs +++ b/modules/Orders/src/SimpleModule.Orders.Contracts/OrderItem.cs @@ -1,9 +1,7 @@ -using SimpleModule.Products.Contracts; - namespace SimpleModule.Orders.Contracts; public class OrderItem { - public ProductId ProductId { get; set; } + public int ProductId { get; set; } public int Quantity { get; set; } } diff --git a/modules/Orders/src/SimpleModule.Orders.Contracts/SimpleModule.Orders.Contracts.csproj b/modules/Orders/src/SimpleModule.Orders.Contracts/SimpleModule.Orders.Contracts.csproj index 44a73920..f539a38a 100644 --- a/modules/Orders/src/SimpleModule.Orders.Contracts/SimpleModule.Orders.Contracts.csproj +++ b/modules/Orders/src/SimpleModule.Orders.Contracts/SimpleModule.Orders.Contracts.csproj @@ -8,7 +8,5 @@ - - diff --git a/modules/Orders/src/SimpleModule.Orders.Contracts/UpdateOrderRequest.cs b/modules/Orders/src/SimpleModule.Orders.Contracts/UpdateOrderRequest.cs index f86aabb1..2a189e49 100644 --- a/modules/Orders/src/SimpleModule.Orders.Contracts/UpdateOrderRequest.cs +++ b/modules/Orders/src/SimpleModule.Orders.Contracts/UpdateOrderRequest.cs @@ -1,9 +1,7 @@ -using SimpleModule.Users.Contracts; - namespace SimpleModule.Orders.Contracts; public class UpdateOrderRequest { - public UserId UserId { get; set; } + public string UserId { get; set; } = string.Empty; public List Items { get; set; } = new(); } diff --git a/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/CreateRequestValidator.cs b/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/CreateRequestValidator.cs index b36bbca5..16617b96 100644 --- a/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/CreateRequestValidator.cs +++ b/modules/Orders/src/SimpleModule.Orders/Endpoints/Orders/CreateRequestValidator.cs @@ -15,7 +15,7 @@ public static ValidationResult Validate(CreateOrderRequest request) { var builder = new ValidationBuilder() .AddErrorIf( - string.IsNullOrWhiteSpace(request.UserId.Value), + string.IsNullOrWhiteSpace(request.UserId), OrdersConstants.Fields.UserId, OrdersConstants.ValidationMessages.UserIdRequired ) diff --git a/modules/Orders/src/SimpleModule.Orders/EntityConfigurations/OrderConfiguration.cs b/modules/Orders/src/SimpleModule.Orders/EntityConfigurations/OrderConfiguration.cs index 22c42f82..c3783993 100644 --- a/modules/Orders/src/SimpleModule.Orders/EntityConfigurations/OrderConfiguration.cs +++ b/modules/Orders/src/SimpleModule.Orders/EntityConfigurations/OrderConfiguration.cs @@ -2,8 +2,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using SimpleModule.Orders.Contracts; -using SimpleModule.Products.Contracts; -using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.EntityConfigurations; @@ -44,7 +42,7 @@ internal static List GenerateSeedOrderItems() new { OrderId = OrderId.From(i), - ProductId = ProductId.From(productId), + ProductId = productId, Quantity = quantity, } ); @@ -81,11 +79,9 @@ private static Order[] GenerateSeedOrders() new Order { Id = OrderId.From(i), - UserId = UserId.From( - orderFaker - .Random.Int(1, 10) - .ToString(System.Globalization.CultureInfo.InvariantCulture) - ), + UserId = orderFaker + .Random.Int(1, 10) + .ToString(System.Globalization.CultureInfo.InvariantCulture), Total = Math.Round(total, 2), CreatedAt = orderFaker.Date.Between( new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), diff --git a/modules/Orders/src/SimpleModule.Orders/OrderService.cs b/modules/Orders/src/SimpleModule.Orders/OrderService.cs index 343e68ef..a4644ac7 100644 --- a/modules/Orders/src/SimpleModule.Orders/OrderService.cs +++ b/modules/Orders/src/SimpleModule.Orders/OrderService.cs @@ -36,15 +36,16 @@ public async Task> GetAllOrdersAsync() => public async Task CreateOrderAsync(CreateOrderRequest request) { - var user = await users.GetUserByIdAsync(request.UserId); + var userId = UserId.From(request.UserId); + var user = await users.GetUserByIdAsync(userId); if (user is null) { throw new NotFoundException("User", request.UserId); } - var productIds = request.Items.Select(i => i.ProductId).Distinct(); + var productIds = request.Items.Select(i => ProductId.From(i.ProductId)).Distinct(); var productList = await products.GetProductsByIdsAsync(productIds); - var productMap = productList.ToDictionary(p => p.Id); + var productMap = productList.ToDictionary(p => p.Id.Value); decimal total = 0; foreach (var item in request.Items) @@ -83,15 +84,16 @@ public async Task UpdateOrderAsync(OrderId id, UpdateOrderRequest request throw new NotFoundException("Order", id); } - var user = await users.GetUserByIdAsync(request.UserId); + var userId = UserId.From(request.UserId); + var user = await users.GetUserByIdAsync(userId); if (user is null) { throw new NotFoundException("User", request.UserId); } - var productIds = request.Items.Select(i => i.ProductId).Distinct(); + var productIds = request.Items.Select(i => ProductId.From(i.ProductId)).Distinct(); var productList = await products.GetProductsByIdsAsync(productIds); - var productMap = productList.ToDictionary(p => p.Id); + var productMap = productList.ToDictionary(p => p.Id.Value); decimal total = 0; foreach (var item in request.Items) @@ -139,7 +141,7 @@ public async Task DeleteOrderAsync(OrderId id) private static partial void LogOrderCreated( ILogger logger, OrderId orderId, - UserId userId, + string userId, decimal total ); @@ -150,7 +152,7 @@ decimal total private static partial void LogOrderUpdated( ILogger logger, OrderId orderId, - UserId userId, + string userId, decimal total ); diff --git a/modules/Orders/src/SimpleModule.Orders/OrdersDbContext.cs b/modules/Orders/src/SimpleModule.Orders/OrdersDbContext.cs index 0046efd4..6fc3bb95 100644 --- a/modules/Orders/src/SimpleModule.Orders/OrdersDbContext.cs +++ b/modules/Orders/src/SimpleModule.Orders/OrdersDbContext.cs @@ -3,8 +3,6 @@ using SimpleModule.Database; using SimpleModule.Orders.Contracts; using SimpleModule.Orders.EntityConfigurations; -using SimpleModule.Products.Contracts; -using SimpleModule.Users.Contracts; namespace SimpleModule.Orders; @@ -28,11 +26,5 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura configurationBuilder .Properties() .HaveConversion(); - configurationBuilder - .Properties() - .HaveConversion(); - configurationBuilder - .Properties() - .HaveConversion(); } } diff --git a/modules/Orders/src/SimpleModule.Orders/Views/CreateEndpoint.cs b/modules/Orders/src/SimpleModule.Orders/Views/CreateEndpoint.cs index 2bcd4b6d..0fd7cf7d 100644 --- a/modules/Orders/src/SimpleModule.Orders/Views/CreateEndpoint.cs +++ b/modules/Orders/src/SimpleModule.Orders/Views/CreateEndpoint.cs @@ -5,10 +5,10 @@ using SimpleModule.Core.Inertia; using SimpleModule.Orders.Contracts; using SimpleModule.Products.Contracts; -using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.Views; +[ViewPage("Orders/Create")] public class CreateEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) @@ -28,11 +28,11 @@ public void Map(IEndpointRouteBuilder app) { var request = new CreateOrderRequest { - UserId = UserId.From(body.UserId), + UserId = body.UserId, Items = body .Items.Select(i => new OrderItem { - ProductId = ProductId.From(i.ProductId), + ProductId = i.ProductId, Quantity = i.Quantity, }) .ToList(), diff --git a/modules/Orders/src/SimpleModule.Orders/Views/EditEndpoint.cs b/modules/Orders/src/SimpleModule.Orders/Views/EditEndpoint.cs index 3acedf40..8aacd250 100644 --- a/modules/Orders/src/SimpleModule.Orders/Views/EditEndpoint.cs +++ b/modules/Orders/src/SimpleModule.Orders/Views/EditEndpoint.cs @@ -5,10 +5,10 @@ using SimpleModule.Core.Inertia; using SimpleModule.Orders.Contracts; using SimpleModule.Products.Contracts; -using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.Views; +[ViewPage("Orders/Edit")] public class EditEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) @@ -28,10 +28,10 @@ public void Map(IEndpointRouteBuilder app) order = new { id = order.Id.Value, - userId = order.UserId.Value, + userId = order.UserId, items = order.Items.Select(i => new { - productId = i.ProductId.Value, + productId = i.ProductId, quantity = i.Quantity, }), total = order.Total, @@ -49,11 +49,11 @@ public void Map(IEndpointRouteBuilder app) { var request = new UpdateOrderRequest { - UserId = UserId.From(body.UserId), + UserId = body.UserId, Items = body .Items.Select(i => new OrderItem { - ProductId = ProductId.From(i.ProductId), + ProductId = i.ProductId, Quantity = i.Quantity, }) .ToList(), diff --git a/modules/Orders/src/SimpleModule.Orders/Views/ListEndpoint.cs b/modules/Orders/src/SimpleModule.Orders/Views/ListEndpoint.cs index b5a3d71a..d53439b3 100644 --- a/modules/Orders/src/SimpleModule.Orders/Views/ListEndpoint.cs +++ b/modules/Orders/src/SimpleModule.Orders/Views/ListEndpoint.cs @@ -6,6 +6,7 @@ namespace SimpleModule.Orders.Views; +[ViewPage("Orders/List")] public class ListEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) diff --git a/modules/Orders/tests/SimpleModule.Orders.Tests/Integration/OrdersEndpointTests.cs b/modules/Orders/tests/SimpleModule.Orders.Tests/Integration/OrdersEndpointTests.cs index 20439fe0..85263f58 100644 --- a/modules/Orders/tests/SimpleModule.Orders.Tests/Integration/OrdersEndpointTests.cs +++ b/modules/Orders/tests/SimpleModule.Orders.Tests/Integration/OrdersEndpointTests.cs @@ -3,9 +3,7 @@ using FluentAssertions; using SimpleModule.Orders; using SimpleModule.Orders.Contracts; -using SimpleModule.Products.Contracts; using SimpleModule.Tests.Shared.Fixtures; -using SimpleModule.Users.Contracts; namespace Orders.Tests.Integration; @@ -45,8 +43,8 @@ public async Task CreateOrder_WithInvalidUserId_Returns404() var request = new CreateOrderRequest { - UserId = UserId.From("nonexistent-user-id"), - Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 2 }], + UserId = "nonexistent-user-id", + Items = [new OrderItem { ProductId = 1, Quantity = 2 }], }; var response = await client.PostAsJsonAsync("/api/orders", request); @@ -62,8 +60,8 @@ public async Task UpdateOrder_WithNonExistentId_Returns404() var request = new UpdateOrderRequest { - UserId = UserId.From("1"), - Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], + UserId = "1", + Items = [new OrderItem { ProductId = 1, Quantity = 1 }], }; var response = await client.PutAsJsonAsync("/api/orders/99999", request); diff --git a/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs b/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs index b502626d..a01a546d 100644 --- a/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs +++ b/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs @@ -1,8 +1,6 @@ using FluentAssertions; using SimpleModule.Orders.Contracts; using SimpleModule.Orders.Endpoints.Orders; -using SimpleModule.Products.Contracts; -using SimpleModule.Users.Contracts; namespace Orders.Tests.Unit; @@ -13,8 +11,8 @@ public void Validate_WithValidRequest_ReturnsSuccess() { var request = new CreateOrderRequest { - UserId = UserId.From("1"), - Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 2 }], + UserId = "1", + Items = [new OrderItem { ProductId = 1, Quantity = 2 }], }; var result = CreateRequestValidator.Validate(request); @@ -28,7 +26,7 @@ public void Validate_WithDefaultUserId_ReturnsError() { var request = new CreateOrderRequest { - Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], + Items = [new OrderItem { ProductId = 1, Quantity = 1 }], }; var result = CreateRequestValidator.Validate(request); @@ -40,7 +38,7 @@ public void Validate_WithDefaultUserId_ReturnsError() [Fact] public void Validate_WithEmptyItems_ReturnsError() { - var request = new CreateOrderRequest { UserId = UserId.From("1"), Items = [] }; + var request = new CreateOrderRequest { UserId = "1", Items = [] }; var result = CreateRequestValidator.Validate(request); @@ -53,8 +51,8 @@ public void Validate_WithZeroQuantity_ReturnsError() { var request = new CreateOrderRequest { - UserId = UserId.From("1"), - Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 0 }], + UserId = "1", + Items = [new OrderItem { ProductId = 1, Quantity = 0 }], }; var result = CreateRequestValidator.Validate(request); diff --git a/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/OrderServiceTests.cs b/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/OrderServiceTests.cs index 5e36dd78..e709fedb 100644 --- a/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/OrderServiceTests.cs +++ b/modules/Orders/tests/SimpleModule.Orders.Tests/Unit/OrderServiceTests.cs @@ -73,14 +73,14 @@ public async Task CreateOrderAsync_WithValidUserAndProduct_CalculatesCorrectTota var request = new CreateOrderRequest { - UserId = UserId.From("1"), - Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 3 }], + UserId = "1", + Items = [new OrderItem { ProductId = 1, Quantity = 3 }], }; var order = await _sut.CreateOrderAsync(request); order.Total.Should().Be(75.00m); - order.UserId.Should().Be(UserId.From("1")); + order.UserId.Should().Be("1"); order.Items.Should().HaveCount(1); } @@ -91,8 +91,8 @@ public async Task CreateOrderAsync_WithInvalidUser_ThrowsNotFoundException() var request = new CreateOrderRequest { - UserId = UserId.From("999"), - Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], + UserId = "999", + Items = [new OrderItem { ProductId = 1, Quantity = 1 }], }; var act = () => _sut.CreateOrderAsync(request); @@ -112,8 +112,8 @@ public async Task CreateOrderAsync_WithInvalidProduct_ThrowsNotFoundException() var request = new CreateOrderRequest { - UserId = UserId.From("1"), - Items = [new OrderItem { ProductId = ProductId.From(999), Quantity = 1 }], + UserId = "1", + Items = [new OrderItem { ProductId = 999, Quantity = 1 }], }; var act = () => _sut.CreateOrderAsync(request); @@ -153,8 +153,8 @@ public async Task GetOrderByIdAsync_ReturnsMatchingOrder() var request = new CreateOrderRequest { - UserId = UserId.From("1"), - Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], + UserId = "1", + Items = [new OrderItem { ProductId = 1, Quantity = 1 }], }; var created = await _sut.CreateOrderAsync(request); @@ -188,15 +188,15 @@ public async Task UpdateOrderAsync_WithValidData_UpdatesOrder() var createRequest = new CreateOrderRequest { - UserId = UserId.From("1"), - Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], + UserId = "1", + Items = [new OrderItem { ProductId = 1, Quantity = 1 }], }; var created = await _sut.CreateOrderAsync(createRequest); var updateRequest = new UpdateOrderRequest { - UserId = UserId.From("1"), - Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 5 }], + UserId = "1", + Items = [new OrderItem { ProductId = 1, Quantity = 5 }], }; var updated = await _sut.UpdateOrderAsync(created.Id, updateRequest); @@ -210,8 +210,8 @@ public async Task UpdateOrderAsync_WithNonExistentOrder_ThrowsNotFoundException( { var request = new UpdateOrderRequest { - UserId = UserId.From("1"), - Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], + UserId = "1", + Items = [new OrderItem { ProductId = 1, Quantity = 1 }], }; var act = () => _sut.UpdateOrderAsync(OrderId.From(99999), request); @@ -243,8 +243,8 @@ public async Task DeleteOrderAsync_WithExistingOrder_RemovesOrder() var createRequest = new CreateOrderRequest { - UserId = UserId.From("1"), - Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], + UserId = "1", + Items = [new OrderItem { ProductId = 1, Quantity = 1 }], }; var created = await _sut.CreateOrderAsync(createRequest); diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModule.cs b/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModule.cs index 7f66e6ee..832150e2 100644 --- a/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModule.cs +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModule.cs @@ -6,7 +6,7 @@ namespace SimpleModule.PageBuilder; -[Module(PageBuilderConstants.ModuleName, RoutePrefix = PageBuilderConstants.RoutePrefix)] +[Module(PageBuilderConstants.ModuleName, RoutePrefix = PageBuilderConstants.RoutePrefix, ViewPrefix = "/pages")] public class PageBuilderModule : IModule { public void ConfigureServices(IServiceCollection services, IConfiguration configuration) @@ -23,7 +23,7 @@ public void ConfigureMenu(IMenuBuilder menus) new MenuItem { Label = "Pages", - Url = "/admin/pages", + Url = "/pages/manage", Icon = """""", Order = 20, diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/Pages/index.ts b/modules/PageBuilder/src/SimpleModule.PageBuilder/Pages/index.ts index ff019059..d198fc5f 100644 --- a/modules/PageBuilder/src/SimpleModule.PageBuilder/Pages/index.ts +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/Pages/index.ts @@ -4,5 +4,6 @@ export const pages: Record = { 'PageBuilder/Manage': () => import('../Views/Manage'), 'PageBuilder/Editor': () => import('../Views/Editor'), 'PageBuilder/Viewer': () => import('../Views/Viewer'), + 'PageBuilder/ViewerDraft': () => import('../Views/Viewer'), 'PageBuilder/PagesList': () => import('../Views/PagesList'), }; diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/Editor.tsx b/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/Editor.tsx index ec6d5e6d..a58333fa 100644 --- a/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/Editor.tsx +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/Editor.tsx @@ -188,7 +188,7 @@ export default function Editor({ page, templates }: Props) { }); } - router.visit('/admin/pages'); + router.visit('/pages/manage'); }, [page], ); @@ -198,7 +198,7 @@ export default function Editor({ page, templates }: Props) { { - if (!open) router.visit('/admin/pages'); + if (!open) router.visit('/pages/manage'); }} > @@ -241,7 +241,7 @@ export default function Editor({ page, templates }: Props) { ))} - @@ -263,7 +263,7 @@ export default function Editor({ page, templates }: Props) { } + actions={} data={pages} emptyTitle="No pages yet" emptyDescription="Get started by creating your first content page." emptyAction={ - } @@ -106,7 +106,7 @@ export default function Manage({ pages }: Props) { {pageData.map((page) => ( {page.title} - /p/{page.slug} + /pages/view/{page.slug}
router.get(`/admin/pages/${page.id}/edit`)} + onClick={() => router.get(`/pages/${page.id}/edit`)} > Edit {page.isPublished && ( window.open(`/p/${page.slug}`, '_blank')} + onClick={() => window.open(`/pages/view/${page.slug}`, '_blank')} > View Page )} {page.hasDraft && ( window.open(`/p/${page.slug}/draft`, '_blank')} + onClick={() => window.open(`/pages/view/${page.slug}/draft`, '_blank')} > Preview Draft diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/ManageEndpoint.cs b/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/ManageEndpoint.cs index aa8bce0d..b02fcab0 100644 --- a/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/ManageEndpoint.cs +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/ManageEndpoint.cs @@ -6,12 +6,13 @@ namespace SimpleModule.PageBuilder.Views; +[ViewPage("PageBuilder/Manage")] public class ManageEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/admin/pages", + "/manage", async (IPageBuilderContracts pageBuilder) => Inertia.Render( "PageBuilder/Manage", diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/PagesList.tsx b/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/PagesList.tsx index 51cce757..1e42e688 100644 --- a/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/PagesList.tsx +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/PagesList.tsx @@ -33,7 +33,7 @@ export default function PagesList({ pages }: Props) { ) : (
{pages.map((page) => ( - + {page.title} diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/PagesListEndpoint.cs b/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/PagesListEndpoint.cs index e670ef1d..34e1d03c 100644 --- a/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/PagesListEndpoint.cs +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/PagesListEndpoint.cs @@ -7,12 +7,13 @@ namespace SimpleModule.PageBuilder.Views; +[ViewPage("PageBuilder/PagesList")] public class PagesListEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/pages", + "/", async (IPageBuilderContracts pageBuilder) => Inertia.Render( "PageBuilder/PagesList", diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/ViewerDraftEndpoint.cs b/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/ViewerDraftEndpoint.cs index eeafc732..380723ed 100644 --- a/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/ViewerDraftEndpoint.cs +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/ViewerDraftEndpoint.cs @@ -7,12 +7,13 @@ namespace SimpleModule.PageBuilder.Views; +[ViewPage("PageBuilder/ViewerDraft")] public class ViewerDraftEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/p/{slug}/draft", + "/view/{slug}/draft", async (string slug, IPageBuilderContracts pageBuilder) => { var page = await pageBuilder.GetPageBySlugAsync(slug); @@ -34,7 +35,7 @@ public void Map(IEndpointRouteBuilder app) }; return Inertia.Render( - "PageBuilder/Viewer", + "PageBuilder/ViewerDraft", new { page = viewerPage, isDraft = true } ); } diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/ViewerEndpoint.cs b/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/ViewerEndpoint.cs index bea68011..7a0503ae 100644 --- a/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/ViewerEndpoint.cs +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/ViewerEndpoint.cs @@ -8,12 +8,13 @@ namespace SimpleModule.PageBuilder.Views; +[ViewPage("PageBuilder/Viewer")] public class ViewerEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/p/{slug}", + "/view/{slug}", async (string slug, IPageBuilderContracts pageBuilder) => { var page = await pageBuilder.GetPageBySlugAsync(slug); diff --git a/modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageEndpointTests.cs b/modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageEndpointTests.cs index 578b837f..4f596333 100644 --- a/modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageEndpointTests.cs +++ b/modules/PageBuilder/tests/SimpleModule.PageBuilder.Tests/PageEndpointTests.cs @@ -96,7 +96,7 @@ public async Task ViewerEndpoint_PublishedPage_ReturnsOk() await client.PostAsync($"/api/pagebuilder/{created!.Id}/publish", null); var anonClient = _factory.CreateClient(); - var response = await anonClient.GetAsync("/p/viewer-test"); + var response = await anonClient.GetAsync("/pages/view/viewer-test"); response.StatusCode.Should().Be(HttpStatusCode.OK); } @@ -111,7 +111,7 @@ await client.PostAsJsonAsync( ); var anonClient = _factory.CreateClient(); - var response = await anonClient.GetAsync("/p/unpublished-page"); + var response = await anonClient.GetAsync("/pages/view/unpublished-page"); response.StatusCode.Should().Be(HttpStatusCode.NotFound); } diff --git a/modules/Products/src/SimpleModule.Products/Views/BrowseEndpoint.cs b/modules/Products/src/SimpleModule.Products/Views/BrowseEndpoint.cs index 1972f7b9..47c081bf 100644 --- a/modules/Products/src/SimpleModule.Products/Views/BrowseEndpoint.cs +++ b/modules/Products/src/SimpleModule.Products/Views/BrowseEndpoint.cs @@ -7,6 +7,7 @@ namespace SimpleModule.Products.Views; +[ViewPage("Products/Browse")] public class BrowseEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) diff --git a/modules/Products/src/SimpleModule.Products/Views/CreateEndpoint.cs b/modules/Products/src/SimpleModule.Products/Views/CreateEndpoint.cs index 4fcfcbb1..4af12c5d 100644 --- a/modules/Products/src/SimpleModule.Products/Views/CreateEndpoint.cs +++ b/modules/Products/src/SimpleModule.Products/Views/CreateEndpoint.cs @@ -8,6 +8,7 @@ namespace SimpleModule.Products.Views; +[ViewPage("Products/Create")] public class CreateEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) diff --git a/modules/Products/src/SimpleModule.Products/Views/EditEndpoint.cs b/modules/Products/src/SimpleModule.Products/Views/EditEndpoint.cs index 10f0d2f7..1960a450 100644 --- a/modules/Products/src/SimpleModule.Products/Views/EditEndpoint.cs +++ b/modules/Products/src/SimpleModule.Products/Views/EditEndpoint.cs @@ -8,6 +8,7 @@ namespace SimpleModule.Products.Views; +[ViewPage("Products/Edit")] public class EditEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) diff --git a/modules/Products/src/SimpleModule.Products/Views/ManageEndpoint.cs b/modules/Products/src/SimpleModule.Products/Views/ManageEndpoint.cs index 7291d73f..5f00bb1f 100644 --- a/modules/Products/src/SimpleModule.Products/Views/ManageEndpoint.cs +++ b/modules/Products/src/SimpleModule.Products/Views/ManageEndpoint.cs @@ -6,6 +6,7 @@ namespace SimpleModule.Products.Views; +[ViewPage("Products/Manage")] public class ManageEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) diff --git a/modules/Settings/src/SimpleModule.Settings/Views/AdminSettingsEndpoint.cs b/modules/Settings/src/SimpleModule.Settings/Views/AdminSettingsEndpoint.cs index 67f3f970..0629b3cc 100644 --- a/modules/Settings/src/SimpleModule.Settings/Views/AdminSettingsEndpoint.cs +++ b/modules/Settings/src/SimpleModule.Settings/Views/AdminSettingsEndpoint.cs @@ -8,6 +8,7 @@ namespace SimpleModule.Settings.Views; +[ViewPage("Settings/AdminSettings")] public class AdminSettingsEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) diff --git a/modules/Settings/src/SimpleModule.Settings/Views/MenuManagerEndpoint.cs b/modules/Settings/src/SimpleModule.Settings/Views/MenuManagerEndpoint.cs index b3606cdd..af499a3f 100644 --- a/modules/Settings/src/SimpleModule.Settings/Views/MenuManagerEndpoint.cs +++ b/modules/Settings/src/SimpleModule.Settings/Views/MenuManagerEndpoint.cs @@ -7,6 +7,7 @@ namespace SimpleModule.Settings.Views; +[ViewPage("Settings/MenuManager")] public class MenuManagerEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) diff --git a/modules/Settings/src/SimpleModule.Settings/Views/UserSettingsEndpoint.cs b/modules/Settings/src/SimpleModule.Settings/Views/UserSettingsEndpoint.cs index 427ffb6f..99ea4632 100644 --- a/modules/Settings/src/SimpleModule.Settings/Views/UserSettingsEndpoint.cs +++ b/modules/Settings/src/SimpleModule.Settings/Views/UserSettingsEndpoint.cs @@ -9,6 +9,7 @@ namespace SimpleModule.Settings.Views; +[ViewPage("Settings/UserSettings")] public class UserSettingsEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) diff --git a/modules/Users/src/SimpleModule.Users.Contracts/AdminUserDto.cs b/modules/Users/src/SimpleModule.Users.Contracts/AdminUserDto.cs new file mode 100644 index 00000000..74f74337 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users.Contracts/AdminUserDto.cs @@ -0,0 +1,16 @@ +using SimpleModule.Core; + +namespace SimpleModule.Users.Contracts; + +[Dto] +public class AdminUserDto +{ + public string Id { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string? Email { get; set; } + public bool EmailConfirmed { get; set; } + public List Roles { get; set; } = []; + public bool IsLockedOut { get; set; } + public bool IsDeactivated { get; set; } + public string CreatedAt { get; set; } = string.Empty; +} diff --git a/modules/Users/src/SimpleModule.Users.Contracts/CreateAdminUserRequest.cs b/modules/Users/src/SimpleModule.Users.Contracts/CreateAdminUserRequest.cs new file mode 100644 index 00000000..c1fe4940 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users.Contracts/CreateAdminUserRequest.cs @@ -0,0 +1,10 @@ +namespace SimpleModule.Users.Contracts; + +public class CreateAdminUserRequest +{ + public string Email { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public bool EmailConfirmed { get; set; } + public List Roles { get; set; } = []; +} diff --git a/modules/Users/src/SimpleModule.Users.Contracts/IRoleAdminContracts.cs b/modules/Users/src/SimpleModule.Users.Contracts/IRoleAdminContracts.cs new file mode 100644 index 00000000..83f9a517 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users.Contracts/IRoleAdminContracts.cs @@ -0,0 +1,11 @@ +namespace SimpleModule.Users.Contracts; + +public interface IRoleAdminContracts +{ + Task> GetAllRolesAsync(); + Task GetRoleByIdAsync(string id); + Task CreateRoleAsync(string name, string? description); + Task UpdateRoleAsync(string id, string name, string? description); + Task DeleteRoleAsync(string id); + Task HasUsersInRoleAsync(string id); +} diff --git a/modules/Users/src/SimpleModule.Users.Contracts/IUserAdminContracts.cs b/modules/Users/src/SimpleModule.Users.Contracts/IUserAdminContracts.cs new file mode 100644 index 00000000..d5cfd9d7 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users.Contracts/IUserAdminContracts.cs @@ -0,0 +1,19 @@ +using SimpleModule.Core; + +namespace SimpleModule.Users.Contracts; + +public interface IUserAdminContracts +{ + Task> GetUsersPagedAsync(string? search, int page, int pageSize); + Task GetAdminUserByIdAsync(UserId id); + Task CreateUserWithPasswordAsync(CreateAdminUserRequest request); + Task UpdateUserDetailsAsync(UserId id, UpdateAdminUserRequest request); + Task SetUserRolesAsync(UserId id, IEnumerable roles); + Task ResetPasswordAsync(UserId id, string newPassword); + Task LockAccountAsync(UserId id); + Task UnlockAccountAsync(UserId id); + Task DeactivateAsync(UserId id); + Task ReactivateAsync(UserId id); + Task ForceEmailReverificationAsync(UserId id); + Task DisableTwoFactorAsync(UserId id); +} diff --git a/modules/Users/src/SimpleModule.Users.Contracts/RoleDto.cs b/modules/Users/src/SimpleModule.Users.Contracts/RoleDto.cs new file mode 100644 index 00000000..7da1cb79 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users.Contracts/RoleDto.cs @@ -0,0 +1,13 @@ +using SimpleModule.Core; + +namespace SimpleModule.Users.Contracts; + +[Dto] +public class RoleDto +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public int UserCount { get; set; } + public string CreatedAt { get; set; } = string.Empty; +} diff --git a/modules/Users/src/SimpleModule.Users.Contracts/UpdateAdminUserRequest.cs b/modules/Users/src/SimpleModule.Users.Contracts/UpdateAdminUserRequest.cs new file mode 100644 index 00000000..9d5af882 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users.Contracts/UpdateAdminUserRequest.cs @@ -0,0 +1,8 @@ +namespace SimpleModule.Users.Contracts; + +public class UpdateAdminUserRequest +{ + public string DisplayName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public bool EmailConfirmed { get; set; } +} diff --git a/modules/Users/src/SimpleModule.Users/RoleAdminService.cs b/modules/Users/src/SimpleModule.Users/RoleAdminService.cs new file mode 100644 index 00000000..0e78a527 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/RoleAdminService.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core.Exceptions; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users; + +public sealed class RoleAdminService( + RoleManager roleManager, + UserManager userManager +) : IRoleAdminContracts +{ + public async Task> GetAllRolesAsync() + { + var roles = await roleManager.Roles.OrderBy(r => r.Name).ToListAsync(); + + var result = new List(roles.Count); + foreach (var role in roles) + { + var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!); + result.Add(MapToDto(role, usersInRole.Count)); + } + + return result; + } + + public async Task GetRoleByIdAsync(string id) + { + var role = await roleManager.FindByIdAsync(id); + if (role is null) + { + return null; + } + + var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!); + return MapToDto(role, usersInRole.Count); + } + + public async Task CreateRoleAsync(string name, string? description) + { + var trimmedName = name.Trim(); + if (string.IsNullOrEmpty(trimmedName)) + { + throw new ValidationException( + new Dictionary + { + ["Name"] = ["Role name is required."], + } + ); + } + + var role = new ApplicationRole + { + Name = trimmedName, + Description = description?.Trim() is { Length: > 0 } d ? d : null, + }; + + var result = await roleManager.CreateAsync(role); + if (!result.Succeeded) + { + var errors = result.Errors.ToDictionary(e => e.Code, e => new[] { e.Description }); + throw new ValidationException(errors); + } + + return MapToDto(role, 0); + } + + public async Task UpdateRoleAsync(string id, string name, string? description) + { + var role = await roleManager.FindByIdAsync(id) + ?? throw new NotFoundException("Role", id); + + role.Name = name.Trim(); + role.Description = description?.Trim() is { Length: > 0 } d ? d : null; + await roleManager.UpdateAsync(role); + } + + public async Task DeleteRoleAsync(string id) + { + var role = await roleManager.FindByIdAsync(id) + ?? throw new NotFoundException("Role", id); + + var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!); + if (usersInRole.Count > 0) + { + throw new ValidationException( + new Dictionary + { + ["Role"] = ["Cannot delete a role that has users assigned to it."], + } + ); + } + + await roleManager.DeleteAsync(role); + } + + public async Task HasUsersInRoleAsync(string id) + { + var role = await roleManager.FindByIdAsync(id) + ?? throw new NotFoundException("Role", id); + + var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!); + return usersInRole.Count > 0; + } + + private static RoleDto MapToDto(ApplicationRole role, int userCount) => + new() + { + Id = role.Id, + Name = role.Name ?? string.Empty, + Description = role.Description, + UserCount = userCount, + CreatedAt = role.CreatedAt.ToString("O"), + }; +} diff --git a/modules/Users/src/SimpleModule.Users/UserAdminService.cs b/modules/Users/src/SimpleModule.Users/UserAdminService.cs new file mode 100644 index 00000000..31391cbe --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/UserAdminService.cs @@ -0,0 +1,214 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core; +using SimpleModule.Core.Exceptions; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users; + +public sealed class UserAdminService( + UserManager userManager +) : IUserAdminContracts +{ + public async Task> GetUsersPagedAsync( + string? search, + int page, + int pageSize + ) + { + var query = userManager.Users.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var pattern = $"%{search.Trim()}%"; + query = query.Where(u => + (u.Email != null && EF.Functions.Like(u.Email, pattern)) + || EF.Functions.Like(u.DisplayName, pattern) + || (u.UserName != null && EF.Functions.Like(u.UserName, pattern)) + ); + } + + var totalCount = await query.CountAsync(); + var totalPages = (int)Math.Ceiling((double)totalCount / pageSize); + page = Math.Clamp(page, 1, Math.Max(1, totalPages)); + + var users = await query + .OrderBy(u => u.DisplayName) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var items = new List(users.Count); + foreach (var user in users) + { + var roles = await userManager.GetRolesAsync(user); + items.Add(MapToAdminDto(user, roles.ToList())); + } + + return new PagedResult + { + Items = items, + TotalCount = totalCount, + Page = page, + PageSize = pageSize, + }; + } + + public async Task GetAdminUserByIdAsync(UserId id) + { + var user = await userManager.FindByIdAsync(id.Value); + if (user is null) + { + return null; + } + + var roles = await userManager.GetRolesAsync(user); + return MapToAdminDto(user, roles.ToList()); + } + + public async Task CreateUserWithPasswordAsync(CreateAdminUserRequest request) + { + var user = new ApplicationUser + { + UserName = request.Email, + Email = request.Email, + DisplayName = request.DisplayName, + EmailConfirmed = request.EmailConfirmed, + }; + + var result = await userManager.CreateAsync(user, request.Password); + if (!result.Succeeded) + { + var errors = result.Errors.ToDictionary(e => e.Code, e => new[] { e.Description }); + throw new ValidationException(errors); + } + + if (request.Roles.Count > 0) + { + await userManager.AddToRolesAsync(user, request.Roles); + } + + var roles = await userManager.GetRolesAsync(user); + return MapToAdminDto(user, roles.ToList()); + } + + public async Task UpdateUserDetailsAsync(UserId id, UpdateAdminUserRequest request) + { + var user = await userManager.FindByIdAsync(id.Value) + ?? throw new NotFoundException("User", id); + + user.DisplayName = request.DisplayName; + user.Email = request.Email; + user.UserName = request.Email; + user.EmailConfirmed = request.EmailConfirmed; + + await userManager.UpdateAsync(user); + } + + public async Task SetUserRolesAsync(UserId id, IEnumerable roles) + { + var user = await userManager.FindByIdAsync(id.Value) + ?? throw new NotFoundException("User", id); + + var newRoles = roles.ToHashSet(); + var currentRoles = (await userManager.GetRolesAsync(user)).ToHashSet(); + + var toRemove = currentRoles.Except(newRoles).ToList(); + var toAdd = newRoles.Except(currentRoles).ToList(); + + if (toRemove.Count > 0) + { + await userManager.RemoveFromRolesAsync(user, toRemove); + } + + if (toAdd.Count > 0) + { + await userManager.AddToRolesAsync(user, toAdd); + } + } + + public async Task ResetPasswordAsync(UserId id, string newPassword) + { + var user = await userManager.FindByIdAsync(id.Value) + ?? throw new NotFoundException("User", id); + + var token = await userManager.GeneratePasswordResetTokenAsync(user); + var result = await userManager.ResetPasswordAsync(user, token, newPassword); + if (!result.Succeeded) + { + var errors = result.Errors.ToDictionary(e => e.Code, e => new[] { e.Description }); + throw new ValidationException(errors); + } + } + + public async Task LockAccountAsync(UserId id) + { + var user = await userManager.FindByIdAsync(id.Value) + ?? throw new NotFoundException("User", id); + + await userManager.SetLockoutEnabledAsync(user, true); + await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)); + } + + public async Task UnlockAccountAsync(UserId id) + { + var user = await userManager.FindByIdAsync(id.Value) + ?? throw new NotFoundException("User", id); + + await userManager.SetLockoutEndDateAsync(user, null); + await userManager.ResetAccessFailedCountAsync(user); + } + + public async Task DeactivateAsync(UserId id) + { + var user = await userManager.FindByIdAsync(id.Value) + ?? throw new NotFoundException("User", id); + + user.DeactivatedAt = DateTimeOffset.UtcNow; + await userManager.UpdateAsync(user); + await userManager.SetLockoutEnabledAsync(user, true); + await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)); + } + + public async Task ReactivateAsync(UserId id) + { + var user = await userManager.FindByIdAsync(id.Value) + ?? throw new NotFoundException("User", id); + + user.DeactivatedAt = null; + await userManager.UpdateAsync(user); + await userManager.SetLockoutEndDateAsync(user, null); + await userManager.ResetAccessFailedCountAsync(user); + } + + public async Task ForceEmailReverificationAsync(UserId id) + { + var user = await userManager.FindByIdAsync(id.Value) + ?? throw new NotFoundException("User", id); + + user.EmailConfirmed = false; + await userManager.UpdateAsync(user); + } + + public async Task DisableTwoFactorAsync(UserId id) + { + var user = await userManager.FindByIdAsync(id.Value) + ?? throw new NotFoundException("User", id); + + await userManager.SetTwoFactorEnabledAsync(user, false); + await userManager.ResetAuthenticatorKeyAsync(user); + } + + private static AdminUserDto MapToAdminDto(ApplicationUser user, List roles) => + new() + { + Id = user.Id, + DisplayName = user.DisplayName, + Email = user.Email, + EmailConfirmed = user.EmailConfirmed, + Roles = roles, + IsLockedOut = user.LockoutEnd.HasValue && user.LockoutEnd > DateTimeOffset.UtcNow, + IsDeactivated = user.DeactivatedAt.HasValue, + CreatedAt = user.CreatedAt.ToString("O"), + }; +} diff --git a/modules/Users/src/SimpleModule.Users/UsersModule.cs b/modules/Users/src/SimpleModule.Users/UsersModule.cs index 2ab386ed..ef57ab43 100644 --- a/modules/Users/src/SimpleModule.Users/UsersModule.cs +++ b/modules/Users/src/SimpleModule.Users/UsersModule.cs @@ -9,7 +9,7 @@ namespace SimpleModule.Users; -[Module(UsersConstants.ModuleName)] +[Module(UsersConstants.ModuleName, ViewPrefix = "/Identity/Account/Manage")] public class UsersModule : IModule { public void ConfigureServices(IServiceCollection services, IConfiguration configuration) diff --git a/modules/Users/src/SimpleModule.Users/Views/Account/Disable2faEndpoint.cs b/modules/Users/src/SimpleModule.Users/Views/Account/Disable2faEndpoint.cs index 3e5208d4..e432b51d 100644 --- a/modules/Users/src/SimpleModule.Users/Views/Account/Disable2faEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Views/Account/Disable2faEndpoint.cs @@ -9,12 +9,13 @@ namespace SimpleModule.Users.Views.Account; +[ViewPage("Users/Account/Disable2fa")] public class Disable2faEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/Identity/Account/Manage/Disable2fa", + "/Disable2fa", async (ClaimsPrincipal principal, UserManager userManager) => { var user = await userManager.GetUserAsync(principal); diff --git a/modules/Users/src/SimpleModule.Users/Views/Account/EnableAuthenticatorEndpoint.cs b/modules/Users/src/SimpleModule.Users/Views/Account/EnableAuthenticatorEndpoint.cs index df43a90b..00843402 100644 --- a/modules/Users/src/SimpleModule.Users/Views/Account/EnableAuthenticatorEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Views/Account/EnableAuthenticatorEndpoint.cs @@ -12,6 +12,7 @@ namespace SimpleModule.Users.Views.Account; +[ViewPage("Users/Account/EnableAuthenticator")] public class EnableAuthenticatorEndpoint : IViewEndpoint { private static readonly CompositeFormat AuthenticatorUriFormat = CompositeFormat.Parse( @@ -21,7 +22,7 @@ public class EnableAuthenticatorEndpoint : IViewEndpoint public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/Identity/Account/Manage/EnableAuthenticator", + "/EnableAuthenticator", async (ClaimsPrincipal principal, UserManager userManager) => { var user = await userManager.GetUserAsync(principal); diff --git a/modules/Users/src/SimpleModule.Users/Views/Account/GenerateRecoveryCodesEndpoint.cs b/modules/Users/src/SimpleModule.Users/Views/Account/GenerateRecoveryCodesEndpoint.cs index b27fa558..e9572e17 100644 --- a/modules/Users/src/SimpleModule.Users/Views/Account/GenerateRecoveryCodesEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Views/Account/GenerateRecoveryCodesEndpoint.cs @@ -9,12 +9,13 @@ namespace SimpleModule.Users.Views.Account; +[ViewPage("Users/Account/GenerateRecoveryCodes")] public class GenerateRecoveryCodesEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/Identity/Account/Manage/GenerateRecoveryCodes", + "/GenerateRecoveryCodes", async (ClaimsPrincipal principal, UserManager userManager) => { var user = await userManager.GetUserAsync(principal); diff --git a/modules/Users/src/SimpleModule.Users/Views/Account/ResetAuthenticatorEndpoint.cs b/modules/Users/src/SimpleModule.Users/Views/Account/ResetAuthenticatorEndpoint.cs index 2cff375b..686c2b2d 100644 --- a/modules/Users/src/SimpleModule.Users/Views/Account/ResetAuthenticatorEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Views/Account/ResetAuthenticatorEndpoint.cs @@ -9,12 +9,13 @@ namespace SimpleModule.Users.Views.Account; +[ViewPage("Users/Account/ResetAuthenticator")] public class ResetAuthenticatorEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/Identity/Account/Manage/ResetAuthenticator", + "/ResetAuthenticator", async (ClaimsPrincipal principal, UserManager userManager) => { var user = await userManager.GetUserAsync(principal); diff --git a/modules/Users/src/SimpleModule.Users/Views/Account/TwoFactorAuthenticationEndpoint.cs b/modules/Users/src/SimpleModule.Users/Views/Account/TwoFactorAuthenticationEndpoint.cs index c9e96e44..320762ff 100644 --- a/modules/Users/src/SimpleModule.Users/Views/Account/TwoFactorAuthenticationEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Views/Account/TwoFactorAuthenticationEndpoint.cs @@ -9,12 +9,13 @@ namespace SimpleModule.Users.Views.Account; +[ViewPage("Users/Account/TwoFactorAuthentication")] public class TwoFactorAuthenticationEndpoint : IViewEndpoint { public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/Identity/Account/Manage/TwoFactorAuthentication", + "/TwoFactorAuthentication", async ( ClaimsPrincipal principal, UserManager userManager, diff --git a/modules/Users/src/Users/types.ts b/modules/Users/src/Users/types.ts index 133573ae..827485b0 100644 --- a/modules/Users/src/Users/types.ts +++ b/modules/Users/src/Users/types.ts @@ -1,10 +1,55 @@ // Auto-generated from [Dto] types — do not edit +export interface AdminUserDto { + id: string; + displayName: string; + email: string; + emailConfirmed: boolean; + roles: string[]; + isLockedOut: boolean; + isDeactivated: boolean; + createdAt: string; +} + +export interface RoleDto { + id: string; + name: string; + description: string; + userCount: number; + createdAt: string; +} + +export interface ApplicationRole { + description: string; + createdAt: string; +} + +export interface ApplicationUser { + displayName: string; + createdAt: string; + lastLoginAt: any; + deactivatedAt: any; +} + +export interface CreateAdminUserRequest { + email: string; + displayName: string; + password: string; + emailConfirmed: boolean; + roles: string[]; +} + export interface CreateUserRequest { email: string; displayName: string; password: string; } +export interface UpdateAdminUserRequest { + displayName: string; + email: string; + emailConfirmed: boolean; +} + export interface UpdateUserRequest { email: string; displayName: string; diff --git a/tests/SimpleModule.Core.Tests/EventBusTests.cs b/tests/SimpleModule.Core.Tests/EventBusTests.cs index 248f3563..06370656 100644 --- a/tests/SimpleModule.Core.Tests/EventBusTests.cs +++ b/tests/SimpleModule.Core.Tests/EventBusTests.cs @@ -52,7 +52,7 @@ public async Task PublishAsync_WithRegisteredHandler_InvokesHandler() var services = new ServiceCollection(); services.AddSingleton>(handler); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); await bus.PublishAsync(new TestEvent("test")); @@ -68,7 +68,7 @@ public async Task PublishAsync_WithMultipleHandlers_InvokesAll() services.AddSingleton>(handler1); services.AddSingleton>(handler2); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); await bus.PublishAsync(new TestEvent("test")); @@ -81,7 +81,7 @@ public async Task PublishAsync_WithNoHandlers_DoesNotThrow() { var services = new ServiceCollection(); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); var act = () => bus.PublishAsync(new TestEvent("test")); @@ -96,7 +96,7 @@ public async Task PublishAsync_WhenHandlerThrows_OtherHandlersStillExecute() services.AddSingleton>(new ThrowingEventHandler()); services.AddSingleton>(successHandler); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); var act = () => bus.PublishAsync(new TestEvent("test")); @@ -111,7 +111,7 @@ public async Task PublishAsync_WhenHandlerThrows_ThrowsAggregateException() var services = new ServiceCollection(); services.AddSingleton>(new ThrowingEventHandler()); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); var act = () => bus.PublishAsync(new TestEvent("test")); @@ -127,7 +127,7 @@ public async Task PublishAsync_WhenMultipleHandlersThrow_AggregatesAllExceptions services.AddSingleton>(new ThrowingEventHandler()); services.AddSingleton>(new ThrowingEventHandler()); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); var act = () => bus.PublishAsync(new TestEvent("test")); @@ -144,7 +144,7 @@ public async Task PublishAsync_HandlersExecuteInRegistrationOrder() services.AddSingleton>(new OrderTrackingHandler(callOrder, 2)); services.AddSingleton>(new OrderTrackingHandler(callOrder, 3)); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); await bus.PublishAsync(new TestEvent("test")); @@ -166,7 +166,7 @@ public async Task PublishAsync_PassesCancellationToken_ToHandlers() ) ); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); using var cts = new CancellationTokenSource(); await bus.PublishAsync(new TestEvent("test"), cts.Token); diff --git a/tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.cs b/tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.cs index 3ac02024..dc6cab27 100644 --- a/tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.cs +++ b/tests/SimpleModule.Core.Tests/Events/EventBusPartialFailureTests.cs @@ -91,7 +91,7 @@ public async Task PublishAsync_WhenOneHandlerFails_OtherHandlersStillExecute() services.AddSingleton>(failingHandler); services.AddSingleton>(successHandler2); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); var testEvent = new TestEvent("test-value"); var act = async () => await bus.PublishAsync(testEvent); @@ -122,7 +122,7 @@ public async Task PublishAsync_WhenMultipleHandlersFail_AllExceptionsAreCollecte services.AddSingleton>(new FailingHandler(exception2)); services.AddSingleton>(new FailingHandler(exception3)); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); var act = async () => await bus.PublishAsync(new TestEvent("test")); @@ -155,7 +155,7 @@ public async Task PublishAsync_ExecutionOrder_IsDeterministic_RegistrationOrder( ); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); await bus.PublishAsync(new TestEvent("test")); @@ -188,7 +188,7 @@ public async Task PublishAsync_ExecutionOrder_PreservedEvenWhenSomeFail() ); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); var act = async () => await bus.PublishAsync(new TestEvent("test")); @@ -217,7 +217,7 @@ public async Task PublishAsync_SuccessfulHandlers_CompleteSideEffects_DespiteLat services.AddSingleton>(successHandler2); services.AddSingleton>(successHandler3); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); var act = async () => await bus.PublishAsync(new TestEvent("test")); @@ -237,7 +237,7 @@ public async Task PublishAsync_AggregateException_IncludesEventTypeName() var services = new ServiceCollection(); services.AddSingleton>(new FailingHandler()); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); var act = async () => await bus.PublishAsync(new TestEvent("test")); @@ -273,7 +273,7 @@ public async Task PublishAsync_CancellationToken_IsPropagatedToAllHandlers() services.AddSingleton>(handler1); services.AddSingleton>(handler2); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); using var cts = new CancellationTokenSource(); await bus.PublishAsync(new TestEvent("test"), cts.Token); @@ -301,7 +301,7 @@ public async Task PublishAsync_CancellationToken_PropagatedEvenWhenHandlersFail( services.AddSingleton>(handler); services.AddSingleton>(new FailingHandler()); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); using var cts = new CancellationTokenSource(); @@ -324,7 +324,7 @@ public async Task PublishAsync_WhenAllHandlersSucceed_DoesNotThrow() services.AddSingleton>(handler1); services.AddSingleton>(handler2); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); var act = async () => await bus.PublishAsync(new TestEvent("test")); @@ -341,7 +341,7 @@ public async Task PublishAsync_WhenNoHandlersRegistered_DoesNotThrow() { var services = new ServiceCollection(); var provider = services.BuildServiceProvider(); - var bus = new EventBus(provider, NullLogger.Instance); + var bus = new EventBus(provider, NullLogger.Instance, new BackgroundEventChannel(NullLogger.Instance)); var act = async () => await bus.PublishAsync(new TestEvent("test")); diff --git a/tests/SimpleModule.Generator.Tests/DiagnosticTests.cs b/tests/SimpleModule.Generator.Tests/DiagnosticTests.cs index 1f64cc75..1950a9f8 100644 --- a/tests/SimpleModule.Generator.Tests/DiagnosticTests.cs +++ b/tests/SimpleModule.Generator.Tests/DiagnosticTests.cs @@ -730,7 +730,10 @@ public ProductsDbContext(DbContextOptions options, IOptions OrderItemFaker { get; } = new Faker() - .RuleFor(oi => oi.ProductId, f => ProductId.From(f.Random.Int(1, 100))) + .RuleFor(oi => oi.ProductId, f => f.Random.Int(1, 100)) .RuleFor(oi => oi.Quantity, f => f.Random.Int(1, 10)); public static Faker OrderFaker { get; } = @@ -35,7 +35,7 @@ public static class FakeDataGenerators .RuleFor(o => o.Id, f => OrderId.From(f.IndexFaker + 1)) .RuleFor( o => o.UserId, - f => UserId.From(f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture)) + f => f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture) ) .RuleFor(o => o.Items, f => OrderItemFaker.Generate(f.Random.Int(1, 3))) .RuleFor(o => o.Total, f => f.Finance.Amount(10, 500)) @@ -45,7 +45,7 @@ public static class FakeDataGenerators new Faker() .RuleFor( r => r.UserId, - f => UserId.From(f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture)) + f => f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture) ) .RuleFor(r => r.Items, f => OrderItemFaker.Generate(f.Random.Int(1, 3))); @@ -53,7 +53,7 @@ public static class FakeDataGenerators new Faker() .RuleFor( r => r.UserId, - f => UserId.From(f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture)) + f => f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture) ) .RuleFor(r => r.Items, f => OrderItemFaker.Generate(f.Random.Int(1, 3))); diff --git a/tests/e2e/pages/pagebuilder/editor.page.ts b/tests/e2e/pages/pagebuilder/editor.page.ts index 52ac2914..4c7e634f 100644 --- a/tests/e2e/pages/pagebuilder/editor.page.ts +++ b/tests/e2e/pages/pagebuilder/editor.page.ts @@ -4,7 +4,7 @@ export class PageBuilderEditorPage { constructor(private page: Page) {} async gotoNew() { - await this.page.goto('/admin/pages/new'); + await this.page.goto('/pages/new'); // Wait for either the template picker dialog or the editor to appear await Promise.race([ this.page.getByRole('dialog').waitFor({ state: 'visible', timeout: 10000 }), @@ -19,7 +19,7 @@ export class PageBuilderEditorPage { } async gotoEdit(id: number) { - await this.page.goto(`/admin/pages/${id}/edit`); + await this.page.goto(`/pages/${id}/edit`); } get editorOverlay() { @@ -60,6 +60,6 @@ export class PageBuilderEditorPage { /** Click Puck's publish button and wait for navigation back to manage */ async publish() { await this.publishButton.click(); - await this.page.waitForURL('**/admin/pages', { timeout: 10000 }); + await this.page.waitForURL('**/pages/manage', { timeout: 10000 }); } } diff --git a/tests/e2e/pages/pagebuilder/manage.page.ts b/tests/e2e/pages/pagebuilder/manage.page.ts index 59547deb..361219e5 100644 --- a/tests/e2e/pages/pagebuilder/manage.page.ts +++ b/tests/e2e/pages/pagebuilder/manage.page.ts @@ -4,7 +4,7 @@ export class PageBuilderManagePage { constructor(private page: Page) {} async goto() { - await this.page.goto('/admin/pages'); + await this.page.goto('/pages/manage'); } async showAllRows() { diff --git a/tests/e2e/pages/pagebuilder/viewer.page.ts b/tests/e2e/pages/pagebuilder/viewer.page.ts index 4b79959b..9418a6d3 100644 --- a/tests/e2e/pages/pagebuilder/viewer.page.ts +++ b/tests/e2e/pages/pagebuilder/viewer.page.ts @@ -4,7 +4,7 @@ export class PageBuilderViewerPage { constructor(private page: Page) {} async goto(slug: string) { - await this.page.goto(`/p/${slug}`); + await this.page.goto(`/pages/view/${slug}`); } get content() { diff --git a/tests/e2e/tests/flows/pagebuilder-crud.spec.ts b/tests/e2e/tests/flows/pagebuilder-crud.spec.ts index f33b1d19..d48129ea 100644 --- a/tests/e2e/tests/flows/pagebuilder-crud.spec.ts +++ b/tests/e2e/tests/flows/pagebuilder-crud.spec.ts @@ -59,7 +59,7 @@ test.describe test('unpublished page returns 404 on public viewer', async ({ page }) => { // UI: navigate to the page's public URL - const response = await page.goto(`/p/${pageSlug}`); + const response = await page.goto(`/pages/view/${pageSlug}`); expect(response?.status()).toBe(404); }); @@ -187,7 +187,7 @@ test.describe('PageBuilder editor navigation', () => { // UI: click New Page await manage.newPageButton.click(); - await page.waitForURL('**/admin/pages/new'); + await page.waitForURL('**/pages/new'); // Dismiss template picker if it appears const blankBtn = page.getByRole('button', { name: /blank page/i }); @@ -200,7 +200,7 @@ test.describe('PageBuilder editor navigation', () => { // UI: click back button await page.getByRole('button', { name: /back to pages/i }).click(); - await page.waitForURL('**/admin/pages'); + await page.waitForURL('**/pages/manage'); }); test('edit navigates to editor for existing page', async ({ page, request }) => { @@ -211,7 +211,7 @@ test.describe('PageBuilder editor navigation', () => { const created = await createRes.json(); // UI: navigate to editor - await page.goto(`/admin/pages/${created.id}/edit`); + await page.goto(`/pages/${created.id}/edit`); await expect(page.getByTestId('puck-editor')).toBeVisible({ timeout: 10000 }); // Cleanup From 94b6d5fa6f3dd6f33da4a273da50fdff9bbd9d0a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 27 Mar 2026 20:29:14 +0100 Subject: [PATCH 10/14] =?UTF-8?q?Add=20Module=20Constitution=20=E2=80=94?= =?UTF-8?q?=20authoritative=20rules=20for=20module=20development=20and=20f?= =?UTF-8?q?ramework=20contribution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CONSTITUTION.md | 457 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 docs/CONSTITUTION.md diff --git a/docs/CONSTITUTION.md b/docs/CONSTITUTION.md new file mode 100644 index 00000000..40ab2eb4 --- /dev/null +++ b/docs/CONSTITUTION.md @@ -0,0 +1,457 @@ +# Module Constitution + +The authoritative rules for building modules on SimpleModule and contributing to the framework itself. This document governs architectural decisions, module boundaries, data ownership, communication patterns, and compiler-enforced constraints. + +**Audience:** Developers building modules and contributors to the framework (Core, Generator, Database). + +**Assumption:** This is a small team deploying everything as one unit. Modules exist for code clarity and ownership, not for independent deployment or microservice extraction. + +--- + +## 1. Founding Principles + +- **Small team, single deployment.** All modules deploy together. One migration history, one release, one rollback. +- **Shared database.** All modules share one database via the unified HostDbContext. Schema and table-prefix isolation is for cleanliness, not independence. This is a deliberate design choice that simplifies operations and enables cross-module queries when needed. +- **Modular for clarity, not for microservices.** Boundaries organize code, enforce ownership, and prevent spaghetti. They are not for independent scaling. The contracts pattern makes future extraction possible, but do not design for it now. +- **Compile-time safety over runtime discipline.** If a rule can be enforced by the source generator (SM diagnostics), it must be. Do not rely on code review for what the compiler can catch. +- **Convention over configuration.** Modules follow predictable patterns so the codebase reads like one person wrote it. + +--- + +## 2. Module Boundaries + +### What a Module Owns + +- Its **implementation assembly** (`SimpleModule.{Name}`) -- entities, services, DbContext, endpoints, views, event handlers +- Its **contracts assembly** (`SimpleModule.{Name}.Contracts`) -- the public API surface: contract interface, DTOs, request/response types, value objects, events, permissions +- Its **frontend assets** -- React components, Vite config, page registry +- Its **menu items** -- registered via `ConfigureMenu` + +### What a Module Exposes + +Only what is in Contracts. Everything else is internal. + +- One primary contract interface (`I{Name}Contracts`) +- DTOs and request types marked with `[Dto]` (auto-generates TypeScript types) +- Value object IDs via Vogen with EF Core converters (Vogen is an allowed dependency in Contracts projects) +- Events implementing `IEvent` +- Permission constants via `IModulePermissions` + +### Module Lifecycle Hooks + +All hooks are optional. All have default no-op implementations. + +1. **ConfigureServices** -- register DI services, DbContext, hosted services +2. **ConfigureEndpoints** -- escape hatch for non-standard routes (standard endpoints are auto-discovered) +3. **ConfigureMiddleware** -- register ASP.NET middleware +4. **ConfigureMenu** -- register navigation menu items +5. **ConfigurePermissions** -- register authorization permissions +6. **ConfigureSettings** -- register runtime-configurable settings +7. **OnStartAsync** -- one-time initialization after all services are registered +8. **OnStopAsync** -- graceful shutdown cleanup +9. **CheckHealthAsync** -- report module health status (Healthy, Degraded, Unhealthy) + +### What a Module Must Never Expose + +- Entity classes +- DbContext or DbSet types +- Internal services +- EF Core configurations + +### Structural Rules + +- Every module has both a Contracts project and an implementation project. +- The module class is decorated with `[Module]` and implements `IModule`. +- Module name, route prefix, and view prefix should be defined in a constants class (convention, not enforced by diagnostic). +- One module, one DbContext (if data is needed). +- SM0043 warns if a module class overrides nothing. + +--- + +## 3. Dependencies + +### Allowed + +- Module implementation --> own Contracts project +- Module implementation --> other modules' Contracts projects (never their implementations) +- Module implementation --> framework projects (Core, Database) +- Contracts --> Core only (plus Vogen for value objects) + +### Forbidden + +- Module --> another module's implementation (SM0011, error) +- Circular contract references (SM0010, error) +- Contracts --> Database or any framework project beyond Core + +### Dependency Direction Principle + +Dependencies flow one way: **implementation --> contracts --> core**. Never sideways (implementation --> implementation). Never backwards (contracts --> implementation). + +### Resolving Circular Dependencies + +- Use the event bus: module B publishes an event, module A handles it. No reference needed. +- Extract shared concepts into a third Contracts project, or rethink ownership. + +### DI Injection Rules + +- Inject contract interfaces, never concrete services. +- Framework services (`IEventBus`, `ISettingsContracts`) are injected directly. +- DbContext is injected only within the owning module's implementation. + +--- + +## 4. Data Ownership + +### Each Module Owns Its Data Exclusively + +- Entities live in the implementation assembly, never in Contracts. +- Only the owning module's service layer may read or write its entities. +- Other modules access data through the contract interface. + +### DbContext Rules + +- Register with `AddModuleDbContext(configuration, ModuleName)`. +- Call `ApplyModuleSchema()` in `OnModelCreating`. +- Use entity configurations via `IEntityTypeConfiguration`. +- Register Vogen value object converters in `ConfigureConventions`. +- One DbContext per module, maximum. + +### Entity Configuration + +- One configuration per entity (SM0007 prevents duplicates). +- No entity in two modules' DbSets with different types (SM0001). +- Orphaned configurations warned (SM0006). + +### Schema Isolation + +Schema isolation is automatic and provider-dependent: + +- **PostgreSQL / SQL Server:** schema per module (lowercase name) +- **SQLite:** table prefix per module + +This is cosmetic organization -- all modules share one connection. + +### Forbidden + +- Injecting HostDbContext in module code +- Raw SQL referencing another module's tables +- Foreign keys across module boundaries -- use IDs and validate via contracts +- Sharing entity types between modules -- use DTOs in Contracts for shared shapes + +--- + +## 5. Communication + +### Contract Interfaces + +- One primary interface per module: `I{Name}Contracts` +- The only way other modules interact with your data or behavior +- SM0012 warns at 15+ methods, SM0013 errors at 20+ +- Exactly one implementation required (SM0025/SM0026), public and non-abstract (SM0028/SM0029) + +### Events + +- Cross-module notifications use `IEventBus.PublishAsync()` +- Events are defined in the publishing module's Contracts project +- Any module can handle any event via `IEventHandler` +- Handlers currently execute sequentially in registration order; all run even if some fail. Design handlers as order-independent for forward compatibility. +- Failures are collected into `AggregateException` +- Handlers should be stateless, independent, and idempotent + +### PublishAsync vs PublishInBackground + +- **`PublishAsync()`** -- synchronous dispatch. The caller blocks until all handlers complete. Exceptions propagate to the caller. +- **`PublishInBackground()`** -- fire-and-forget. Failures are logged, not thrown. Use for notifications where the caller must not block or fail (audit logging, cache invalidation). + +### When to Use Which Communication Pattern + +- **Contracts** -- caller needs a response +- **Events** -- caller does not care who listens + +### Forbidden + +- Direct service injection across modules (only contract interfaces) +- Shared mutable state +- Database-level integration (triggers, views, cross-module queries) + +--- + +## 6. Endpoints + +### Two Types + +- **`IEndpoint`** -- API endpoints returning JSON +- **`IViewEndpoint`** -- view endpoints returning `Inertia.Render()` + +### Routing + +- API endpoints live under `RoutePrefix`, view endpoints under `ViewPrefix`. +- View page names must match the module name prefix (SM0041). +- No duplicate view page names (SM0015). +- Modules with view endpoints must define `ViewPrefix` (SM0042). + +### REST Conventions + +- **GET** for reads +- **POST** for creates +- **PUT** for updates +- **DELETE** for deletes +- Use `CrudEndpoints` helpers for consistent status codes: 200 (OK), 201 (Created), 204 (No Content), 404 (Not Found) +- For non-standard responses, write custom handlers. + +### Parameter Binding + +- Implicit binding by default -- do not manually read request body or form data for scalars. +- `[FromForm]` is required for form submissions. +- `ReadFormAsync()` only for multi-value form fields (arrays from repeated keys). +- `[FromServices]` is unnecessary noise; DI services are injected automatically. + +### Authorization + +- `.RequirePermission()` with permission constants. +- `.AllowAnonymous()` for public endpoints. +- Default is authorized. + +### Escape Hatch + +Override `ConfigureEndpoints` on the module class for non-standard routes. + +--- + +## 7. Frontend + +### Page Registry + +- Every `IViewEndpoint` must have a matching entry in `Pages/index.ts`. +- The key must match the component name passed to `Inertia.Render()`. +- Missing entries cause silent client-side 404s with no error in the console. +- Run `npm run validate-pages` to verify all endpoints have matching page entries. + +### Dynamic Imports + +- **Within a module build:** Dynamic imports follow Rollup's default behavior (code splitting is supported within the module's bundle). +- **Between modules:** ClientApp lazy-loads each module's `pages.js` bundle at runtime. This is how the system works -- each module is a separate entry point. + +### Build System + +- Each module has a `vite.config.ts` using `defineModuleConfig()`. +- Output: `{ModuleName}.pages.js` in `wwwroot/`. +- Library mode -- React, React-DOM, and @inertiajs/react are externalized. + +### TypeScript Types + +- `[Dto]` types auto-generate TypeScript interfaces via the source generator. +- Do not hand-write types that mirror DTOs. + +### Package Structure + +- React and Inertia declared as `peerDependencies`. +- Use `@simplemodule/ui` for shared components. +- Use `@simplemodule/theme-default` for styling. + +### Linting and Formatting + +- Biome configured at repo root. +- Single quotes, semicolons, 2-space indent, trailing commas, 100-char line width. +- `npm run check` to verify, `npm run check:fix` to auto-fix. + +--- + +## 8. Permissions & Authorization + +### Defining Permissions + +- Sealed class implementing `IModulePermissions` (SM0032 enforces sealed). +- `public const string` fields only (SM0027). +- Values follow the pattern `{ModuleName}.{Action}` (SM0031, SM0034). +- No duplicate values (SM0033). + +### Registering Permissions + +- Override `ConfigurePermissions` on the module class and call `builder.AddPermissions()`. + +### Applying Permissions + +- `.RequirePermission()` on endpoints. +- `.AllowAnonymous()` for public endpoints. +- Never hardcode permission strings -- always use the permission constants. + +### Rules + +- Permissions are owned by the defining module. +- Other modules may reference permission constants from Contracts. +- Not every module needs permissions. + +--- + +## 9. Settings & Configuration + +### Defining Settings + +Override `ConfigureSettings` on the module class. + +### Scopes + +| Scope | Purpose | +|-------|---------| +| **System** | Global settings that affect infrastructure (database, caching, external service configuration) | +| **Application** | Application-wide preferences visible to all users (site title, default language) | +| **User** | Per-user preferences (theme, notification settings) | + +### Types + +Text, Number, Bool, Json. + +### Rules + +- Namespace setting keys to the module. +- Settings are only for values configurable at runtime. +- For per-environment values, use `IConfiguration` and `appsettings.json`. +- Access other modules' settings through `ISettingsContracts`. + +--- + +## 10. Testing + +### Stack + +xUnit.v3, FluentAssertions, Bogus, `SimpleModuleWebApplicationFactory`. + +### Structure + +- Test project per module at `modules/{Name}/tests/{Name}.Tests/`. +- Underscore method naming: `Method_Scenario_Expected`. + +### Integration Tests + +- `SimpleModuleWebApplicationFactory` provides in-memory SQLite and a test auth scheme. +- `CreateAuthenticatedClient(params Claim[] claims)` for authenticated requests. + +### Fake Data + +- `FakeDataGenerators` provides pre-built Bogus fakers for all module DTOs and request types. + +### Database + +- SQLite in-memory locally, PostgreSQL in CI. +- Both providers tested in CI. + +### Rules + +- Test through public API (endpoints and contract interfaces). +- Do not mock the database. +- Test cross-module interactions through contracts. +- Every module should have tests. + +--- + +## 11. Compiler-Enforced Rules + +All SM diagnostics are emitted by the Roslyn source generator at compile time. `TreatWarningsAsErrors` is enabled globally, so warnings are effectively errors unless suppressed in `.editorconfig`. + +### Database & Entities + +| Diagnostic | Severity | Rule | +|------------|----------|------| +| SM0001 | Error | No duplicate DbSet property names across modules | +| SM0003 | Error | Only one IdentityDbContext allowed | +| SM0005 | Error | IdentityDbContext must use three type arguments | +| SM0006 | Warning | Orphaned entity configuration (not referenced by any DbSet) | +| SM0007 | Error | No duplicate entity configurations | + +### Module Structure + +| Diagnostic | Severity | Rule | +|------------|----------|------| +| SM0002 | Warning | Module name must not be empty | +| SM0040 | Error | No duplicate module names | +| SM0043 | Warning | Module must override at least one IModule method | + +### Dependencies + +| Diagnostic | Severity | Rule | +|------------|----------|------| +| SM0010 | Error | No circular module dependencies | +| SM0011 | Error | No direct module-to-module implementation references | + +### Contracts + +| Diagnostic | Severity | Rule | +|------------|----------|------| +| SM0012 | Warning | Contract interface has 15+ methods (consider splitting) | +| SM0013 | Error | Contract interface has 20+ methods (must split) | +| SM0014 | Error | Contracts assembly has no public interfaces | +| SM0025 | Error | No implementation found for contract interface | +| SM0026 | Error | Multiple implementations of contract interface | +| SM0028 | Error | Contract implementation must be public | +| SM0029 | Error | Contract implementation must not be abstract | +| SM0035 | Warning | DTO with no public properties | +| SM0038 | Warning | Infrastructure type found in Contracts assembly | + +### Permissions + +| Diagnostic | Severity | Rule | +|------------|----------|------| +| SM0027 | Error | Permission field must be const string | +| SM0031 | Warning | Permission value does not follow naming pattern | +| SM0032 | Error | Permission class must be sealed | +| SM0033 | Error | No duplicate permission values | +| SM0034 | Warning | Permission value prefix must match module name | + +### View Endpoints + +| Diagnostic | Severity | Rule | +|------------|----------|------| +| SM0015 | Error | No duplicate view page names | +| SM0041 | Warning | View page name must be prefixed with module name | +| SM0042 | Error | ViewPrefix required when module has view endpoints | + +### Interceptors + +| Diagnostic | Severity | Rule | +|------------|----------|------| +| SM0039 | Warning | Interceptor has transitive DbContext dependency (resolve at interception time) | + +--- + +## 12. Framework Contributor Guidelines + +### Source Generator + +- Target netstandard2.0 with `IIncrementalGenerator`. +- Use incremental pipeline for caching. +- New diagnostics use the next available SM number after the highest existing one. +- All diagnostics must have tests. + +### Adding a Diagnostic + +1. Define the descriptor in `DiagnosticEmitter.cs`. +2. Add detection logic in discovery or emission. +3. Add positive and negative test cases. +4. Document in this Constitution, Section 11. +5. Use Error severity for runtime breakage, Warning for code smells. + +### HostDbContext Generation + +- The generator merges all module DbSets into one context. +- Entity-to-module mapping drives schema isolation. +- Test against all modules before merging changes. + +### Database Framework + +- `ModuleDbContextOptionsBuilder` handles provider detection and routing. +- Interceptors are resolved lazily to avoid circular DI. +- `ApplyModuleSchema` must handle PostgreSQL, SQL Server, and SQLite. + +### Core Framework + +- All `IModule` methods must have default implementations. +- New lifecycle hooks require a default no-op. +- Keep Contracts dependencies minimal. + +### Build and CI + +- `TreatWarningsAsErrors` is enabled globally via `Directory.Build.props`. +- `AnalysisLevel=latest-all`, `AnalysisMode=All`. +- Suppressed rules live in `.editorconfig`. +- Tests run against both SQLite and PostgreSQL in CI. From 81fd433dedc84a7a8400ccdb7c9920e3198f4ab1 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 27 Mar 2026 20:33:00 +0100 Subject: [PATCH 11/14] Address quality review feedback on Constitution document - Differentiate SM0031 vs SM0034 diagnostic descriptions - Add PublishInBackground shutdown/cancellation behavior note - Expand Settings Types to a proper table with descriptions - Clarify permission constants assembly placement - Improve SM0043 description accuracy --- docs/CONSTITUTION.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/CONSTITUTION.md b/docs/CONSTITUTION.md index 40ab2eb4..b87dcaad 100644 --- a/docs/CONSTITUTION.md +++ b/docs/CONSTITUTION.md @@ -35,7 +35,7 @@ Only what is in Contracts. Everything else is internal. - DTOs and request types marked with `[Dto]` (auto-generates TypeScript types) - Value object IDs via Vogen with EF Core converters (Vogen is an allowed dependency in Contracts projects) - Events implementing `IEvent` -- Permission constants via `IModulePermissions` +- Permission constants via `IModulePermissions` (place in Contracts if other modules need to reference them) ### Module Lifecycle Hooks @@ -64,7 +64,7 @@ All hooks are optional. All have default no-op implementations. - The module class is decorated with `[Module]` and implements `IModule`. - Module name, route prefix, and view prefix should be defined in a constants class (convention, not enforced by diagnostic). - One module, one DbContext (if data is needed). -- SM0043 warns if a module class overrides nothing. +- SM0043 warns if a module class overrides no lifecycle hooks (indicating a likely empty or placeholder module). --- @@ -161,7 +161,7 @@ This is cosmetic organization -- all modules share one connection. ### PublishAsync vs PublishInBackground - **`PublishAsync()`** -- synchronous dispatch. The caller blocks until all handlers complete. Exceptions propagate to the caller. -- **`PublishInBackground()`** -- fire-and-forget. Failures are logged, not thrown. Use for notifications where the caller must not block or fail (audit logging, cache invalidation). +- **`PublishInBackground()`** -- fire-and-forget. Failures are logged, not thrown. No cancellation token is accepted, so callers cannot cancel in-flight background work, and handlers may be interrupted on host shutdown. Use for notifications where the caller must not block or fail (audit logging, cache invalidation). ### When to Use Which Communication Pattern @@ -300,7 +300,12 @@ Override `ConfigureSettings` on the module class. ### Types -Text, Number, Bool, Json. +| Type | Description | +|------|-------------| +| **Text** | String value (single-line) | +| **Number** | Numeric value (integer or decimal) | +| **Bool** | Boolean (true/false) | +| **Json** | Arbitrary JSON value (for complex or structured settings) | ### Rules @@ -393,10 +398,10 @@ All SM diagnostics are emitted by the Roslyn source generator at compile time. ` | Diagnostic | Severity | Rule | |------------|----------|------| | SM0027 | Error | Permission field must be const string | -| SM0031 | Warning | Permission value does not follow naming pattern | +| SM0031 | Warning | Permission value must follow the `Module.Action` pattern (exactly one dot) | | SM0032 | Error | Permission class must be sealed | | SM0033 | Error | No duplicate permission values | -| SM0034 | Warning | Permission value prefix must match module name | +| SM0034 | Warning | Permission value prefix must match the owning module name | ### View Endpoints From e807a2eb52a2f880305c93dd08b3a419bc7bc3a8 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 27 Mar 2026 20:35:00 +0100 Subject: [PATCH 12/14] =?UTF-8?q?Trim=20CLAUDE.md=20=E2=80=94=20move=20arc?= =?UTF-8?q?hitectural=20rules=20to=20Constitution,=20add=20reference=20lin?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 218 +++--------------------------------------------------- 1 file changed, 10 insertions(+), 208 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 39a1a3bb..8496a512 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,11 +83,20 @@ Test stack: xUnit.v3, FluentAssertions, Bogus, Microsoft.AspNetCore.Mvc.Testing. 3. React ClientApp dynamically imports module's `pages.js` bundle 4. Component hydrates with server-provided props +## Module Rules & Architecture + +See [docs/CONSTITUTION.md](docs/CONSTITUTION.md) for the authoritative reference on: +- Module boundaries, dependencies, and data ownership +- Communication patterns (contracts and events) +- Endpoint, frontend, and authorization rules +- Compiler-enforced diagnostics (SM0001-SM0043) +- Framework contributor guidelines + ## Key Constraints - **Source generator must target netstandard2.0** with `IIncrementalGenerator` (not `ISourceGenerator`). - Modules need ``. -- **Module Vite builds use library mode** — externalize React, React-DOM, @inertiajs/react. Inline dynamic imports. +- **Module Vite builds use library mode** — externalize React, React-DOM, @inertiajs/react. - **TreatWarningsAsErrors is enabled** globally via `Directory.Build.props` with `AnalysisLevel=latest-all` and `AnalysisMode=All`. Suppressed rules are listed in `.editorconfig`. ## C# Conventions (enforced by .editorconfig) @@ -96,61 +105,6 @@ Test stack: xUnit.v3, FluentAssertions, Bogus, Microsoft.AspNetCore.Mvc.Testing. - **Style**: File-scoped namespaces (error), usings outside namespace (error), prefer `var`. - **Tests**: Underscore method names allowed (`Method_Scenario_Expected`). CA2234 (Uri overload) and xUnit1051 (CancellationToken) suppressed in test projects. -## Module Communication - -- **Contracts pattern** — each module has a `.Contracts` project with a public interface (e.g., `IProductContracts`) and `[Dto]` types. Other modules depend on contracts, never implementations. -- **Event bus** — `IEventBus.PublishAsync()` broadcasts to all `IEventHandler` implementations. Handler failures are isolated (collected in `AggregateException`). - -### Event Handler Patterns & Exception Isolation - -The EventBus implements **partial success semantics**: all handlers execute sequentially in registration order, even if some fail. Exceptions are collected and rethrown as `AggregateException` after all handlers complete. - -**Handler Execution Guarantees:** - -- All registered `IEventHandler` implementations execute for each event -- Handlers run in registration order -- If handler A throws, handler B still runs -- Side effects from successful handlers are preserved -- All exceptions are collected and thrown together as `AggregateException` - -**Handler Implementation Best Practices:** - -- **Stateless**: Avoid storing mutable state; handlers may be called concurrently in future versions -- **Independent**: Don't rely on side effects from other handlers; they may execute in any order or be skipped -- **No throwing for expected failures**: Use result types or early returns instead; thrown exceptions interrupt and must be handled by the caller -- **Idempotent when possible**: The same event may be reprocessed in retry scenarios; design handlers to handle duplicate calls gracefully -- **Avoid long-running work**: For expensive operations, use background jobs or a reliable message queue instead of synchronous handlers - -**Exception Handling Pattern:** - -```csharp -public sealed class AuditLogEventHandler : IEventHandler -{ - public async Task HandleAsync(OrderCreatedEvent @event, CancellationToken cancellationToken) - { - try - { - // Log the event - await _logger.LogEventAsync(@event, cancellationToken); - } - catch (Exception ex) - { - // Don't throw: audit logging must never disrupt the primary operation - _logger.LogError(ex, "Failed to log event"); - // Optionally: queue for retry, alert, etc. - } - } -} -``` - -**Testing Partial Failure:** -Use `EventBusPartialFailureTests` as a reference. Key scenarios: - -- Verify successful handlers complete their work even if others fail -- Verify exceptions are collected and rethrown as `AggregateException` -- Verify handler execution order is preserved even when some fail -- Verify cancellation tokens propagate to all handlers - ## Pages Registry Pattern (Pages/index.ts) When you add a new `IViewEndpoint`, you **must** register it in your module's `Pages/index.ts` immediately. This is a manual, critical step. @@ -203,70 +157,6 @@ sm new feature # add feature to existing module sm doctor [--fix] # validate project structure, auto-fix issues ``` -## Database - -- Multi-provider: SQLite (table prefixes), PostgreSQL/SQL Server (schemas per module). -- Each module registers `ModuleDbContextInfo` for schema isolation. -- Uses `EnsureCreated()` — for production migrations, use EF Core migrations per module. - -### Unified HostDbContext (Known Limitation) - -All module entities are currently merged into a single `HostDbContext` by the source generator. This means all modules share one database connection and one migration history. Trade-offs: - -- **Schema changes in one module block deployments of others** — a migration in Products prevents independent Orders deployment. -- **Only one module can own IdentityDbContext** (SM0003 enforces this). -- **Schema isolation is provider-dependent** — SQLite uses table prefixes, PostgreSQL uses schemas. Raw SQL or cross-module queries leak this abstraction. - -**Future path:** The `IModuleDbContext` marker interface (in `SimpleModule.Core.Hosting`) exists as a stepping stone. Modules implementing it declare their schema name. A future generator update can use this to support: -1. Per-module connection strings (opt-in) -2. Independent migration histories per module -3. A path toward separate databases for modules that need microservice-level isolation - -For now, the unified context remains the default. Design modules with minimal cross-entity queries to ease future decoupling. - -### EF Core Interceptor DI Patterns - -**Problem:** SaveChangesInterceptors registered in DI can cause circular dependencies when they depend on services that themselves depend on DbContext. For example: `SaveChangesInterceptor → ISettings → SettingsService → SettingsDbContext` creates a deadlock during construction. - -**Solution:** Resolve runtime dependencies at interception time, not in the constructor. - -**Pattern:** - -```csharp -// CORRECT: Constructor has minimal dependencies (only required for interceptor itself) -public sealed class MyInterceptor( - IServiceProvider? serviceProvider = null // Injected via DI, nullable for optionality -) : SaveChangesInterceptor -{ - public override ValueTask> SavingChangesAsync( - DbContextEventData eventData, - InterceptionResult result, - CancellationToken cancellationToken = default - ) - { - // Resolve optional services at interception time - var settings = serviceProvider?.GetService(); - if (settings is not null) - { - // Use the service - } - return base.SavingChangesAsync(eventData, result, cancellationToken); - } -} - -// WRONG: Constructor tries to resolve ISettingsContracts -public sealed class BadInterceptor( - ISettingsContracts settings // WRONG: Causes circular dependency! -) : SaveChangesInterceptor { } -``` - -**Key Guidelines:** - -- Never inject services that transitively depend on DbContext into interceptor constructors -- Inject `IServiceProvider?` as an optional dependency if runtime service resolution is needed -- Resolve services only within `SavingChangesAsync`, `SavedChangesAsync`, or `SaveChangesFailed` methods -- The framework resolves all registered `ISaveChangesInterceptor` instances lazily during DbContext options construction - ## Adding a New Module Use `sm new module ` (CLI) or manually: @@ -289,94 +179,6 @@ Use `sm new module ` (CLI) or manually: 5. Add `ProjectReference` to `template/SimpleModule.Host/SimpleModule.Host.csproj` 6. Add all projects to `SimpleModule.slnx` -## Minimal API Parameter Binding - -Reference: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding?view=aspnetcore-10.0 - -### Binding Source Priority - -| HTTP method | Binding order (implicit) | -|-------------|--------------------------| -| GET, HEAD, OPTIONS, DELETE | Route → Query → Header → DI services | -| POST, PUT, PATCH | Route → Query → Header → Body (JSON) → DI services | - -**Key rule:** GET/HEAD/OPTIONS/DELETE never implicitly bind from body. Use `[FromBody]` explicitly if needed. - -### Implicit Binding (no attribute needed) - -- **Simple types** (`int`, `string`, etc.): Route → Query (implicit) -- **Complex types** (classes/records): Body (JSON) for POST/PUT/DELETE (implicit) -- **DI services**: Auto-injected when registered in the container. `[FromServices]` is noise. -- **Special types**: `HttpContext`, `HttpRequest`, `HttpResponse`, `CancellationToken`, `ClaimsPrincipal`, `Stream`, `PipeReader` are auto-bound -- **Arrays from query strings**: `int[]`, `string[]`, `StringValues` bind from repeated keys: `?q=1&q=2` → `int[] q` - -### Optional Parameters - -Parameters are **required by default**. Missing required params → 400 Bad Request. Make optional via: -- Nullable type: `int? pageNumber` (null if missing) -- Default value: `int pageNumber = 1` (uses default if missing) -- `string[]` from query: missing key → empty array (not null) - -### When to Use Attributes - -- `[FromForm]` — **required** for scalar form data (never implicit). Supports scalars, enums, complex types, `IFormFile`. -- `[FromForm]` on complex types — binds class properties from form fields: `([FromForm] Todo todo) => ...` -- `[FromBody]` — explicit body binding (usually implicit for complex types in POST/PUT) -- `[FromQuery]` — when a parameter name conflicts with a route parameter, or to rename: `[FromQuery(Name = "p")]` -- `[FromRoute]` — when disambiguation needed -- `[FromHeader(Name = "X-Header")]` — for HTTP headers. Supports `int[]`/`string[]` from repeated headers. -- `[AsParameters]` — bind a struct/class from multiple sources (route + query + header + DI) - -### Form Binding Limitations (IMPORTANT) - -`[FromForm]` supports: -- ✅ Scalar types, enums, complex types, `IFormFile`/`IFormFileCollection` -- ❌ **`List`, `string[]` from repeated form keys** — does NOT work in Minimal APIs - -For multi-value form fields (e.g., `permissions=A&permissions=B`), use `ReadFormAsync()`: -```csharp -var form = await context.Request.ReadFormAsync(); -var permissions = form["permissions"].Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p!).ToList(); -``` - -### Correct Patterns - -```csharp -// API: complex type auto-binds from JSON body, service auto-injected -app.MapPost("/", async (CreateProductRequest request, IProductContracts products) => ...); - -// API: route param + body + DI -app.MapPut("/{id}", async (int id, UpdateProductRequest request, IProductContracts products) => ...); - -// View: scalar form data requires [FromForm] -app.MapPost("/", async ([FromForm] string name, [FromForm] decimal price, IProductContracts products) => ...); - -// View: complex type from form -app.MapPost("/todo", async ([FromForm] Todo todo) => TypedResults.Ok(todo)); - -// Query: arrays bind from repeated keys -app.MapGet("/tags", (int[] q) => $"tag1: {q[0]}, tag2: {q[1]}"); -``` - -### Anti-patterns (Avoid) - -```csharp -// BAD: manual form reading for SCALAR values (use [FromForm] instead) -app.MapPost("/", async (HttpContext context, IService svc) => { - var form = await context.Request.ReadFormAsync(); - var name = form["name"].ToString(); // Use [FromForm] string name -}); - -// BAD: manual JSON deserialization (let model binding handle it) -app.MapPost("/", async (HttpContext context, IService svc) => { - var body = await JsonSerializer.DeserializeAsync(context.Request.Body); -}); - -// OK: ReadFormAsync for multi-value form fields (no [FromForm] equivalent) -var form = await context.Request.ReadFormAsync(); -var permissions = form["permissions"].ToList(); // This is the correct approach -``` - ## Linting & Formatting Biome is configured at repo root (`biome.json`). Covers `modules/**`, `packages/**`, `template/**` except `**/wwwroot/**`. Settings: single quotes, semicolons always, 2-space indent, trailing commas, 100-char line width. Tailwind CSS directives enabled. From f8a9b3fdb7b24d1680f53b479463d5b0a18e28f7 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 27 Mar 2026 20:37:01 +0100 Subject: [PATCH 13/14] Add Constitution design spec and implementation plan --- .../plans/2026-03-27-module-constitution.md | 119 ++++++ .../2026-03-27-module-constitution-design.md | 392 ++++++++++++++++++ 2 files changed, 511 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-27-module-constitution.md create mode 100644 docs/superpowers/specs/2026-03-27-module-constitution-design.md diff --git a/docs/superpowers/plans/2026-03-27-module-constitution.md b/docs/superpowers/plans/2026-03-27-module-constitution.md new file mode 100644 index 00000000..a400079f --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-module-constitution.md @@ -0,0 +1,119 @@ +# Module Constitution Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Write the Module Constitution document and trim CLAUDE.md to reference it for architectural rules. + +**Architecture:** Two files change: create `docs/CONSTITUTION.md` (the authoritative rules document), then edit `CLAUDE.md` to remove sections now covered by the Constitution and add a reference link. + +**Tech Stack:** Markdown only. No code changes. + +**Spec:** `docs/superpowers/specs/2026-03-27-module-constitution-design.md` + +--- + +## Chunk 1: Write the Constitution + +### Task 1: Write docs/CONSTITUTION.md + +**Files:** + +- Create: `docs/CONSTITUTION.md` + +**Source material:** The spec at `docs/superpowers/specs/2026-03-27-module-constitution-design.md` contains the full content for all 12 sections. Write it as a clean, final document — not a spec format. + +- [ ] **Step 1: Write the Constitution document** + +Create `docs/CONSTITUTION.md` with all 12 sections from the spec. Format as a rules document, not a spec. Use clear headers, bullet points, and tables. No code examples (per design decision). The document should read as the authoritative reference for module development and framework contribution. + +**Important framing note:** The existing CLAUDE.md calls the shared database a "Known Limitation." The Constitution reframes this as a deliberate design choice (spec Section 1, line 29). Do not carry over the limitation framing. + +Sections to include (all content is in the spec): +1. Founding Principles +2. Module Boundaries (including all 9 lifecycle hooks) +3. Dependencies (including Vogen exception for Contracts) +4. Data Ownership +5. Communication (including forward-compat handler ordering note, PublishAsync vs PublishInBackground guidance) +6. Endpoints (including specific REST conventions and status codes) +7. Frontend (including corrected dynamic imports rule, within-module vs between-module) +8. Permissions & Authorization +9. Settings & Configuration (including scope definitions) +10. Testing +11. Compiler-Enforced Rules (SM diagnostic table) +12. Framework Contributor Guidelines (using "highest existing SM number") + +- [ ] **Step 2: Verify the document** + +Read the written file and confirm: +- All 12 sections present +- All SM diagnostics listed (SM0001 through SM0043, excluding gaps) +- All 9 IModule lifecycle hooks documented +- No code examples +- Consistent formatting + +- [ ] **Step 3: Commit** + +```bash +git add docs/CONSTITUTION.md +git commit -m "Add Module Constitution — authoritative rules for module development and framework contribution" +``` + +--- + +## Chunk 2: Trim CLAUDE.md + +### Task 2: Remove sections moved to Constitution + +**Files:** + +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Remove the following sections from CLAUDE.md** + +Remove these sections entirely (their content now lives in the Constitution): + +- **"Module Communication"** (lines 99-102) — covered by Constitution Section 5 +- **"Event Handler Patterns & Exception Isolation"** (lines 104-152) — covered by Constitution Section 5 +- **"EF Core Interceptor DI Patterns"** (lines 227-268) — covered by Constitution Section 4 and 12 +- **"Minimal API Parameter Binding"** (lines 292-378) — covered by Constitution Section 6 +- **"Database" section** (lines 206-225, including "Unified HostDbContext" subsection) — covered by Constitution Sections 1 and 4 + +- [ ] **Step 2: Add Constitution reference** + +After the "Architecture" section (after "Request Flow"), add: + +```markdown +## Module Rules & Architecture + +See [docs/CONSTITUTION.md](docs/CONSTITUTION.md) for the authoritative reference on: +- Module boundaries, dependencies, and data ownership +- Communication patterns (contracts and events) +- Endpoint, frontend, and authorization rules +- Compiler-enforced diagnostics (SM0001–SM0043) +- Framework contributor guidelines +``` + +- [ ] **Step 3: Update Key Constraints** + +In the "Key Constraints" section, update line 90 from: +> Module Vite builds use library mode — externalize React, React-DOM, @inertiajs/react. Inline dynamic imports. + +To: +> Module Vite builds use library mode — externalize React, React-DOM, @inertiajs/react. + +(Removes the incorrect "Inline dynamic imports" claim.) + +- [ ] **Step 4: Verify CLAUDE.md** + +Read the updated file and confirm: +- Removed sections are gone +- Constitution reference is present and links correctly +- Remaining sections are intact: What This Is, Build & Run, Frontend, Testing, Architecture, Key Constraints, C# Conventions, Pages Registry Pattern, Test Infrastructure, Frontend Packages, CLI, Adding a New Module, Linting & Formatting, AI workflow sections (Plan Node Default through Core Principles) +- No orphaned headers or broken formatting + +- [ ] **Step 5: Commit** + +```bash +git add CLAUDE.md +git commit -m "Trim CLAUDE.md — move architectural rules to Constitution, add reference link" +``` diff --git a/docs/superpowers/specs/2026-03-27-module-constitution-design.md b/docs/superpowers/specs/2026-03-27-module-constitution-design.md new file mode 100644 index 00000000..0fd13770 --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-module-constitution-design.md @@ -0,0 +1,392 @@ +# Module Constitution Design Spec + +**Date:** 2026-03-27 +**Status:** Draft +**Output:** `docs/CONSTITUTION.md` + +## Goal + +Create a single authoritative document — the Constitution — that codifies all rules for building modules and contributing to the framework. Designed for a small team with a single deployment unit and shared database. + +After writing, CLAUDE.md will be trimmed to reference the Constitution for architectural rules, keeping only build commands, tooling, and AI-assistant instructions. + +## Audience + +- Developers building modules on top of SimpleModule +- Contributors to the framework itself (Core, Generator, Database) + +## Key Assumption + +This is a small team deploying everything as one unit. Modules exist for code clarity and ownership, not for independent deployment or microservice extraction. + +## Document Structure + +### 1. Founding Principles + +The preamble — assumptions everything else builds on: + +- **Small team, single deployment** — all modules deploy together. One migration history, one release, one rollback. +- **Shared database** — all modules share one database via the unified HostDbContext. Schema/table-prefix isolation is for cleanliness, not independence. Deliberate choice, not a limitation. +- **Modular for clarity, not for microservices** — boundaries organize code, enforce ownership, prevent spaghetti. Not for independent scaling. The contracts pattern makes future extraction possible but don't design for it now. +- **Compile-time safety over runtime discipline** — if a rule can be enforced by the source generator (SM diagnostics), it should be. Don't rely on code review for what the compiler can catch. +- **Convention over configuration** — modules follow predictable patterns so the codebase reads like one person wrote it. + +### 2. Module Boundaries + +**What a module owns:** +- Its implementation assembly (`SimpleModule.{Name}`) — entities, services, DbContext, endpoints, views, event handlers +- Its contracts assembly (`SimpleModule.{Name}.Contracts`) — the public API surface: contract interface, DTOs, request/response types, value objects, events, permissions +- Its frontend assets — React components, Vite config, page registry +- Its menu items — registered via `ConfigureMenu` + +**What a module exposes:** +- Only what's in Contracts. Everything else is internal. +- One primary contract interface (`I{Name}Contracts`) +- DTOs and request types marked with `[Dto]` (auto-generates TypeScript types) +- Value object IDs via Vogen with EF Core converters (Vogen is an allowed dependency in Contracts projects) +- Events implementing `IEvent` +- Permission constants via `IModulePermissions` + +**Module lifecycle hooks (all optional, all have default no-ops):** +- `ConfigureServices` — register DI services, DbContext, hosted services +- `ConfigureEndpoints` — escape hatch for non-standard routes (standard endpoints are auto-discovered) +- `ConfigureMiddleware` — register ASP.NET middleware +- `ConfigureMenu` — register navigation menu items +- `ConfigurePermissions` — register authorization permissions +- `ConfigureSettings` — register runtime-configurable settings +- `OnStartAsync` — one-time initialization after all services are registered +- `OnStopAsync` — graceful shutdown cleanup +- `CheckHealthAsync` — report module health status (Healthy, Degraded, Unhealthy) + +**What a module must never expose:** +- Entity classes +- DbContext or DbSet types +- Internal services +- EF Core configurations + +**Structural rules:** +- Every module has both a Contracts project and an implementation project +- The module class is decorated with `[Module]` and implements `IModule` +- Module name, route prefix, and view prefix should be defined in a constants class (convention, not enforced by diagnostic) +- One module, one DbContext (if data is needed) +- SM0043 warns if a module class overrides nothing + +### 3. Dependencies + +**Allowed:** +- Module → own Contracts project +- Module → other module's Contracts project (never implementation) +- Module → framework projects (Core, Database) +- Contracts → Core only (plus Vogen for value objects) + +**Forbidden:** +- Module → another module's implementation (SM0011, error) +- Circular contract references (SM0010, error) +- Contracts → Database or any framework project beyond Core + +**Resolving circular dependencies:** +- Use the event bus: B publishes an event, A handles it. No reference needed. +- Extract shared concepts into a third Contracts project, or rethink ownership. + +**Dependency direction principle:** +- Dependencies flow one way: implementation → contracts → core +- Never sideways (implementation → implementation) +- Never backwards (contracts → implementation) + +**DI injection rules:** +- Inject contract interfaces, never concrete services +- Framework services (`IEventBus`, `ISettingsContracts`) are injected directly +- DbContext is injected only within the owning module's implementation + +### 4. Data Ownership + +**Each module owns its data exclusively:** +- Entities live in the implementation assembly, never in Contracts +- Only the owning module's service layer may read or write its entities +- Other modules access data through the contract interface + +**DbContext rules:** +- Register with `AddModuleDbContext(configuration, ModuleName)` +- Call `ApplyModuleSchema()` in `OnModelCreating` +- Use entity configurations via `IEntityTypeConfiguration` +- Register Vogen value object converters in `ConfigureConventions` +- One DbContext per module, maximum + +**Entity configuration:** +- One configuration per entity (SM0007 prevents duplicates) +- No entity in two modules' DbSets with different types (SM0001) +- Orphaned configurations warned (SM0006) + +**Schema isolation (automatic):** +- PostgreSQL/SQL Server: schema per module (lowercase name) +- SQLite: table prefix per module +- Cosmetic organization, not security — all modules share one connection + +**Forbidden:** +- Injecting HostDbContext in module code +- Raw SQL referencing another module's tables +- Foreign keys across module boundaries — use IDs and validate via contracts +- Sharing entity types between modules — use DTOs in Contracts for shared shapes + +### 5. Communication + +**Contract interfaces:** +- One primary interface per module: `I{Name}Contracts` +- The only way other modules interact with your data or behavior +- SM0012 warns at 15+ methods, SM0013 errors at 20+ +- Exactly one implementation required (SM0025/SM0026), public and non-abstract (SM0028/SM0029) + +**Events:** +- Cross-module notifications use `IEventBus.PublishAsync()` +- Events defined in the publishing module's Contracts project +- Any module can handle any event via `IEventHandler` +- Handlers currently execute sequentially in registration order, all run even if some fail. Design handlers as order-independent for forward compatibility. +- Failures collected into `AggregateException` +- `PublishAsync()` — synchronous dispatch, caller blocks until all handlers complete, exceptions propagate +- `PublishInBackground()` — fire-and-forget, failures logged not thrown. Use for notifications where the caller must not block or fail (audit logging, cache invalidation). +- Handlers should be stateless, independent, and idempotent + +**When to use which:** +- Contracts — caller needs a response +- Events — caller doesn't care who listens + +**Forbidden:** +- Direct service injection across modules (only contract interfaces) +- Shared mutable state +- Database-level integration (triggers, views, cross-module queries) + +### 6. Endpoints + +**Two types:** +- `IEndpoint` — API endpoints returning JSON +- `IViewEndpoint` — view endpoints returning `Inertia.Render()` + +**Routing:** +- API endpoints under `RoutePrefix`, view endpoints under `ViewPrefix` +- Standard REST conventions: GET for reads, POST for creates, PUT for updates, DELETE for deletes. Use `CrudEndpoints` helpers for consistent status codes (200, 201, 204, 404). +- View page names must match module name prefix (SM0041) +- No duplicate view page names (SM0015) +- Modules with view endpoints must define ViewPrefix (SM0042) + +**Parameter binding:** +- Implicit binding by default — don't manually read request body or form data for scalars +- `[FromForm]` required for form submissions +- `ReadFormAsync()` only for multi-value form fields +- `[FromServices]` is unnecessary noise + +**Authorization:** +- `.RequirePermission()` with permission constants +- `.AllowAnonymous()` for public endpoints +- Default is authorized + +**CrudEndpoints helper:** +- Standard CRUD responses with consistent status codes +- For non-standard responses, write custom handlers + +**Escape hatch:** +- Override `ConfigureEndpoints` on the module class for non-standard routes + +### 7. Frontend + +**Page registry:** +- Every `IViewEndpoint` must have a matching entry in `Pages/index.ts` +- Key must match the component name in `Inertia.Render()` +- Missing entries cause silent client-side 404s +- Run `npm run validate-pages` to verify + +**Build system:** +- Each module has a `vite.config.ts` using `defineModuleConfig()` +- Output: `{ModuleName}.pages.js` in `wwwroot/` +- Library mode — React, React-DOM, @inertiajs/react externalized +- Dynamic imports within module builds follow Rollup's default behavior (code splitting supported) +- Dynamic imports between modules are how the system works — ClientApp lazy-loads each module's `pages.js` bundle + +**TypeScript types:** +- `[Dto]` types auto-generate TypeScript interfaces +- Don't hand-write types that mirror DTOs + +**Package structure:** +- React and Inertia as `peerDependencies` +- Use `@simplemodule/ui` for shared components +- Use `@simplemodule/theme-default` for styling + +**Linting and formatting:** +- Biome configured at repo root +- Single quotes, semicolons, 2-space indent, trailing commas, 100-char width +- `npm run check` to verify, `npm run check:fix` to auto-fix + +### 8. Permissions & Authorization + +**Defining permissions:** +- Sealed class implementing `IModulePermissions` +- `public const string` fields only (SM0027) +- Values follow `{ModuleName}.{Action}` (SM0031, SM0034) +- Class must be sealed (SM0032) +- No duplicate values (SM0033) + +**Registering:** +- Override `ConfigurePermissions`, call `builder.AddPermissions()` + +**Applying:** +- `.RequirePermission()` on endpoints +- `.AllowAnonymous()` for public endpoints +- Never hardcode permission strings + +**Rules:** +- Permissions owned by the defining module +- Other modules may reference permission constants from Contracts +- Not every module needs permissions + +### 9. Settings & Configuration + +**Defining settings:** +- Override `ConfigureSettings` on the module class + +**Scopes:** +- `System` — global settings that affect infrastructure (database, caching, external service config) +- `Application` — application-wide preferences visible to all users (site title, default language) +- `User` — per-user preferences (theme, notification settings) + +**Types:** Text, Number, Bool, Json + +**Rules:** +- Namespace setting keys to the module +- Only for values configurable at runtime +- For per-environment values, use `IConfiguration` and `appsettings.json` +- Access other modules' settings through `ISettingsContracts` + +### 10. Testing + +**Stack:** xUnit.v3, FluentAssertions, Bogus, `SimpleModuleWebApplicationFactory` + +**Structure:** +- Test project per module at `modules/{Name}/tests/{Name}.Tests/` +- Underscore method naming: `Method_Scenario_Expected` + +**Integration tests:** +- `SimpleModuleWebApplicationFactory` with in-memory SQLite and test auth +- `CreateAuthenticatedClient(params Claim[] claims)` for auth + +**Fake data:** +- `FakeDataGenerators` for pre-built Bogus fakers + +**Database:** +- SQLite in-memory locally, PostgreSQL in CI +- Both providers tested in CI + +**Rules:** +- Test through public API (endpoints and contract interfaces) +- Don't mock the database +- Test cross-module interactions through contracts +- Every module should have tests + +### 11. Compiler-Enforced Rules + +Reference table of all SM diagnostics: + +**Database & Entities:** +- SM0001 (Error): No duplicate DbSet property names across modules +- SM0003 (Error): Only one IdentityDbContext +- SM0005 (Error): IdentityDbContext must use three type arguments +- SM0006 (Warning): Orphaned entity configuration +- SM0007 (Error): No duplicate entity configurations + +**Module Structure:** +- SM0002 (Warning): Module name must not be empty +- SM0040 (Error): No duplicate module names +- SM0043 (Warning): Module must override at least one IModule method + +**Dependencies:** +- SM0010 (Error): No circular module dependencies +- SM0011 (Error): No direct module-to-module implementation references + +**Contracts:** +- SM0012 (Warning): Contract interface 15+ methods +- SM0013 (Error): Contract interface 20+ methods +- SM0014 (Error): Contracts assembly has no public interfaces +- SM0025 (Error): No implementation for contract interface +- SM0026 (Error): Multiple implementations of contract interface +- SM0028 (Error): Implementation must be public +- SM0029 (Error): Implementation must not be abstract +- SM0035 (Warning): DTO with no public properties +- SM0038 (Warning): Infrastructure type in Contracts + +**Permissions:** +- SM0027 (Error): Permission field must be const string +- SM0031 (Warning): Permission value naming pattern +- SM0032 (Error): Permission class must be sealed +- SM0033 (Error): No duplicate permission values +- SM0034 (Warning): Permission prefix must match module + +**View Endpoints:** +- SM0015 (Error): No duplicate view page names +- SM0041 (Warning): View page name prefix +- SM0042 (Error): ViewPrefix required with view endpoints + +**Interceptors:** +- SM0039 (Warning): Interceptor has transitive DbContext dependency + +### 12. Framework Contributor Guidelines + +**Source generator:** +- Target netstandard2.0 with `IIncrementalGenerator` +- Use incremental pipeline for caching +- New diagnostics use the next available SM number after the highest existing one +- All diagnostics must have tests + +**Adding a diagnostic:** +- Define descriptor in `DiagnosticEmitter.cs` +- Add detection logic in discovery or emission +- Add positive and negative test cases +- Document in Constitution Section 11 +- Errors for runtime breakage, warnings for code smells + +**HostDbContext generation:** +- Generator merges all module DbSets into one context +- Entity-to-module mapping drives schema isolation +- Test against all modules before merging changes + +**Database framework:** +- `ModuleDbContextOptionsBuilder` handles provider detection and routing +- Interceptors resolved lazily to avoid circular DI +- `ApplyModuleSchema` must handle PostgreSQL, SQL Server, and SQLite + +**Core framework:** +- All IModule methods must have default implementations +- New lifecycle hooks require a default no-op +- Keep Contracts dependencies minimal + +**Build and CI:** +- `TreatWarningsAsErrors` enabled globally +- `AnalysisLevel=latest-all`, `AnalysisMode=All` +- Suppressed rules live in `.editorconfig` +- Tests run against SQLite and PostgreSQL in CI + +## CLAUDE.md Changes + +After writing the Constitution, CLAUDE.md will be updated to: + +1. Remove these sections (moved to Constitution): + - Module Communication + - Event Handler Patterns & Exception Isolation + - EF Core Interceptor DI Patterns + - Minimal API Parameter Binding + - Database (detailed rules) + - Unified HostDbContext (reframed from "Known Limitation" to "deliberate choice" in Constitution) + +2. Replace with a brief reference: + - Link to `docs/CONSTITUTION.md` for architectural rules + - Keep: build commands, test commands, frontend commands, CLI usage + - Keep: Adding a New Module checklist (procedural, not architectural) + - Keep: C# conventions (enforced by .editorconfig) + - Keep: Key Constraints (generator netstandard2.0, TreatWarningsAsErrors) + - Keep: Test Infrastructure (factory usage) + - Keep: Frontend Packages + - Keep: Linting & Formatting + - Keep: Task Management and Core Principles (AI workflow) + +## Out of Scope + +- Code examples in the Constitution (reference codebase instead) +- Tutorial/walkthrough content (this is a rules document) +- Future microservice patterns (small team, single deploy) From f078c5ffb2a260d15ff96b8cdab21275da40f80b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 27 Mar 2026 20:52:55 +0100 Subject: [PATCH 14/14] Fix type generation paths after project rename and lint error The extract-ts-types.mjs script was writing types.ts to the old module paths (e.g., modules/Admin/src/Admin/) instead of the renamed paths (modules/Admin/src/SimpleModule.Admin/). Updated the script to prefix "SimpleModule." to the module directory name and removed the 9 stale type files in the old locations. Also fixed a Biome formatting issue in PageBuilder Manage.tsx. --- modules/Admin/src/Admin/types.ts | 14 --- modules/AuditLogs/src/AuditLogs/types.ts | 86 ------------------- modules/FileStorage/src/FileStorage/types.ts | 11 --- modules/OpenIddict/src/OpenIddict/types.ts | 4 - modules/Orders/src/Orders/types.ts | 24 ------ modules/PageBuilder/src/PageBuilder/types.ts | 71 --------------- .../SimpleModule.PageBuilder/Views/Manage.tsx | 4 +- modules/Products/src/Products/types.ts | 17 ---- modules/Settings/src/Settings/types.ts | 68 --------------- modules/Users/src/Users/types.ts | 65 -------------- tools/extract-ts-types.mjs | 5 +- 11 files changed, 4 insertions(+), 365 deletions(-) delete mode 100644 modules/Admin/src/Admin/types.ts delete mode 100644 modules/AuditLogs/src/AuditLogs/types.ts delete mode 100644 modules/FileStorage/src/FileStorage/types.ts delete mode 100644 modules/OpenIddict/src/OpenIddict/types.ts delete mode 100644 modules/Orders/src/Orders/types.ts delete mode 100644 modules/PageBuilder/src/PageBuilder/types.ts delete mode 100644 modules/Products/src/Products/types.ts delete mode 100644 modules/Settings/src/Settings/types.ts delete mode 100644 modules/Users/src/Users/types.ts diff --git a/modules/Admin/src/Admin/types.ts b/modules/Admin/src/Admin/types.ts deleted file mode 100644 index 140b48c4..00000000 --- a/modules/Admin/src/Admin/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Auto-generated from [Dto] types — do not edit -export interface AdminPermissions { -} - -export interface AuditLogEntryDto { - id: number; - userId: string; - performedByUserId: string; - performedByName: string; - action: string; - details: string; - timestamp: string; -} - diff --git a/modules/AuditLogs/src/AuditLogs/types.ts b/modules/AuditLogs/src/AuditLogs/types.ts deleted file mode 100644 index 11ee1f8f..00000000 --- a/modules/AuditLogs/src/AuditLogs/types.ts +++ /dev/null @@ -1,86 +0,0 @@ -// Auto-generated from [Dto] types — do not edit -export interface DashboardStats { - totalEntries: number; - uniqueUsers: number; - averageDurationMs: number; - errorRate: number; - bySource: any; - byAction: any; - byModule: any; - byStatusCategory: any; - byEntityType: any; - topUsers: NamedCount[]; - topPaths: NamedCount[]; - timeline: TimelinePoint[]; - hourlyDistribution: NamedCount[]; -} - -export interface NamedCount { - name: string; - count: number; -} - -export interface TimelinePoint { - date: string; - http: number; - domain: number; - changes: number; -} - -export interface AuditEntry { - id: number; - correlationId: string; - source: any; - timestamp: string; - userId: string; - userName: string; - ipAddress: string; - userAgent: string; - httpMethod: string; - path: string; - queryString: string; - statusCode: any; - durationMs: any; - requestBody: string; - module: string; - entityType: string; - entityId: string; - action: any; - changes: string; - metadata: string; -} - -export interface AuditExportRequest { - format: string; - effectiveFormat: string; -} - -export interface AuditQueryRequest { - from: any; - to: any; - userId: string; - module: string; - entityType: string; - entityId: string; - source: any; - action: any; - statusCode: any; - searchText: string; - page: any; - pageSize: any; - sortBy: string; - sortDescending: any; - effectivePage: number; - effectivePageSize: number; - effectiveSortBy: string; - effectiveSortDescending: boolean; -} - -export interface AuditStats { - totalEntries: number; - uniqueUsers: number; - byModule: any; - byAction: any; - byStatusCode: any; -} - diff --git a/modules/FileStorage/src/FileStorage/types.ts b/modules/FileStorage/src/FileStorage/types.ts deleted file mode 100644 index 97621843..00000000 --- a/modules/FileStorage/src/FileStorage/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated from [Dto] types — do not edit -export interface StoredFile { - id: number; - fileName: string; - storagePath: string; - contentType: string; - size: number; - folder: string; - createdAt: string; -} - diff --git a/modules/OpenIddict/src/OpenIddict/types.ts b/modules/OpenIddict/src/OpenIddict/types.ts deleted file mode 100644 index 294e1680..00000000 --- a/modules/OpenIddict/src/OpenIddict/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Auto-generated from [Dto] types — do not edit -export interface OpenIddictPermissions { -} - diff --git a/modules/Orders/src/Orders/types.ts b/modules/Orders/src/Orders/types.ts deleted file mode 100644 index 1e9249d7..00000000 --- a/modules/Orders/src/Orders/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Auto-generated from [Dto] types — do not edit -export interface CreateOrderRequest { - userId: string; - items: OrderItem[]; -} - -export interface Order { - id: number; - userId: string; - items: OrderItem[]; - total: number; - createdAt: string; -} - -export interface OrderItem { - productId: number; - quantity: number; -} - -export interface UpdateOrderRequest { - userId: string; - items: OrderItem[]; -} - diff --git a/modules/PageBuilder/src/PageBuilder/types.ts b/modules/PageBuilder/src/PageBuilder/types.ts deleted file mode 100644 index 7af0e9e9..00000000 --- a/modules/PageBuilder/src/PageBuilder/types.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Auto-generated from [Dto] types — do not edit -export interface AddTagRequest { - name: string; -} - -export interface CreatePageRequest { - title: string; - slug: string; -} - -export interface CreatePageTemplateRequest { - name: string; - content: string; -} - -export interface Page { - id: number; - title: string; - slug: string; - content: string; - draftContent: string; - metaDescription: string; - metaKeywords: string; - ogImage: string; - isPublished: boolean; - order: number; - createdAt: string; - updatedAt: string; - tags: PageTag[]; - deletedAt: any; -} - -export interface PageSummary { - id: number; - title: string; - slug: string; - isPublished: boolean; - hasDraft: boolean; - order: number; - createdAt: string; - updatedAt: string; - deletedAt: any; - tags: string[]; -} - -export interface PageTag { - id: number; - name: string; -} - -export interface PageTemplate { - id: number; - name: string; - content: string; - createdAt: string; -} - -export interface UpdatePageContentRequest { - content: string; -} - -export interface UpdatePageRequest { - title: string; - slug: string; - order: number; - isPublished: boolean; - metaDescription: string; - metaKeywords: string; - ogImage: string; -} - diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/Manage.tsx b/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/Manage.tsx index d47df2e0..06780552 100644 --- a/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/Manage.tsx +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/Views/Manage.tsx @@ -173,9 +173,7 @@ export default function Manage({ pages }: Props) { - router.get(`/pages/${page.id}/edit`)} - > + router.get(`/pages/${page.id}/edit`)}> Edit {page.isPublished && ( diff --git a/modules/Products/src/Products/types.ts b/modules/Products/src/Products/types.ts deleted file mode 100644 index bd271339..00000000 --- a/modules/Products/src/Products/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Auto-generated from [Dto] types — do not edit -export interface CreateProductRequest { - name: string; - price: number; -} - -export interface Product { - id: number; - name: string; - price: number; -} - -export interface UpdateProductRequest { - name: string; - price: number; -} - diff --git a/modules/Settings/src/Settings/types.ts b/modules/Settings/src/Settings/types.ts deleted file mode 100644 index ba807c56..00000000 --- a/modules/Settings/src/Settings/types.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Auto-generated from [Dto] types — do not edit -export interface CreateMenuItemRequest { - parentId: any; - label: string; - url: string; - pageRoute: string; - icon: string; - cssClass: string; - openInNewTab: boolean; - isVisible: boolean; - isHomePage: boolean; -} - -export interface PublicMenuItemDto { - id: number; - parentId: any; - label: string; - url: string; - pageRoute: string; - icon: string; - cssClass: string; - openInNewTab: boolean; - isVisible: boolean; - isHomePage: boolean; - sortOrder: number; - children: PublicMenuItemDto[]; -} - -export interface ReorderMenuItemsRequest { - items: ReorderItem[]; -} - -export interface ReorderItem { - id: number; - parentId: any; - sortOrder: number; -} - -export interface Setting { - key: string; - value: string; - scope: any; - userId: string; - updatedAt: string; -} - -export interface SettingsFilter { - scope: any; - group: string; -} - -export interface UpdateMenuItemRequest { - label: string; - url: string; - pageRoute: string; - icon: string; - cssClass: string; - openInNewTab: boolean; - isVisible: boolean; - isHomePage: boolean; -} - -export interface UpdateSettingRequest { - key: string; - value: string; - scope: any; -} - diff --git a/modules/Users/src/Users/types.ts b/modules/Users/src/Users/types.ts deleted file mode 100644 index 827485b0..00000000 --- a/modules/Users/src/Users/types.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Auto-generated from [Dto] types — do not edit -export interface AdminUserDto { - id: string; - displayName: string; - email: string; - emailConfirmed: boolean; - roles: string[]; - isLockedOut: boolean; - isDeactivated: boolean; - createdAt: string; -} - -export interface RoleDto { - id: string; - name: string; - description: string; - userCount: number; - createdAt: string; -} - -export interface ApplicationRole { - description: string; - createdAt: string; -} - -export interface ApplicationUser { - displayName: string; - createdAt: string; - lastLoginAt: any; - deactivatedAt: any; -} - -export interface CreateAdminUserRequest { - email: string; - displayName: string; - password: string; - emailConfirmed: boolean; - roles: string[]; -} - -export interface CreateUserRequest { - email: string; - displayName: string; - password: string; -} - -export interface UpdateAdminUserRequest { - displayName: string; - email: string; - emailConfirmed: boolean; -} - -export interface UpdateUserRequest { - email: string; - displayName: string; -} - -export interface UserDto { - id: string; - email: string; - displayName: string; - emailConfirmed: boolean; - twoFactorEnabled: boolean; -} - diff --git a/tools/extract-ts-types.mjs b/tools/extract-ts-types.mjs index 422807fa..1b761930 100644 --- a/tools/extract-ts-types.mjs +++ b/tools/extract-ts-types.mjs @@ -40,15 +40,16 @@ for (const file of files) { if (!tsMatch) continue; const tsContent = tsMatch[1]; + const projectDir = `SimpleModule.${moduleName}`; const outPath = resolve( modulesDir, moduleName, 'src', - moduleName, + projectDir, 'types.ts', ); - mkdirSync(resolve(modulesDir, moduleName, 'src', moduleName), { + mkdirSync(resolve(modulesDir, moduleName, 'src', projectDir), { recursive: true, }); writeFileSync(