diff --git a/CLAUDE.md b/CLAUDE.md index e275e1fc..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,55 +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. - -### 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: @@ -274,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. 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/docs/CONSTITUTION.md b/docs/CONSTITUTION.md new file mode 100644 index 00000000..b87dcaad --- /dev/null +++ b/docs/CONSTITUTION.md @@ -0,0 +1,462 @@ +# 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` (place in Contracts if other modules need to reference them) + +### 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 no lifecycle hooks (indicating a likely empty or placeholder module). + +--- + +## 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. 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 + +- **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 + +| 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 + +- 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 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 the owning 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. 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) 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/Events/BackgroundEventChannel.cs b/framework/SimpleModule.Core/Events/BackgroundEventChannel.cs new file mode 100644 index 00000000..b5df4866 --- /dev/null +++ b/framework/SimpleModule.Core/Events/BackgroundEventChannel.cs @@ -0,0 +1,41 @@ +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 + { + 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..3695b9bf --- /dev/null +++ b/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs @@ -0,0 +1,49 @@ +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. Events are dispatched concurrently +/// to avoid head-of-line blocking from slow handlers. +/// +public sealed partial class BackgroundEventDispatcher( + BackgroundEventChannel channel, + IServiceScopeFactory scopeFactory, + ILogger logger +) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var dispatch in channel.Reader.ReadAllAsync(stoppingToken)) + { + _ = DispatchAsync(dispatch, stoppingToken); + } + } + + private async Task DispatchAsync( + Func dispatch, + CancellationToken stoppingToken + ) + { + try + { + using var scope = scopeFactory.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..1a6991e6 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 +) : IEventBus { /// /// Publishes an event to all registered handlers, ensuring all handlers execute even if some fail. @@ -138,6 +141,13 @@ public async Task PublishAsync(T @event, CancellationToken cancellationToken } } + /// + public void PublishInBackground(T @event) + where T : IEvent + { + 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.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 4a10e46c..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 @@ -204,6 +206,15 @@ 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 InterceptorDependsOnDbContext = new( id: "SM0039", title: "SaveChanges interceptor has transitive DbContext dependency", @@ -213,6 +224,42 @@ 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 + ); + + 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 @@ -230,6 +277,58 @@ 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; + } + } + + // 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. @@ -658,6 +757,77 @@ 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); + } + } + } + + // 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)) + { +#pragma warning disable CA1308 // Route prefixes are conventionally lowercase + context.ReportDiagnostic( + Diagnostic.Create( + ViewEndpointWithoutViewPrefix, + Location.None, + module.ModuleName, + module.Views.Length, + module.ModuleName.ToLowerInvariant() + ) + ); +#pragma warning restore CA1308 + } + } + // SM0039: Interceptor depends on contract whose implementation takes a DbContext foreach (var interceptor in data.Interceptors) { 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/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/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/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 53ae403b..d61a8a90 100644 --- a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs @@ -1,14 +1,12 @@ 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; using SimpleModule.Core; using SimpleModule.Permissions.Contracts; -using SimpleModule.Users; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Admin.Endpoints.Admin; @@ -28,7 +26,7 @@ public void Map(IEndpointRouteBuilder app) [FromForm] string name, [FromForm] string? description, HttpContext context, - RoleManager roleManager, + IRoleAdminContracts roleAdmin, IPermissionContracts permissionContracts, AuditService audit ) => @@ -37,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"] @@ -61,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, @@ -83,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"); @@ -115,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(); @@ -135,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}'" @@ -149,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}'" @@ -172,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 fb08b2f4..c05a5dd9 100644 --- a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs @@ -1,15 +1,12 @@ 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; using SimpleModule.Core; using SimpleModule.Permissions.Contracts; -using SimpleModule.Users; using SimpleModule.Users.Contracts; -using SimpleModule.Users.Entities; namespace SimpleModule.Admin.Endpoints.Admin; @@ -31,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}"); @@ -76,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, @@ -110,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"); } @@ -209,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"); @@ -234,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"); @@ -259,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"); @@ -284,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"); @@ -309,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"); @@ -334,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"); @@ -361,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/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/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 f6b851c5..655880eb 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEditEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEditEndpoint.cs @@ -1,46 +1,33 @@ 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; using SimpleModule.Permissions.Contracts; -using SimpleModule.Users; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; 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(); @@ -54,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 d8b30255..1d3d58d2 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEndpoint.cs @@ -1,47 +1,39 @@ 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; -using SimpleModule.Users; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; 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 e77851d3..90c27c4e 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersActivityEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersActivityEndpoint.cs @@ -1,10 +1,9 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using SimpleModule.Core; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; namespace SimpleModule.Admin.Views.Admin; @@ -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 119487a8..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.Entities; +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 0f90ef9a..93f5a9fb 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs @@ -1,18 +1,16 @@ 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; using SimpleModule.Permissions.Contracts; -using SimpleModule.Users; using SimpleModule.Users.Contracts; -using SimpleModule.Users.Entities; namespace SimpleModule.Admin.Views.Admin; +[ViewPage("Admin/Admin/UsersEdit")] public class UsersEditEndpoint : IViewEndpoint { private const int ActivityPageSize = 20; @@ -20,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 = ( @@ -69,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 { @@ -90,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 301c66eb..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.Entities; +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/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/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/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/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/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/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/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/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/AuthorizationEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/AuthorizationEndpoint.cs index ae3d98c4..ff6a4368 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; @@ -73,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); @@ -87,18 +85,15 @@ await userManager.GetUserAsync(result.Principal) // Load permissions via IPermissionContracts var permissionContracts = context.RequestServices.GetRequiredService(); - var userId = UserId.From(await userManager.GetUserIdAsync(user)); + var userContracts = context.RequestServices.GetRequiredService(); + var userId = UserId.From(userIdString); - // 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/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/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/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/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/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/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/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`)} - > + 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/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/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/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/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/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/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/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/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/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/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.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/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/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/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/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/UserService.cs b/modules/Users/src/SimpleModule.Users/UserService.cs index b2c81725..d2191b8d 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 nameCollection = roleNames as ICollection ?? roleNames.ToList(); + var roles = await roleManager + .Roles.Where(r => nameCollection.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..ef57ab43 100644 --- a/modules/Users/src/SimpleModule.Users/UsersModule.cs +++ b/modules/Users/src/SimpleModule.Users/UsersModule.cs @@ -4,12 +4,12 @@ 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; -[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 30466416..e432b51d 100644 --- a/modules/Users/src/SimpleModule.Users/Views/Account/Disable2faEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Views/Account/Disable2faEndpoint.cs @@ -5,16 +5,17 @@ using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Inertia; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; 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 750e7d5a..00843402 100644 --- a/modules/Users/src/SimpleModule.Users/Views/Account/EnableAuthenticatorEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Views/Account/EnableAuthenticatorEndpoint.cs @@ -8,10 +8,11 @@ using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Inertia; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; 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 87337d4d..e9572e17 100644 --- a/modules/Users/src/SimpleModule.Users/Views/Account/GenerateRecoveryCodesEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Views/Account/GenerateRecoveryCodesEndpoint.cs @@ -5,16 +5,17 @@ using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Inertia; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; 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 41751981..686c2b2d 100644 --- a/modules/Users/src/SimpleModule.Users/Views/Account/ResetAuthenticatorEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Views/Account/ResetAuthenticatorEndpoint.cs @@ -5,16 +5,17 @@ using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Inertia; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; 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 a4a65b79..320762ff 100644 --- a/modules/Users/src/SimpleModule.Users/Views/Account/TwoFactorAuthenticationEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Views/Account/TwoFactorAuthenticationEndpoint.cs @@ -5,16 +5,17 @@ using Microsoft.AspNetCore.Routing; using SimpleModule.Core; using SimpleModule.Core.Inertia; -using SimpleModule.Users.Entities; +using SimpleModule.Users.Contracts; 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 deleted file mode 100644 index 133573ae..00000000 --- a/modules/Users/src/Users/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Auto-generated from [Dto] types — do not edit -export interface CreateUserRequest { - email: string; - displayName: string; - password: string; -} - -export interface UpdateUserRequest { - email: string; - displayName: string; -} - -export interface UserDto { - id: string; - email: string; - displayName: string; - emailConfirmed: boolean; - twoFactorEnabled: boolean; -} - 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/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 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/template/SimpleModule.Host/SimpleModule.Host.csproj b/template/SimpleModule.Host/SimpleModule.Host.csproj index 2adb087c..98a2a828 100644 --- a/template/SimpleModule.Host/SimpleModule.Host.csproj +++ b/template/SimpleModule.Host/SimpleModule.Host.csproj @@ -4,10 +4,6 @@ net10.0 false true - - $(NoWarn);SM0011 - - $(NoWarn);SM0035;SM0038 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 b4d614a9..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 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 } diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs index 3ef39ef9..399012b1 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs @@ -27,7 +27,7 @@ public static class FakeDataGenerators public static Faker 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/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); + } } 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 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(