diff --git a/SimpleModule.slnx b/SimpleModule.slnx index 01daccd7..a96e186b 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -84,6 +84,11 @@ + + + + + diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/OverrideType.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/OverrideType.cs index 970d4ea2..af7cf859 100644 --- a/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/OverrideType.cs +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/OverrideType.cs @@ -4,4 +4,5 @@ public enum OverrideType { User = 0, Role = 1, + Tenant = 2, } diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs index 63a0c4d5..65763b3b 100644 --- a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs @@ -1,6 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using SimpleModule.Core.Entities; using SimpleModule.Core.FeatureFlags; using SimpleModule.FeatureFlags.Contracts; using SimpleModule.FeatureFlags.Entities; @@ -11,16 +13,21 @@ public sealed partial class FeatureFlagService( FeatureFlagsDbContext db, IFeatureFlagRegistry registry, IMemoryCache cache, - ILogger logger + ILogger logger, + IServiceProvider serviceProvider ) : IFeatureFlagContracts, IFeatureFlagService { + private readonly Lazy _tenantContext = new( + () => serviceProvider.GetService() + ); private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30); private const string AllFlagDataCacheKey = "ff:all-data"; private sealed record FlagData( bool IsEnabled, Dictionary UserOverrides, - Dictionary RoleOverrides + Dictionary RoleOverrides, + Dictionary TenantOverrides ); public async Task IsEnabledAsync( @@ -273,23 +280,28 @@ List overrides { var userOverrides = new Dictionary(StringComparer.Ordinal); var roleOverrides = new Dictionary(StringComparer.OrdinalIgnoreCase); + var tenantOverrides = new Dictionary(StringComparer.Ordinal); foreach (var o in overrides) { - if (o.OverrideType == OverrideType.User) - { - userOverrides[o.OverrideValue] = o.IsEnabled; - } - else + switch (o.OverrideType) { - roleOverrides[o.OverrideValue] = o.IsEnabled; + case OverrideType.User: + userOverrides[o.OverrideValue] = o.IsEnabled; + break; + case OverrideType.Role: + roleOverrides[o.OverrideValue] = o.IsEnabled; + break; + case OverrideType.Tenant: + tenantOverrides[o.OverrideValue] = o.IsEnabled; + break; } } - return new FlagData(isEnabled, userOverrides, roleOverrides); + return new FlagData(isEnabled, userOverrides, roleOverrides, tenantOverrides); } - private static bool ResolveFlagState( + private bool ResolveFlagState( FlagData data, string? userId, IEnumerable? roles @@ -311,6 +323,13 @@ private static bool ResolveFlagState( } } + var currentTenantId = _tenantContext.Value?.TenantId; + if (currentTenantId is not null + && data.TenantOverrides.TryGetValue(currentTenantId, out var tenantEnabled)) + { + return tenantEnabled; + } + return data.IsEnabled; } diff --git a/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs b/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs index 5437989c..d8983b5e 100644 --- a/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs +++ b/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using SimpleModule.Core.FeatureFlags; @@ -54,7 +55,8 @@ public FeatureFlagServiceTests() _db, _registry, _cache, - NullLogger.Instance + NullLogger.Instance, + new ServiceCollection().BuildServiceProvider() ); } @@ -78,7 +80,8 @@ private FeatureFlagService CreateFreshService() _db, _registry, freshCache, - NullLogger.Instance + NullLogger.Instance, + new ServiceCollection().BuildServiceProvider() ); } diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/AddTenantHostRequest.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/AddTenantHostRequest.cs new file mode 100644 index 00000000..4512de95 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/AddTenantHostRequest.cs @@ -0,0 +1,6 @@ +namespace SimpleModule.Tenants.Contracts; + +public class AddTenantHostRequest +{ + public string HostName { get; set; } = string.Empty; +} diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/CreateTenantRequest.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/CreateTenantRequest.cs new file mode 100644 index 00000000..90cb814d --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/CreateTenantRequest.cs @@ -0,0 +1,12 @@ +namespace SimpleModule.Tenants.Contracts; + +public class CreateTenantRequest +{ + public string Name { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public string? AdminEmail { get; set; } + public string? EditionName { get; set; } + public string? ConnectionString { get; set; } + public DateTimeOffset? ValidUpTo { get; set; } + public List? Hosts { get; set; } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantCreatedEvent.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantCreatedEvent.cs new file mode 100644 index 00000000..87994bcc --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantCreatedEvent.cs @@ -0,0 +1,5 @@ +using SimpleModule.Core.Events; + +namespace SimpleModule.Tenants.Contracts.Events; + +public sealed record TenantCreatedEvent(TenantId TenantId, string Name, string Slug) : IEvent; diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantHostAddedEvent.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantHostAddedEvent.cs new file mode 100644 index 00000000..7f1f69eb --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantHostAddedEvent.cs @@ -0,0 +1,5 @@ +using SimpleModule.Core.Events; + +namespace SimpleModule.Tenants.Contracts.Events; + +public sealed record TenantHostAddedEvent(TenantId TenantId, string HostName) : IEvent; diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantHostRemovedEvent.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantHostRemovedEvent.cs new file mode 100644 index 00000000..90993d2b --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantHostRemovedEvent.cs @@ -0,0 +1,5 @@ +using SimpleModule.Core.Events; + +namespace SimpleModule.Tenants.Contracts.Events; + +public sealed record TenantHostRemovedEvent(TenantId TenantId, string HostName) : IEvent; diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantStatusChangedEvent.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantStatusChangedEvent.cs new file mode 100644 index 00000000..9ba0f301 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantStatusChangedEvent.cs @@ -0,0 +1,9 @@ +using SimpleModule.Core.Events; + +namespace SimpleModule.Tenants.Contracts.Events; + +public sealed record TenantStatusChangedEvent( + TenantId TenantId, + TenantStatus OldStatus, + TenantStatus NewStatus +) : IEvent; diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantUpdatedEvent.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantUpdatedEvent.cs new file mode 100644 index 00000000..7992e73b --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Events/TenantUpdatedEvent.cs @@ -0,0 +1,5 @@ +using SimpleModule.Core.Events; + +namespace SimpleModule.Tenants.Contracts.Events; + +public sealed record TenantUpdatedEvent(TenantId TenantId, string Name) : IEvent; diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/ITenantContracts.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/ITenantContracts.cs new file mode 100644 index 00000000..34dd4645 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/ITenantContracts.cs @@ -0,0 +1,15 @@ +namespace SimpleModule.Tenants.Contracts; + +public interface ITenantContracts +{ + Task> GetAllTenantsAsync(); + Task GetTenantByIdAsync(TenantId id); + Task GetTenantBySlugAsync(string slug); + Task GetTenantByHostNameAsync(string hostName); + Task CreateTenantAsync(CreateTenantRequest request); + Task UpdateTenantAsync(TenantId id, UpdateTenantRequest request); + Task DeleteTenantAsync(TenantId id); + Task ChangeStatusAsync(TenantId id, TenantStatus status); + Task AddHostAsync(TenantId tenantId, AddTenantHostRequest request); + Task RemoveHostAsync(TenantId tenantId, TenantHostId hostId); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/SimpleModule.Tenants.Contracts.csproj b/modules/Tenants/src/SimpleModule.Tenants.Contracts/SimpleModule.Tenants.Contracts.csproj new file mode 100644 index 00000000..97fdfa07 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/SimpleModule.Tenants.Contracts.csproj @@ -0,0 +1,12 @@ + + + net10.0 + Library + $(DefineConstants);VOGEN_NO_VALIDATION + + + + + + + diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/Tenant.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Tenant.cs new file mode 100644 index 00000000..89a2a7fa --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/Tenant.cs @@ -0,0 +1,19 @@ +using SimpleModule.Core; + +namespace SimpleModule.Tenants.Contracts; + +[Dto] +public class Tenant +{ + public TenantId Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public TenantStatus Status { get; set; } + public string? AdminEmail { get; set; } + public string? EditionName { get; set; } + public string? ConnectionString { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public DateTimeOffset? ValidUpTo { get; set; } + public List Hosts { get; set; } = []; +} diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/TenantHost.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/TenantHost.cs new file mode 100644 index 00000000..3f81e38a --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/TenantHost.cs @@ -0,0 +1,12 @@ +using SimpleModule.Core; + +namespace SimpleModule.Tenants.Contracts; + +[Dto] +public class TenantHost +{ + public TenantHostId Id { get; set; } + public TenantId TenantId { get; set; } + public string HostName { get; set; } = string.Empty; + public bool IsActive { get; set; } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/TenantHostId.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/TenantHostId.cs new file mode 100644 index 00000000..1d538623 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/TenantHostId.cs @@ -0,0 +1,6 @@ +using Vogen; + +namespace SimpleModule.Tenants.Contracts; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct TenantHostId; diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/TenantId.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/TenantId.cs new file mode 100644 index 00000000..036d5d88 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/TenantId.cs @@ -0,0 +1,6 @@ +using Vogen; + +namespace SimpleModule.Tenants.Contracts; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct TenantId; diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/TenantStatus.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/TenantStatus.cs new file mode 100644 index 00000000..e573960c --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/TenantStatus.cs @@ -0,0 +1,8 @@ +namespace SimpleModule.Tenants.Contracts; + +public enum TenantStatus +{ + Active = 0, + Suspended = 1, + Inactive = 2, +} diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/TenantsPermissions.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/TenantsPermissions.cs new file mode 100644 index 00000000..290e1557 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/TenantsPermissions.cs @@ -0,0 +1,13 @@ +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Tenants.Contracts; + +public sealed class TenantsPermissions : IModulePermissions +{ + public const string View = "Tenants.View"; + public const string Create = "Tenants.Create"; + public const string Update = "Tenants.Update"; + public const string Delete = "Tenants.Delete"; + public const string ChangeStatus = "Tenants.ChangeStatus"; + public const string ManageHosts = "Tenants.ManageHosts"; +} diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/UpdateTenantRequest.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/UpdateTenantRequest.cs new file mode 100644 index 00000000..00158452 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/UpdateTenantRequest.cs @@ -0,0 +1,10 @@ +namespace SimpleModule.Tenants.Contracts; + +public class UpdateTenantRequest +{ + public string Name { get; set; } = string.Empty; + public string? AdminEmail { get; set; } + public string? EditionName { get; set; } + public string? ConnectionString { get; set; } + public DateTimeOffset? ValidUpTo { get; set; } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/DeleteTenantFeatureEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/DeleteTenantFeatureEndpoint.cs new file mode 100644 index 00000000..11103d22 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/DeleteTenantFeatureEndpoint.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.FeatureFlags.Contracts; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Endpoints.TenantFeatures; + +public class DeleteTenantFeatureEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapDelete( + "/{id}/features/{flagName}", + async (TenantId id, string flagName, HttpContext context) => + { + var featureFlags = context.RequestServices.GetService(); + if (featureFlags is null) + { + return Results.NotFound(); + } + + var tenantIdStr = id.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + var overrides = await featureFlags.GetOverridesAsync(flagName); + var tenantOverride = overrides.FirstOrDefault(o => + o.OverrideType == OverrideType.Tenant + && string.Equals(o.OverrideValue, tenantIdStr, StringComparison.Ordinal) + ); + + if (tenantOverride is null) + { + return Results.NotFound(); + } + + await featureFlags.DeleteOverrideAsync(tenantOverride.Id); + return Results.NoContent(); + } + ) + .RequirePermission(TenantsPermissions.Update); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/GetTenantFeaturesEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/GetTenantFeaturesEndpoint.cs new file mode 100644 index 00000000..e65848bf --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/GetTenantFeaturesEndpoint.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.FeatureFlags.Contracts; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Endpoints.TenantFeatures; + +public class GetTenantFeaturesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + "/{id}/features", + async (TenantId id, HttpContext context) => + { + var featureFlags = context.RequestServices.GetService(); + if (featureFlags is null) + { + return Results.Ok(new TenantFeaturesResponse([], [])); + } + + var flags = (await featureFlags.GetAllFlagsAsync()).ToList(); + var tenantOverrides = await TenantFeatureHelper.GetOverridesForTenantAsync( + featureFlags, flags, id); + + return Results.Ok(new TenantFeaturesResponse(flags, tenantOverrides)); + } + ) + .RequirePermission(TenantsPermissions.View); +} + +public sealed record TenantFeaturesResponse( + IEnumerable Flags, + IEnumerable TenantOverrides +); diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/SetTenantFeatureEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/SetTenantFeatureEndpoint.cs new file mode 100644 index 00000000..13075273 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/SetTenantFeatureEndpoint.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.FeatureFlags.Contracts; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Endpoints.TenantFeatures; + +public class SetTenantFeatureEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPut( + "/{id}/features/{flagName}", + async (TenantId id, string flagName, SetTenantFeatureRequest request, HttpContext context) => + { + var featureFlags = context.RequestServices.GetService(); + if (featureFlags is null) + { + return Results.NotFound(); + } + + var result = await featureFlags.SetOverrideAsync( + flagName, + new SetOverrideRequest + { + OverrideType = OverrideType.Tenant, + OverrideValue = id.Value.ToString(System.Globalization.CultureInfo.InvariantCulture), + IsEnabled = request.IsEnabled, + } + ); + + return Results.Ok(result); + } + ) + .RequirePermission(TenantsPermissions.Update); +} + +public class SetTenantFeatureRequest +{ + public bool IsEnabled { get; set; } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/TenantFeatureHelper.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/TenantFeatureHelper.cs new file mode 100644 index 00000000..ddddd1a8 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/TenantFeatureHelper.cs @@ -0,0 +1,25 @@ +using SimpleModule.FeatureFlags.Contracts; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Endpoints.TenantFeatures; + +internal static class TenantFeatureHelper +{ + public static async Task> GetOverridesForTenantAsync( + IFeatureFlagContracts featureFlags, + IEnumerable flags, + TenantId tenantId) + { + var tenantIdStr = tenantId.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + var overrideTasks = flags + .Where(f => !f.IsDeprecated) + .Select(f => featureFlags.GetOverridesAsync(f.Name)); + var allOverrides = await Task.WhenAll(overrideTasks); + return allOverrides + .SelectMany(o => o) + .Where(o => + o.OverrideType == OverrideType.Tenant + && string.Equals(o.OverrideValue, tenantIdStr, StringComparison.Ordinal)) + .ToList(); + } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/AddHostEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/AddHostEndpoint.cs new file mode 100644 index 00000000..82c23c00 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/AddHostEndpoint.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Exceptions; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Endpoints.Tenants; + +public class AddHostEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPost( + "/{id}/hosts", + async (TenantId id, AddTenantHostRequest request, ITenantContracts contracts) => + { + var validation = AddHostRequestValidator.Validate(request); + if (!validation.IsValid) + { + throw new ValidationException(validation.Errors); + } + + var host = await contracts.AddHostAsync(id, request); + return TypedResults.Created( + $"{TenantsConstants.RoutePrefix}/{id}/hosts/{host.Id}", + host + ); + } + ) + .RequirePermission(TenantsPermissions.ManageHosts); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/AddHostRequestValidator.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/AddHostRequestValidator.cs new file mode 100644 index 00000000..6f949cef --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/AddHostRequestValidator.cs @@ -0,0 +1,21 @@ +using SimpleModule.Core.Validation; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Endpoints.Tenants; + +public static class AddHostRequestValidator +{ + public static ValidationResult Validate(AddTenantHostRequest request) => + new ValidationBuilder() + .AddErrorIf( + string.IsNullOrWhiteSpace(request.HostName), + "HostName", + "Host name is required." + ) + .AddErrorIf( + !string.IsNullOrWhiteSpace(request.HostName) && request.HostName.Length > 512, + "HostName", + "Host name must not exceed 512 characters." + ) + .Build(); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/ChangeStatusEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/ChangeStatusEndpoint.cs new file mode 100644 index 00000000..e62360c7 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/ChangeStatusEndpoint.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Endpoints.Tenants; + +public class ChangeStatusEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPut( + "/{id}/status", + (TenantId id, ChangeStatusRequest request, ITenantContracts contracts) => + CrudEndpoints.Update(() => contracts.ChangeStatusAsync(id, request.Status)) + ) + .RequirePermission(TenantsPermissions.ChangeStatus); +} + +public class ChangeStatusRequest +{ + public TenantStatus Status { get; set; } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/CreateEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/CreateEndpoint.cs new file mode 100644 index 00000000..eeaf1611 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/CreateEndpoint.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.Core.Exceptions; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Endpoints.Tenants; + +public class CreateEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPost( + "/", + (CreateTenantRequest request, ITenantContracts contracts) => + { + var validation = CreateRequestValidator.Validate(request); + if (!validation.IsValid) + { + throw new ValidationException(validation.Errors); + } + + return CrudEndpoints.Create( + () => contracts.CreateTenantAsync(request), + t => $"{TenantsConstants.RoutePrefix}/{t.Id}" + ); + } + ) + .RequirePermission(TenantsPermissions.Create); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/CreateRequestValidator.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/CreateRequestValidator.cs new file mode 100644 index 00000000..37182f95 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/CreateRequestValidator.cs @@ -0,0 +1,36 @@ +using System.Text.RegularExpressions; +using SimpleModule.Core.Validation; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Endpoints.Tenants; + +public static partial class CreateRequestValidator +{ + public static ValidationResult Validate(CreateTenantRequest request) => + new ValidationBuilder() + .AddErrorIf( + string.IsNullOrWhiteSpace(request.Name), + "Name", + "Tenant name is required." + ) + .AddErrorIf( + string.IsNullOrWhiteSpace(request.Slug), + "Slug", + "Tenant slug is required." + ) + .AddErrorIf( + !string.IsNullOrWhiteSpace(request.Slug) && !SlugPattern().IsMatch(request.Slug), + "Slug", + "Slug must contain only lowercase letters, numbers, and hyphens." + ) + .AddErrorIf( + !string.IsNullOrWhiteSpace(request.AdminEmail) + && !request.AdminEmail.Contains('@', StringComparison.Ordinal), + "AdminEmail", + "Invalid email format." + ) + .Build(); + + [GeneratedRegex(@"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$")] + private static partial Regex SlugPattern(); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/DeleteEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/DeleteEndpoint.cs new file mode 100644 index 00000000..98fe420f --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/DeleteEndpoint.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Endpoints.Tenants; + +public class DeleteEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapDelete( + "/{id}", + (TenantId id, ITenantContracts contracts) => + CrudEndpoints.Delete(() => contracts.DeleteTenantAsync(id)) + ) + .RequirePermission(TenantsPermissions.Delete); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/GetAllEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/GetAllEndpoint.cs new file mode 100644 index 00000000..39bc9163 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/GetAllEndpoint.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Endpoints.Tenants; + +public class GetAllEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/", (ITenantContracts contracts) => CrudEndpoints.GetAll(contracts.GetAllTenantsAsync)) + .RequirePermission(TenantsPermissions.View); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/GetByIdEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/GetByIdEndpoint.cs new file mode 100644 index 00000000..1fd0e01d --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/GetByIdEndpoint.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Endpoints.Tenants; + +public class GetByIdEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + "/{id}", + (TenantId id, ITenantContracts contracts) => + CrudEndpoints.GetById(() => contracts.GetTenantByIdAsync(id)) + ) + .RequirePermission(TenantsPermissions.View); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/RemoveHostEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/RemoveHostEndpoint.cs new file mode 100644 index 00000000..e7ef40b4 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/RemoveHostEndpoint.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Endpoints.Tenants; + +public class RemoveHostEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapDelete( + "/{id}/hosts/{hostId}", + (TenantId id, TenantHostId hostId, ITenantContracts contracts) => + CrudEndpoints.Delete(() => contracts.RemoveHostAsync(id, hostId)) + ) + .RequirePermission(TenantsPermissions.ManageHosts); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/UpdateEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/UpdateEndpoint.cs new file mode 100644 index 00000000..b6e27c83 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/UpdateEndpoint.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.Core.Exceptions; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Endpoints.Tenants; + +public class UpdateEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPut( + "/{id}", + (TenantId id, UpdateTenantRequest request, ITenantContracts contracts) => + { + var validation = UpdateRequestValidator.Validate(request); + if (!validation.IsValid) + { + throw new ValidationException(validation.Errors); + } + + return CrudEndpoints.Update(() => contracts.UpdateTenantAsync(id, request)); + } + ) + .RequirePermission(TenantsPermissions.Update); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/UpdateRequestValidator.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/UpdateRequestValidator.cs new file mode 100644 index 00000000..64282fbd --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/UpdateRequestValidator.cs @@ -0,0 +1,22 @@ +using SimpleModule.Core.Validation; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Endpoints.Tenants; + +public static class UpdateRequestValidator +{ + public static ValidationResult Validate(UpdateTenantRequest request) => + new ValidationBuilder() + .AddErrorIf( + string.IsNullOrWhiteSpace(request.Name), + "Name", + "Tenant name is required." + ) + .AddErrorIf( + !string.IsNullOrWhiteSpace(request.AdminEmail) + && !request.AdminEmail.Contains('@', StringComparison.Ordinal), + "AdminEmail", + "Invalid email format." + ) + .Build(); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Entities/TenantEntity.cs b/modules/Tenants/src/SimpleModule.Tenants/Entities/TenantEntity.cs new file mode 100644 index 00000000..65749fd5 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Entities/TenantEntity.cs @@ -0,0 +1,16 @@ +using SimpleModule.Core.Entities; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Entities; + +public sealed class TenantEntity : AuditableEntity +{ + public string Name { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public TenantStatus Status { get; set; } + public string? AdminEmail { get; set; } + public string? EditionName { get; set; } + public string? ConnectionString { get; set; } + public DateTimeOffset? ValidUpTo { get; set; } + public ICollection Hosts { get; set; } = []; +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Entities/TenantHostEntity.cs b/modules/Tenants/src/SimpleModule.Tenants/Entities/TenantHostEntity.cs new file mode 100644 index 00000000..91198748 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Entities/TenantHostEntity.cs @@ -0,0 +1,12 @@ +using SimpleModule.Core.Entities; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Entities; + +public sealed class TenantHostEntity : Entity +{ + public TenantId TenantId { get; set; } + public string HostName { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; + public TenantEntity Tenant { get; set; } = null!; +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/EntityConfigurations/TenantConfiguration.cs b/modules/Tenants/src/SimpleModule.Tenants/EntityConfigurations/TenantConfiguration.cs new file mode 100644 index 00000000..19e1e107 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/EntityConfigurations/TenantConfiguration.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimpleModule.Tenants.Contracts; +using SimpleModule.Tenants.Entities; + +namespace SimpleModule.Tenants.EntityConfigurations; + +public sealed class TenantConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).ValueGeneratedOnAdd(); + builder.Property(t => t.Name).IsRequired().HasMaxLength(256); + builder.Property(t => t.Slug).IsRequired().HasMaxLength(128); + builder.HasIndex(t => t.Slug).IsUnique(); + builder.Property(t => t.AdminEmail).HasMaxLength(256); + builder.Property(t => t.EditionName).HasMaxLength(128); + builder.Property(t => t.ConnectionString).HasMaxLength(1024); + + builder + .HasMany(t => t.Hosts) + .WithOne(h => h.Tenant) + .HasForeignKey(h => h.TenantId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasData(GenerateSeedTenants()); + } + + private static TenantEntity[] GenerateSeedTenants() + { + var now = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + return + [ + new TenantEntity + { + Id = TenantId.From(1), + Name = "Acme Corporation", + Slug = "acme", + Status = TenantStatus.Active, + AdminEmail = "admin@acme.com", + EditionName = "Enterprise", + CreatedAt = now, + UpdatedAt = now, + ConcurrencyStamp = "seed-acme", + }, + new TenantEntity + { + Id = TenantId.From(2), + Name = "Contoso Ltd", + Slug = "contoso", + Status = TenantStatus.Active, + AdminEmail = "admin@contoso.com", + EditionName = "Standard", + CreatedAt = now, + UpdatedAt = now, + ConcurrencyStamp = "seed-contoso", + }, + new TenantEntity + { + Id = TenantId.From(3), + Name = "Suspended Corp", + Slug = "suspended-corp", + Status = TenantStatus.Suspended, + AdminEmail = "admin@suspended.com", + CreatedAt = now, + UpdatedAt = now, + ConcurrencyStamp = "seed-suspended", + }, + ]; + } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/EntityConfigurations/TenantHostConfiguration.cs b/modules/Tenants/src/SimpleModule.Tenants/EntityConfigurations/TenantHostConfiguration.cs new file mode 100644 index 00000000..163be540 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/EntityConfigurations/TenantHostConfiguration.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimpleModule.Tenants.Contracts; +using SimpleModule.Tenants.Entities; + +namespace SimpleModule.Tenants.EntityConfigurations; + +public sealed class TenantHostConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(h => h.Id); + builder.Property(h => h.Id).ValueGeneratedOnAdd(); + builder.Property(h => h.HostName).IsRequired().HasMaxLength(512); + builder.HasIndex(h => h.HostName).IsUnique(); + + builder.HasData(GenerateSeedHosts()); + } + + private static TenantHostEntity[] GenerateSeedHosts() + { + var now = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + return + [ + new TenantHostEntity + { + Id = TenantHostId.From(1), + TenantId = TenantId.From(1), + HostName = "acme.localhost", + IsActive = true, + CreatedAt = now, + UpdatedAt = now, + ConcurrencyStamp = "seed-host-1", + }, + new TenantHostEntity + { + Id = TenantHostId.From(2), + TenantId = TenantId.From(1), + HostName = "acme.local", + IsActive = true, + CreatedAt = now, + UpdatedAt = now, + ConcurrencyStamp = "seed-host-2", + }, + new TenantHostEntity + { + Id = TenantHostId.From(3), + TenantId = TenantId.From(2), + HostName = "contoso.localhost", + IsActive = true, + CreatedAt = now, + UpdatedAt = now, + ConcurrencyStamp = "seed-host-3", + }, + ]; + } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Middleware/TenantResolutionMiddleware.cs b/modules/Tenants/src/SimpleModule.Tenants/Middleware/TenantResolutionMiddleware.cs new file mode 100644 index 00000000..9aa874fd --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Middleware/TenantResolutionMiddleware.cs @@ -0,0 +1,122 @@ +using System.Data.Common; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SimpleModule.Tenants.Contracts; +using SimpleModule.Tenants.Resolvers; + +namespace SimpleModule.Tenants.Middleware; + +public sealed partial class TenantResolutionMiddleware( + RequestDelegate next, + ILogger logger +) +{ + private static readonly HashSet SkipExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".css", + ".js", + ".mjs", + ".map", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".svg", + ".ico", + ".woff", + ".woff2", + ".ttf", + ".eot", + }; + + public async Task InvokeAsync(HttpContext context) + { + var path = context.Request.Path.Value; + + if (ShouldSkip(path)) + { + await next(context); + return; + } + + var tenantContext = context.RequestServices.GetRequiredService(); + + try + { + var hostResolver = + context.RequestServices.GetRequiredService(); + var tenantId = await hostResolver.ResolveAsync(context); + tenantId ??= HeaderTenantResolver.Resolve(context); + tenantId ??= ClaimTenantResolver.Resolve(context); + + if (tenantId is not null) + { + tenantContext.TenantId = tenantId; + LogTenantResolved(logger, tenantId, path); + } + } + catch (DbException ex) + { + LogResolutionFailed(logger, path, ex); + } + catch (InvalidOperationException ex) + { + LogResolutionFailed(logger, path, ex); + } + + await next(context); + } + + private static bool ShouldSkip(string? path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + if (path.StartsWith("/_content/", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/js/", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var lastDot = path.LastIndexOf('.'); + if (lastDot >= 0) + { + var extension = path[lastDot..]; + if (SkipExtensions.Contains(extension)) + { + return true; + } + } + + if (path.StartsWith("/api/tenants", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/tenants", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Tenant {TenantId} resolved for request {Path}" + )] + private static partial void LogTenantResolved( + ILogger logger, + string tenantId, + string? path + ); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Tenant resolution failed for request {Path}" + )] + private static partial void LogResolutionFailed( + ILogger logger, + string? path, + Exception exception + ); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Pages/index.ts b/modules/Tenants/src/SimpleModule.Tenants/Pages/index.ts new file mode 100644 index 00000000..400e0698 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Pages/index.ts @@ -0,0 +1,7 @@ +export const pages: Record = { + 'Tenants/Browse': () => import('../Views/Browse'), + 'Tenants/Manage': () => import('../Views/Manage'), + 'Tenants/Create': () => import('../Views/Create'), + 'Tenants/Edit': () => import('../Views/Edit'), + 'Tenants/Features': () => import('../Views/Features'), +}; diff --git a/modules/Tenants/src/SimpleModule.Tenants/Resolvers/ClaimTenantResolver.cs b/modules/Tenants/src/SimpleModule.Tenants/Resolvers/ClaimTenantResolver.cs new file mode 100644 index 00000000..9a46cb6c --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Resolvers/ClaimTenantResolver.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Http; + +namespace SimpleModule.Tenants.Resolvers; + +internal static class ClaimTenantResolver +{ + public static string? Resolve(HttpContext context) + { + var claim = context.User?.FindFirst(TenantsConstants.TenantClaimType); + return claim?.Value; + } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Resolvers/HeaderTenantResolver.cs b/modules/Tenants/src/SimpleModule.Tenants/Resolvers/HeaderTenantResolver.cs new file mode 100644 index 00000000..d8f57579 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Resolvers/HeaderTenantResolver.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Http; + +namespace SimpleModule.Tenants.Resolvers; + +internal static class HeaderTenantResolver +{ + public static string? Resolve(HttpContext context) + { + if (context.Request.Headers.TryGetValue(TenantsConstants.TenantIdHeader, out var values)) + { + var value = values.ToString(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + return null; + } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Resolvers/HostNameTenantResolver.cs b/modules/Tenants/src/SimpleModule.Tenants/Resolvers/HostNameTenantResolver.cs new file mode 100644 index 00000000..194eccbe --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Resolvers/HostNameTenantResolver.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace SimpleModule.Tenants.Resolvers; + +public sealed class HostNameTenantResolver( + TenantsDbContext db, + IMemoryCache cache +) +{ + private static readonly MemoryCacheEntryOptions CacheOptions = new() + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5), + }; + + public async Task ResolveAsync(HttpContext context) + { + var host = context.Request.Host.Host; + if (string.IsNullOrEmpty(host)) + { + return null; + } + + var cacheKey = $"tenant:host:{host}"; + return await cache.GetOrCreateAsync(cacheKey, async entry => + { + entry.SetOptions(CacheOptions); + + var tenantHost = await db + .TenantHosts.AsNoTracking() + .Where(h => h.HostName == host && h.IsActive) + .Select(h => (int?)h.TenantId.Value) + .FirstOrDefaultAsync(); + + return tenantHost?.ToString(System.Globalization.CultureInfo.InvariantCulture); + }); + } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/SimpleModule.Tenants.csproj b/modules/Tenants/src/SimpleModule.Tenants/SimpleModule.Tenants.csproj new file mode 100644 index 00000000..8ba34ef9 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/SimpleModule.Tenants.csproj @@ -0,0 +1,17 @@ + + + net10.0 + + + + + + + + + + + %(Filename)Endpoint.cs + + + diff --git a/modules/Tenants/src/SimpleModule.Tenants/TenantContext.cs b/modules/Tenants/src/SimpleModule.Tenants/TenantContext.cs new file mode 100644 index 00000000..d4b8c9b4 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/TenantContext.cs @@ -0,0 +1,8 @@ +using SimpleModule.Core.Entities; + +namespace SimpleModule.Tenants; + +public class TenantContext : ITenantContext +{ + public string? TenantId { get; set; } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs b/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs new file mode 100644 index 00000000..a32cc696 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs @@ -0,0 +1,268 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SimpleModule.Core.Events; +using SimpleModule.Core.Exceptions; +using SimpleModule.Tenants.Contracts; +using SimpleModule.Tenants.Contracts.Events; +using SimpleModule.Tenants.Entities; + +namespace SimpleModule.Tenants; + +public sealed partial class TenantService( + TenantsDbContext db, + IEventBus eventBus, + ILogger logger +) : ITenantContracts +{ + public async Task> GetAllTenantsAsync() => + await db + .Tenants.AsNoTracking() + .Include(t => t.Hosts) + .Select(t => MapToDto(t)) + .ToListAsync(); + + public async Task GetTenantByIdAsync(TenantId id) + { + var entity = await db + .Tenants.AsNoTracking() + .Include(t => t.Hosts) + .FirstOrDefaultAsync(t => t.Id == id); + + if (entity is null) + { + LogTenantNotFound(logger, id); + return null; + } + + return MapToDto(entity); + } + + public async Task GetTenantBySlugAsync(string slug) + { + var entity = await db + .Tenants.AsNoTracking() + .Include(t => t.Hosts) + .FirstOrDefaultAsync(t => t.Slug == slug); + + return entity is null ? null : MapToDto(entity); + } + + public async Task GetTenantByHostNameAsync(string hostName) + { + var entity = await db + .Tenants.AsNoTracking() + .Include(t => t.Hosts) + .Where(t => t.Hosts.Any(h => h.HostName == hostName && h.IsActive)) + .FirstOrDefaultAsync(); + + return entity is null ? null : MapToDto(entity); + } + + public async Task CreateTenantAsync(CreateTenantRequest request) + { + var entity = new TenantEntity + { + Name = request.Name, + Slug = request.Slug, + Status = TenantStatus.Active, + AdminEmail = request.AdminEmail, + EditionName = request.EditionName, + ConnectionString = request.ConnectionString, + ValidUpTo = request.ValidUpTo, + }; + + if (request.Hosts is { Count: > 0 }) + { + foreach (var hostName in request.Hosts) + { + entity.Hosts.Add(new TenantHostEntity { HostName = hostName }); + } + } + + db.Tenants.Add(entity); + await db.SaveChangesAsync(); + + LogTenantCreated(logger, entity.Id, entity.Name); + await eventBus.PublishAsync( + new TenantCreatedEvent(entity.Id, entity.Name, entity.Slug) + ); + + return MapToDto(entity); + } + + public async Task UpdateTenantAsync(TenantId id, UpdateTenantRequest request) + { + var entity = await db.Tenants.FindAsync(id); + if (entity is null) + { + throw new NotFoundException("Tenant", id); + } + + entity.Name = request.Name; + entity.AdminEmail = request.AdminEmail; + entity.EditionName = request.EditionName; + entity.ConnectionString = request.ConnectionString; + entity.ValidUpTo = request.ValidUpTo; + + await db.SaveChangesAsync(); + + LogTenantUpdated(logger, entity.Id, entity.Name); + await eventBus.PublishAsync(new TenantUpdatedEvent(entity.Id, entity.Name)); + + return (await GetTenantByIdAsync(id))!; + } + + public async Task DeleteTenantAsync(TenantId id) + { + var entity = await db.Tenants.FindAsync(id); + if (entity is null) + { + throw new NotFoundException("Tenant", id); + } + + db.Tenants.Remove(entity); + await db.SaveChangesAsync(); + + LogTenantDeleted(logger, id); + } + + public async Task ChangeStatusAsync(TenantId id, TenantStatus status) + { + var entity = await db.Tenants.FindAsync(id); + if (entity is null) + { + throw new NotFoundException("Tenant", id); + } + + var oldStatus = entity.Status; + entity.Status = status; + await db.SaveChangesAsync(); + + LogTenantStatusChanged(logger, id, oldStatus, status); + await eventBus.PublishAsync(new TenantStatusChangedEvent(id, oldStatus, status)); + + return (await GetTenantByIdAsync(id))!; + } + + public async Task AddHostAsync(TenantId tenantId, AddTenantHostRequest request) + { + var tenant = await db.Tenants.FindAsync(tenantId); + if (tenant is null) + { + throw new NotFoundException("Tenant", tenantId); + } + + var hostEntity = new TenantHostEntity + { + TenantId = tenantId, + HostName = request.HostName, + }; + + db.TenantHosts.Add(hostEntity); + await db.SaveChangesAsync(); + + LogHostAdded(logger, tenantId, request.HostName); + await eventBus.PublishAsync(new TenantHostAddedEvent(tenantId, request.HostName)); + + return MapHostToDto(hostEntity); + } + + public async Task RemoveHostAsync(TenantId tenantId, TenantHostId hostId) + { + var host = await db + .TenantHosts.FirstOrDefaultAsync(h => h.Id == hostId && h.TenantId == tenantId); + if (host is null) + { + throw new NotFoundException("TenantHost", hostId); + } + + var hostName = host.HostName; + db.TenantHosts.Remove(host); + await db.SaveChangesAsync(); + + LogHostRemoved(logger, tenantId, hostName); + await eventBus.PublishAsync(new TenantHostRemovedEvent(tenantId, hostName)); + } + + private static Tenant MapToDto(TenantEntity entity) => + new() + { + Id = entity.Id, + Name = entity.Name, + Slug = entity.Slug, + Status = entity.Status, + AdminEmail = entity.AdminEmail, + EditionName = entity.EditionName, + ConnectionString = entity.ConnectionString, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt, + ValidUpTo = entity.ValidUpTo, + Hosts = entity.Hosts.Select(MapHostToDto).ToList(), + }; + + private static TenantHost MapHostToDto(TenantHostEntity entity) => + new() + { + Id = entity.Id, + TenantId = entity.TenantId, + HostName = entity.HostName, + IsActive = entity.IsActive, + }; + + [LoggerMessage(Level = LogLevel.Warning, Message = "Tenant with ID {TenantId} not found")] + private static partial void LogTenantNotFound(ILogger logger, TenantId tenantId); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Tenant {TenantId} created: {TenantName}" + )] + private static partial void LogTenantCreated( + ILogger logger, + TenantId tenantId, + string tenantName + ); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Tenant {TenantId} updated: {TenantName}" + )] + private static partial void LogTenantUpdated( + ILogger logger, + TenantId tenantId, + string tenantName + ); + + [LoggerMessage(Level = LogLevel.Information, Message = "Tenant {TenantId} deleted")] + private static partial void LogTenantDeleted(ILogger logger, TenantId tenantId); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Tenant {TenantId} status changed from {OldStatus} to {NewStatus}" + )] + private static partial void LogTenantStatusChanged( + ILogger logger, + TenantId tenantId, + TenantStatus oldStatus, + TenantStatus newStatus + ); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Host {HostName} added to tenant {TenantId}" + )] + private static partial void LogHostAdded( + ILogger logger, + TenantId tenantId, + string hostName + ); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Host {HostName} removed from tenant {TenantId}" + )] + private static partial void LogHostRemoved( + ILogger logger, + TenantId tenantId, + string hostName + ); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/TenantsConstants.cs b/modules/Tenants/src/SimpleModule.Tenants/TenantsConstants.cs new file mode 100644 index 00000000..283848c8 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/TenantsConstants.cs @@ -0,0 +1,9 @@ +namespace SimpleModule.Tenants; + +public static class TenantsConstants +{ + public const string ModuleName = "Tenants"; + public const string RoutePrefix = "/api/tenants"; + public const string TenantIdHeader = "X-Tenant-Id"; + public const string TenantClaimType = "tenant_id"; +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/TenantsDbContext.cs b/modules/Tenants/src/SimpleModule.Tenants/TenantsDbContext.cs new file mode 100644 index 00000000..611d5275 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/TenantsDbContext.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.Tenants.Contracts; +using SimpleModule.Tenants.Entities; +using SimpleModule.Tenants.EntityConfigurations; + +namespace SimpleModule.Tenants; + +public class TenantsDbContext( + DbContextOptions options, + IOptions dbOptions +) : DbContext(options) +{ + public DbSet Tenants => Set(); + public DbSet TenantHosts => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new TenantConfiguration()); + modelBuilder.ApplyConfiguration(new TenantHostConfiguration()); + modelBuilder.ApplyModuleSchema("Tenants", dbOptions.Value); + } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder + .Properties() + .HaveConversion(); + configurationBuilder + .Properties() + .HaveConversion(); + } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/TenantsModule.cs b/modules/Tenants/src/SimpleModule.Tenants/TenantsModule.cs new file mode 100644 index 00000000..35bd62cc --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/TenantsModule.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; +using SimpleModule.Core.Entities; +using SimpleModule.Core.Menu; +using SimpleModule.Database; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants; + +[Module( + TenantsConstants.ModuleName, + RoutePrefix = TenantsConstants.RoutePrefix, + ViewPrefix = "/tenants" +)] +public class TenantsModule : IModule +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddMemoryCache(); + services.AddModuleDbContext(configuration, TenantsConstants.ModuleName); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(); + } + + public void ConfigureMenu(IMenuBuilder menus) + { + menus.Add( + new MenuItem + { + Label = "Tenants", + Url = "/tenants/manage", + Icon = + """""", + Order = 50, + Section = MenuSection.AdminSidebar, + } + ); + } + + public void ConfigureMiddleware(IApplicationBuilder app) + { + app.UseMiddleware(); + } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Views/Browse.tsx b/modules/Tenants/src/SimpleModule.Tenants/Views/Browse.tsx new file mode 100644 index 00000000..1c3b6dc8 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Views/Browse.tsx @@ -0,0 +1,37 @@ +import { Card, CardContent, PageShell } from '@simplemodule/ui'; +import { statusColors, statusLabels } from './tenantStatus'; + +interface BrowseTenant { + id: number; + name: string; + slug: string; + status: number; + hostCount: number; +} + +export default function Browse({ tenants }: { tenants: BrowseTenant[] }) { + return ( + + + {tenants.map((t) => ( + + + + {t.name} + ({t.slug}) + + + + {t.hostCount} host{t.hostCount !== 1 ? 's' : ''} + + + {statusLabels[t.status]} + + + + + ))} + + + ); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Views/BrowseEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Views/BrowseEndpoint.cs new file mode 100644 index 00000000..52d56b2d --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Views/BrowseEndpoint.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Views; + +[ViewPage("Tenants/Browse")] +public class BrowseEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/browse", + async (ITenantContracts contracts) => + { + var tenants = (await contracts.GetAllTenantsAsync()) + .Select(t => new + { + t.Id, + t.Name, + t.Slug, + t.Status, + HostCount = t.Hosts.Count, + }); + return Inertia.Render("Tenants/Browse", new { tenants }); + } + ) + .AllowAnonymous(); + } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Views/Create.tsx b/modules/Tenants/src/SimpleModule.Tenants/Views/Create.tsx new file mode 100644 index 00000000..81d2183e --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Views/Create.tsx @@ -0,0 +1,83 @@ +import { router } from '@inertiajs/react'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, + Button, + Card, + CardContent, + Container, + Field, + FieldGroup, + Input, + Label, +} from '@simplemodule/ui'; + +export default function Create() { + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + router.post('/api/tenants', formData); + } + + return ( + + + + + Tenants + + + + Create Tenant + + + + Create Tenant + + + + + + + Name + + + + Slug + + + + Admin Email + + + + Edition + + + + Valid Until + + + Create + + + + + + ); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Views/CreateEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Views/CreateEndpoint.cs new file mode 100644 index 00000000..9f9aa42b --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Views/CreateEndpoint.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Inertia; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Views; + +[ViewPage("Tenants/Create")] +public class CreateEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/create", () => Inertia.Render("Tenants/Create")) + .RequirePermission(TenantsPermissions.Create); + } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Views/Edit.tsx b/modules/Tenants/src/SimpleModule.Tenants/Views/Edit.tsx new file mode 100644 index 00000000..f02cf9a3 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Views/Edit.tsx @@ -0,0 +1,172 @@ +import { router } from '@inertiajs/react'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, + Button, + Card, + CardContent, + Container, + Field, + FieldGroup, + Input, + Label, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simplemodule/ui'; +import { useState } from 'react'; +import type { Tenant } from '../types'; +import { statusLabels } from './tenantStatus'; + +export default function Edit({ tenant }: { tenant: Tenant }) { + const [newHost, setNewHost] = useState(''); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + router.put(`/api/tenants/${tenant.id}`, Object.fromEntries(formData)); + } + + function handleAddHost() { + if (!newHost.trim()) return; + router.post( + `/api/tenants/${tenant.id}/hosts`, + { hostName: newHost.trim() }, + { preserveScroll: true }, + ); + setNewHost(''); + } + + function handleRemoveHost(hostId: number) { + router.delete(`/api/tenants/${tenant.id}/hosts/${hostId}`, { preserveScroll: true }); + } + + function handleStatusChange(status: number) { + router.put(`/api/tenants/${tenant.id}/status`, { status }, { preserveScroll: true }); + } + + return ( + + + + + Tenants + + + + Edit {tenant.name} + + + + + Edit Tenant + router.get(`/tenants/${tenant.id}/features`)}> + Manage Features + + + + + + + + + Name + + + + Admin Email + + + + Edition + + + + Valid Until + + + Save Changes + + + + + + + + Status + + {[0, 1, 2].map((s) => ( + handleStatusChange(s)} + > + {statusLabels[s]} + + ))} + + + + + + + Hosts + {tenant.hosts.length > 0 && ( + + + + Host Name + Active + + + + + {tenant.hosts.map((host) => ( + + {host.hostName} + {host.isActive ? 'Yes' : 'No'} + + handleRemoveHost(host.id)}> + Remove + + + + ))} + + + )} + + setNewHost(e.target.value)} + /> + Add Host + + + + + ); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Views/EditEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Views/EditEndpoint.cs new file mode 100644 index 00000000..e28f4246 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Views/EditEndpoint.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Inertia; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Views; + +[ViewPage("Tenants/Edit")] +public class EditEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/{id}/edit", + async (TenantId id, ITenantContracts contracts) => + { + var tenant = await contracts.GetTenantByIdAsync(id); + if (tenant is null) + { + return Results.NotFound(); + } + + return Inertia.Render("Tenants/Edit", new { tenant }); + } + ) + .RequirePermission(TenantsPermissions.Update); + } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Views/Features.tsx b/modules/Tenants/src/SimpleModule.Tenants/Views/Features.tsx new file mode 100644 index 00000000..a8e4e058 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Views/Features.tsx @@ -0,0 +1,147 @@ +import { router } from '@inertiajs/react'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, + Button, + Card, + CardContent, + Container, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simplemodule/ui'; + +interface FeatureFlag { + name: string; + description: string; + isEnabled: boolean; + defaultEnabled: boolean; + isDeprecated: boolean; +} + +interface FeatureFlagOverride { + id: number; + flagName: string; + overrideType: number; + overrideValue: string; + isEnabled: boolean; +} + +interface Tenant { + id: number; + name: string; + slug: string; +} + +interface Props { + tenant: Tenant; + flags: FeatureFlag[]; + tenantOverrides: FeatureFlagOverride[]; +} + +export default function Features({ tenant, flags, tenantOverrides }: Props) { + const overrideMap = new Map(tenantOverrides.map((o) => [o.flagName, o])); + + function handleToggle(flagName: string, currentlyEnabled: boolean) { + router.put( + `/api/tenants/${tenant.id}/features/${flagName}`, + { isEnabled: !currentlyEnabled }, + { preserveScroll: true }, + ); + } + + function handleReset(flagName: string) { + router.delete(`/api/tenants/${tenant.id}/features/${flagName}`, { preserveScroll: true }); + } + + if (flags.length === 0) { + return ( + + No Feature Flags Available + + The Feature Flags module is not installed or has no flags. + + + ); + } + + return ( + + + + + Tenants + + + + {tenant.name} + + + + Feature Flags + + + + Feature Flags for {tenant.name} + + + + + + + Flag + Description + Global + Tenant Override + + + + + {flags + .filter((f) => !f.isDeprecated) + .map((flag) => { + const override = overrideMap.get(flag.name); + const effectiveState = override ? override.isEnabled : flag.isEnabled; + + return ( + + {flag.name} + {flag.description || '-'} + + + {flag.isEnabled ? 'On' : 'Off'} + + + + handleToggle(flag.name, effectiveState)} + > + {effectiveState ? 'Enabled' : 'Disabled'} + + + + {override && ( + handleReset(flag.name)}> + Reset + + )} + + + ); + })} + + + + + + ); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Views/FeaturesEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Views/FeaturesEndpoint.cs new file mode 100644 index 00000000..97d88c7a --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Views/FeaturesEndpoint.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Inertia; +using SimpleModule.FeatureFlags.Contracts; +using SimpleModule.Tenants.Contracts; +using SimpleModule.Tenants.Endpoints.TenantFeatures; + +namespace SimpleModule.Tenants.Views; + +[ViewPage("Tenants/Features")] +public class FeaturesEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/{id}/features", + async (TenantId id, ITenantContracts contracts, HttpContext context) => + { + var tenant = await contracts.GetTenantByIdAsync(id); + if (tenant is null) + { + return Results.NotFound(); + } + + var featureFlags = context.RequestServices.GetService(); + if (featureFlags is null) + { + return Inertia.Render( + "Tenants/Features", + new { tenant, flags = Array.Empty(), tenantOverrides = Array.Empty() } + ); + } + + var flags = (await featureFlags.GetAllFlagsAsync()).ToList(); + var tenantOverrides = await TenantFeatureHelper.GetOverridesForTenantAsync( + featureFlags, flags, id); + + return Inertia.Render( + "Tenants/Features", + new { tenant, flags, tenantOverrides } + ); + } + ) + .RequirePermission(TenantsPermissions.View); + } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Views/Manage.tsx b/modules/Tenants/src/SimpleModule.Tenants/Views/Manage.tsx new file mode 100644 index 00000000..5ad445ba --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Views/Manage.tsx @@ -0,0 +1,120 @@ +import { router } from '@inertiajs/react'; +import { + Button, + DataGridPage, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simplemodule/ui'; +import { useState } from 'react'; +import type { Tenant } from '../types'; +import { statusColors, statusLabels } from './tenantStatus'; + +export default function Manage({ tenants }: { tenants: Tenant[] }) { + const [deleteTarget, setDeleteTarget] = useState<{ id: number; name: string } | null>(null); + + function handleDelete() { + if (!deleteTarget) return; + router.delete(`/api/tenants/${deleteTarget.id}`); + setDeleteTarget(null); + } + + return ( + <> + router.get('/tenants/create')}>Create Tenant} + data={tenants} + emptyTitle="No tenants yet" + emptyDescription="Get started by creating your first tenant." + > + {(pageData) => ( + + + + ID + Name + Slug + Status + Hosts + Edition + + + + + {pageData.map((tenant) => ( + + #{tenant.id} + {tenant.name} + {tenant.slug} + + + {statusLabels[tenant.status]} + + + {tenant.hosts.length} + {tenant.editionName ?? '-'} + + + router.get(`/tenants/${tenant.id}/edit`)} + > + Edit + + router.get(`/tenants/${tenant.id}/features`)} + > + Features + + setDeleteTarget({ id: tenant.id, name: tenant.name })} + > + Delete + + + + + ))} + + + )} + + + !open && setDeleteTarget(null)}> + + + Delete Tenant + + Are you sure you want to delete “{deleteTarget?.name}”? This action cannot + be undone. + + + + setDeleteTarget(null)}> + Cancel + + + Delete + + + + + > + ); +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Views/ManageEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Views/ManageEndpoint.cs new file mode 100644 index 00000000..35ee963c --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Views/ManageEndpoint.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Inertia; +using SimpleModule.Tenants.Contracts; + +namespace SimpleModule.Tenants.Views; + +[ViewPage("Tenants/Manage")] +public class ManageEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/manage", + async (ITenantContracts contracts) => + Inertia.Render( + "Tenants/Manage", + new { tenants = await contracts.GetAllTenantsAsync() } + ) + ) + .RequirePermission(TenantsPermissions.View); + } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Views/tenantStatus.ts b/modules/Tenants/src/SimpleModule.Tenants/Views/tenantStatus.ts new file mode 100644 index 00000000..ef790575 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/Views/tenantStatus.ts @@ -0,0 +1,11 @@ +export const statusLabels: Record = { + 0: 'Active', + 1: 'Suspended', + 2: 'Inactive', +}; + +export const statusColors: Record = { + 0: 'text-green-600', + 1: 'text-yellow-600', + 2: 'text-red-600', +}; diff --git a/modules/Tenants/src/SimpleModule.Tenants/package.json b/modules/Tenants/src/SimpleModule.Tenants/package.json new file mode 100644 index 00000000..930803ac --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/package.json @@ -0,0 +1,14 @@ +{ + "private": true, + "name": "@simplemodule/tenants", + "version": "0.0.0", + "scripts": { + "build": "cross-env VITE_MODE=prod vite build", + "build:dev": "cross-env VITE_MODE=dev vite build", + "watch": "cross-env VITE_MODE=dev vite build --watch" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/modules/Tenants/src/SimpleModule.Tenants/types.ts b/modules/Tenants/src/SimpleModule.Tenants/types.ts new file mode 100644 index 00000000..c173b2de --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/types.ts @@ -0,0 +1,47 @@ +// Auto-generated from [Dto] types — do not edit +export interface Tenant { + id: number; + name: string; + slug: string; + status: any; + adminEmail: string; + editionName: string; + connectionString: string; + createdAt: string; + updatedAt: string; + validUpTo: string | null; + hosts: TenantHost[]; +} + +export interface TenantHost { + id: number; + tenantId: number; + hostName: string; + isActive: boolean; +} + +export interface AddTenantHostRequest { + hostName: string; +} + +export interface CreateTenantRequest { + name: string; + slug: string; + adminEmail: string; + editionName: string; + connectionString: string; + validUpTo: string | null; + hosts: string[]; +} + +export interface TenantsPermissions { +} + +export interface UpdateTenantRequest { + name: string; + adminEmail: string; + editionName: string; + connectionString: string; + validUpTo: string | null; +} + diff --git a/modules/Tenants/src/SimpleModule.Tenants/vite.config.ts b/modules/Tenants/src/SimpleModule.Tenants/vite.config.ts new file mode 100644 index 00000000..a3d37c71 --- /dev/null +++ b/modules/Tenants/src/SimpleModule.Tenants/vite.config.ts @@ -0,0 +1,3 @@ +import { defineModuleConfig } from '@simplemodule/client/module'; + +export default defineModuleConfig(__dirname); diff --git a/modules/Tenants/tests/SimpleModule.Tenants.Tests/GlobalUsings.cs b/modules/Tenants/tests/SimpleModule.Tenants.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/modules/Tenants/tests/SimpleModule.Tenants.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/modules/Tenants/tests/SimpleModule.Tenants.Tests/SimpleModule.Tenants.Tests.csproj b/modules/Tenants/tests/SimpleModule.Tenants.Tests/SimpleModule.Tenants.Tests.csproj new file mode 100644 index 00000000..12ab737c --- /dev/null +++ b/modules/Tenants/tests/SimpleModule.Tenants.Tests/SimpleModule.Tenants.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0 + false + Exe + + + + + + + + + + + + + + + + + diff --git a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/CreateRequestValidatorTests.cs b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/CreateRequestValidatorTests.cs new file mode 100644 index 00000000..42b6433a --- /dev/null +++ b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/CreateRequestValidatorTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using SimpleModule.Tenants.Contracts; +using SimpleModule.Tenants.Endpoints.Tenants; + +namespace Tenants.Tests.Unit; + +public class CreateRequestValidatorTests +{ + [Fact] + public void Validate_WithValidRequest_ReturnsSuccess() + { + var request = new CreateTenantRequest { Name = "Test", Slug = "test" }; + var result = CreateRequestValidator.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Validate_WithEmptyName_ReturnsError() + { + var request = new CreateTenantRequest { Name = "", Slug = "test" }; + var result = CreateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Name"); + } + + [Fact] + public void Validate_WithEmptySlug_ReturnsError() + { + var request = new CreateTenantRequest { Name = "Test", Slug = "" }; + var result = CreateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Slug"); + } + + [Fact] + public void Validate_WithInvalidSlug_ReturnsError() + { + var request = new CreateTenantRequest { Name = "Test", Slug = "INVALID SLUG!" }; + var result = CreateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Slug"); + } + + [Fact] + public void Validate_WithInvalidEmail_ReturnsError() + { + var request = new CreateTenantRequest + { + Name = "Test", + Slug = "test", + AdminEmail = "not-an-email", + }; + var result = CreateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("AdminEmail"); + } +} diff --git a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantHostIdTests.cs b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantHostIdTests.cs new file mode 100644 index 00000000..3b162457 --- /dev/null +++ b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantHostIdTests.cs @@ -0,0 +1,24 @@ +using FluentAssertions; +using SimpleModule.Tenants.Contracts; + +namespace Tenants.Tests.Unit; + +public class TenantHostIdTests +{ + [Fact] + public void From_CreatesValueObject() + { + var id = TenantHostId.From(42); + + id.Value.Should().Be(42); + } + + [Fact] + public void Equals_WithSameValue_ReturnsTrue() + { + var id1 = TenantHostId.From(1); + var id2 = TenantHostId.From(1); + + id1.Should().Be(id2); + } +} diff --git a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantIdTests.cs b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantIdTests.cs new file mode 100644 index 00000000..0ec3cf19 --- /dev/null +++ b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantIdTests.cs @@ -0,0 +1,33 @@ +using FluentAssertions; +using SimpleModule.Tenants.Contracts; + +namespace Tenants.Tests.Unit; + +public class TenantIdTests +{ + [Fact] + public void From_CreatesValueObject() + { + var id = TenantId.From(42); + + id.Value.Should().Be(42); + } + + [Fact] + public void Equals_WithSameValue_ReturnsTrue() + { + var id1 = TenantId.From(1); + var id2 = TenantId.From(1); + + id1.Should().Be(id2); + } + + [Fact] + public void Equals_WithDifferentValue_ReturnsFalse() + { + var id1 = TenantId.From(1); + var id2 = TenantId.From(2); + + id1.Should().NotBe(id2); + } +} diff --git a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantServiceTests.cs b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantServiceTests.cs new file mode 100644 index 00000000..c13c998a --- /dev/null +++ b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantServiceTests.cs @@ -0,0 +1,195 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SimpleModule.Core.Events; +using SimpleModule.Core.Exceptions; +using SimpleModule.Database; +using SimpleModule.Tenants; +using SimpleModule.Tenants.Contracts; + +namespace Tenants.Tests.Unit; + +public sealed class TenantServiceTests : IDisposable +{ + private readonly TenantsDbContext _db; + private readonly TenantService _sut; + private readonly TestEventBus _eventBus = new(); + + public TenantServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + .Options; + var dbOptions = Options.Create( + new DatabaseOptions + { + ModuleConnections = new Dictionary + { + ["Tenants"] = "Data Source=:memory:", + }, + } + ); + _db = new TenantsDbContext(options, dbOptions); + _db.Database.OpenConnection(); + _db.Database.EnsureCreated(); + _sut = new TenantService(_db, _eventBus, NullLogger.Instance); + } + + public void Dispose() => _db.Dispose(); + + [Fact] + public async Task GetAllTenantsAsync_ReturnsSeedTenants() + { + var tenants = await _sut.GetAllTenantsAsync(); + + tenants.Should().NotBeEmpty(); + tenants.Should().Contain(t => t.Slug == "acme"); + tenants.Should().Contain(t => t.Slug == "contoso"); + } + + [Fact] + public async Task GetTenantByIdAsync_WithExistingId_ReturnsTenant() + { + var tenant = await _sut.GetTenantByIdAsync(TenantId.From(1)); + + tenant.Should().NotBeNull(); + tenant!.Name.Should().Be("Acme Corporation"); + tenant.Hosts.Should().HaveCount(2); + } + + [Fact] + public async Task GetTenantByIdAsync_WithNonExistentId_ReturnsNull() + { + var tenant = await _sut.GetTenantByIdAsync(TenantId.From(99999)); + + tenant.Should().BeNull(); + } + + [Fact] + public async Task CreateTenantAsync_CreatesAndReturnsTenant() + { + var request = new CreateTenantRequest + { + Name = "New Tenant", + Slug = "new-tenant", + AdminEmail = "admin@new.com", + Hosts = ["new.localhost"], + }; + + var tenant = await _sut.CreateTenantAsync(request); + + tenant.Should().NotBeNull(); + tenant.Name.Should().Be("New Tenant"); + tenant.Slug.Should().Be("new-tenant"); + tenant.Status.Should().Be(TenantStatus.Active); + tenant.Hosts.Should().HaveCount(1); + tenant.Hosts[0].HostName.Should().Be("new.localhost"); + _eventBus.PublishedEvents.Should().ContainSingle(e => e is SimpleModule.Tenants.Contracts.Events.TenantCreatedEvent); + } + + [Fact] + public async Task UpdateTenantAsync_WithValidData_UpdatesTenant() + { + var request = new UpdateTenantRequest { Name = "Updated Acme" }; + var updated = await _sut.UpdateTenantAsync(TenantId.From(1), request); + + updated.Name.Should().Be("Updated Acme"); + _eventBus.PublishedEvents.Should().Contain(e => e is SimpleModule.Tenants.Contracts.Events.TenantUpdatedEvent); + } + + [Fact] + public async Task UpdateTenantAsync_WithNonExistentId_ThrowsNotFoundException() + { + var request = new UpdateTenantRequest { Name = "Test" }; + var act = () => _sut.UpdateTenantAsync(TenantId.From(99999), request); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteTenantAsync_WithExistingId_RemovesTenant() + { + var create = new CreateTenantRequest { Name = "ToDelete", Slug = "to-delete" }; + var created = await _sut.CreateTenantAsync(create); + + await _sut.DeleteTenantAsync(created.Id); + + var found = await _sut.GetTenantByIdAsync(created.Id); + found.Should().BeNull(); + } + + [Fact] + public async Task ChangeStatusAsync_ChangesStatusAndPublishesEvent() + { + var result = await _sut.ChangeStatusAsync(TenantId.From(1), TenantStatus.Suspended); + + result.Status.Should().Be(TenantStatus.Suspended); + _eventBus.PublishedEvents.Should() + .Contain(e => e is SimpleModule.Tenants.Contracts.Events.TenantStatusChangedEvent); + } + + [Fact] + public async Task AddHostAsync_AddsHostToTenant() + { + var host = await _sut.AddHostAsync( + TenantId.From(2), + new AddTenantHostRequest { HostName = "new.contoso.com" } + ); + + host.HostName.Should().Be("new.contoso.com"); + host.TenantId.Should().Be(TenantId.From(2)); + + var tenant = await _sut.GetTenantByIdAsync(TenantId.From(2)); + tenant!.Hosts.Should().Contain(h => h.HostName == "new.contoso.com"); + } + + [Fact] + public async Task RemoveHostAsync_RemovesHostFromTenant() + { + var host = await _sut.AddHostAsync( + TenantId.From(2), + new AddTenantHostRequest { HostName = "temp.contoso.com" } + ); + + await _sut.RemoveHostAsync(TenantId.From(2), host.Id); + + var tenant = await _sut.GetTenantByIdAsync(TenantId.From(2)); + tenant!.Hosts.Should().NotContain(h => h.HostName == "temp.contoso.com"); + } + + [Fact] + public async Task GetTenantByHostNameAsync_ReturnsTenantForActiveHost() + { + var tenant = await _sut.GetTenantByHostNameAsync("acme.localhost"); + + tenant.Should().NotBeNull(); + tenant!.Slug.Should().Be("acme"); + } + + [Fact] + public async Task GetTenantByHostNameAsync_ReturnsNullForUnknownHost() + { + var tenant = await _sut.GetTenantByHostNameAsync("unknown.example.com"); + + tenant.Should().BeNull(); + } + + private sealed class TestEventBus : IEventBus + { + public List PublishedEvents { get; } = []; + + public Task PublishAsync(T @event, CancellationToken cancellationToken = default) + where T : IEvent + { + PublishedEvents.Add(@event); + return Task.CompletedTask; + } + + public void PublishInBackground(T @event) + where T : IEvent + { + PublishedEvents.Add(@event); + } + } +} diff --git a/package-lock.json b/package-lock.json index ad2c485a..a3c3a161 100644 --- a/package-lock.json +++ b/package-lock.json @@ -212,6 +212,14 @@ "react-dom": "^19.0.0" } }, + "modules/Tenants/src/SimpleModule.Tenants": { + "name": "@simplemodule/tenants", + "version": "0.0.0", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "modules/Users/src/SimpleModule.Users": { "name": "@simplemodule/users", "version": "0.0.0", @@ -402,7 +410,6 @@ "integrity": "sha512-Jc360x4yqb3eEg4OY4KEIdGePBxZogivKI+OGIU8aLXgAYPTECvzeOBc90312yHA1hr3AeRlAFl0rIc8lQaIrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-common": "5.50.0", "@algolia/requester-browser-xhr": "5.50.0", @@ -525,7 +532,6 @@ "version": "7.29.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1402,7 +1408,6 @@ "node_modules/@floating-ui/dom": { "version": "1.7.6", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" @@ -1454,7 +1459,6 @@ "node_modules/@inertiajs/react": { "version": "2.3.18", "license": "MIT", - "peer": true, "dependencies": { "@inertiajs/core": "2.3.18", "@types/lodash-es": "^4.17.12", @@ -3536,6 +3540,10 @@ "resolved": "modules/Settings/src/SimpleModule.Settings", "link": true }, + "node_modules/@simplemodule/tenants": { + "resolved": "modules/Tenants/src/SimpleModule.Tenants", + "link": true + }, "node_modules/@simplemodule/theme-default": { "resolved": "packages/SimpleModule.Theme.Default", "link": true @@ -3900,7 +3908,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.5.tgz", "integrity": "sha512-Pkjd41UJ4F6Z8cPV+gEvqnt1VhY2g66xMjbpxREs0ECA5jRezCNKSZcc2pueQRTMtmn1SaSzGM9U/ifhVlVYOA==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4178,7 +4185,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.5.tgz", "integrity": "sha512-yJhDa7Chx2EqJMX/jlewBv0za7slf1dKHWYve1XaApuVHEkxl0Ul3EDbwnx316vIITkuFW/pWSwkSsAplyBeCw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -4415,7 +4421,6 @@ "node_modules/@types/react": { "version": "19.2.14", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4423,7 +4428,6 @@ "node_modules/@types/react-dom": { "version": "19.2.3", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4760,7 +4764,6 @@ "integrity": "sha512-yE5I83Q2s8euVou8Y3feXK08wyZInJWLYXgWO6Xti9jBUEZAGUahyeQ7wSZWkifLWVnQVKEz5RAmBlXG5nqxog==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@algolia/abtesting": "1.16.0", "@algolia/client-abtesting": "5.50.0", @@ -4869,7 +4872,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5435,7 +5437,6 @@ "version": "0.25.12", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5554,7 +5555,6 @@ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -5687,7 +5687,6 @@ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.8.tgz", "integrity": "sha512-5/F8wxkNxYtsN0bXfMwIyNLZ9WYsoOYPbmoluqVJqv8KBUbcyKZawJ7uYK4WTX8IHBLYv+VXIwfeNDPy1oKMwQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -5794,7 +5793,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -5883,7 +5881,6 @@ "version": "1.31.1", "devOptional": true, "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -6489,7 +6486,6 @@ "node_modules/picomatch": { "version": "4.0.3", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6711,7 +6707,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -6741,7 +6736,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -6790,7 +6784,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.7.tgz", "integrity": "sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -6841,7 +6834,6 @@ "node_modules/react": { "version": "19.2.4", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6871,7 +6863,6 @@ "node_modules/react-dom": { "version": "19.2.4", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6901,7 +6892,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -7025,8 +7015,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -7412,7 +7401,6 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7659,7 +7647,6 @@ "node_modules/vite": { "version": "6.4.1", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -8267,7 +8254,6 @@ "integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.31", "@vue/compiler-sfc": "3.5.31", diff --git a/template/SimpleModule.Host/SimpleModule.Host.csproj b/template/SimpleModule.Host/SimpleModule.Host.csproj index deb59e6f..02a09eb2 100644 --- a/template/SimpleModule.Host/SimpleModule.Host.csproj +++ b/template/SimpleModule.Host/SimpleModule.Host.csproj @@ -31,6 +31,7 @@ + diff --git a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs index 4d0aa5bc..ba9b3b5e 100644 --- a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs +++ b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs @@ -22,6 +22,7 @@ using SimpleModule.Products; using SimpleModule.Settings; using SimpleModule.FeatureFlags; +using SimpleModule.Tenants; using SimpleModule.Users; namespace SimpleModule.Tests.Shared.Fixtures; @@ -59,6 +60,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ReplaceDbContext(services); ReplaceDbContext(services); ReplaceDbContext(services); + ReplaceDbContext(services); ReplaceDbContext(services, useOpenIddict: true); // Remove hosted seed services — they need real DB tables that @@ -155,6 +157,7 @@ private void EnsureModuleDatabasesCreated() EnsureTablesCreated(sp); EnsureTablesCreated(sp); EnsureTablesCreated(sp); + EnsureTablesCreated(sp); EnsureTablesCreated(sp); }
+ The Feature Flags module is not installed or has no flags. +