From 4a8b940f6292df71350bac41a597b33f341d2e4c Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sat, 25 Jul 2026 20:13:04 -0500 Subject: [PATCH] =?UTF-8?q?refactor(persistence):=20eliminar=20SQL=20Serve?= =?UTF-8?q?r=20del=20c=C3=B3digo;=20PostgreSQL=20=C3=BAnico=20provider=20r?= =?UTF-8?q?elacional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 2 (código). Los `SqlServer*Repository` eran (mal nombrados) los repos relacionales que usaba Postgres; existía en paralelo un set `PostgreSql*Repository` equivalente (verificado idéntico salvo un comentario) pero NO registrado. Se activa el set correcto y se elimina SQL Server: - DependencyInjection.cs: registra los `PostgreSql*Repository`; eliminada la rama `UseSqlServer` del DbContext (pipeline "ums-sql"), el health check `AddSqlServer`, y las cláusulas `Provider == SqlServer` de todas las condiciones. - Borrados: 20 `SqlServer*Repository.cs`, `SqlServerSchemaBootstrapper`, `SqlServerDistributedLockProvider`, 12 migraciones `.sql` de SQL Server. - `UmsApiServiceBootstrappers`: init de plataforma usa PostgreSql bootstrapper/lock. - Enums: quitado `PersistenceProvider.SqlServer` y `AggregateStoreMode.SqlServer`. - `PersistenceOptions`: default provider PostgreSql; removidas las props `UseSqlServer*Stores`. `PersistenceRuntimeReporter` reescrito a PostgreSQL. - appsettings (Dev/UAT) y ContractTest: removidos flags `UseSqlServer*`. - csproj: removidos paquetes EFCore.SqlServer y HealthChecks.SqlServer. Código/config 100% libres de SQL Server (quedan solo docs de política que lo prohíben y ADRs históricos, intencionales). Validado: build 0 errores; Application.Test 608/608; Domain.Test 717/717. Co-Authored-By: Claude Opus 4.8 --- .../TransactionalAtomicityTests.cs | 2 +- .../ContractTestWebApplicationFactory.cs | 6 - .../Ums.Infrastructure/DependencyInjection.cs | 122 ++--- .../Hosting/PersistenceRuntimeReporter.cs | 20 +- ...ServerAccessEnforcementPolicyRepository.cs | 143 ----- .../SqlServerApprovalRequestRepository.cs | 178 ------ .../SqlServerApprovalWorkflowRepository.cs | 188 ------- .../SqlServerDocumentTypeRepository.cs | 143 ----- .../SqlServerNotificationRuleRepository.cs | 158 ------ .../SqlServerUserDocumentRepository.cs | 165 ------ .../Audit/SqlServerAuditRecordRepository.cs | 128 ----- .../SqlServerPermissionTemplateRepository.cs | 218 -------- .../SqlServerProfileRepository.cs | 231 -------- .../Authorization/SqlServerRoleRepository.cs | 146 ----- .../SqlServerSystemSuiteRepository.cs | 512 ------------------ ...lServerTemplateAssignmentRuleRepository.cs | 140 ----- .../SqlServerAppConfigurationRepository.cs | 178 ------ .../SqlServerFeatureFlagRepository.cs | 230 -------- .../SqlServerIdpConfigurationRepository.cs | 150 ----- .../SqlServerParameterRepositories.cs | 226 -------- .../Identity/SqlServerTenantRepository.cs | 449 --------------- .../SqlServerTenantSignupRequestRepository.cs | 173 ------ .../SqlServerUserAccountRepository.cs | 356 ------------ ...erverUserManagementDelegationRepository.cs | 193 ------- .../SqlServerTenantParameterRepository.cs | 180 ------ ...60521_sqlserver_authorization_profiles.sql | 56 -- ...20260521_sqlserver_identity_aggregates.sql | 170 ------ .../20260521_sqlserver_platform_outbox.sql | 46 -- ...0260522_sqlserver_identity_delegations.sql | 84 --- .../20260523_sqlserver_approvals.sql | 111 ---- .../20260523_sqlserver_audit_records.sql | 59 -- ...523_sqlserver_configuration_aggregates.sql | 108 ---- ...260524_sqlserver_approvals_ep07_tables.sql | 136 ----- ...60524_sqlserver_authorization_advanced.sql | 217 -------- ...server_iga_promotion_impact_rowversion.sql | 6 - ...527_sqlserver_standardize_schema_names.sql | 32 -- ...20260528_sqlserver_authorization_roles.sql | 40 -- .../Persistence/Options/AggregateStoreMode.cs | 1 - .../Persistence/Options/PersistenceOptions.cs | 7 +- .../Options/PersistenceProvider.cs | 1 - .../SqlServerDistributedLockProvider.cs | 53 -- .../SqlServerSchemaBootstrapper.cs | 90 --- .../Ums.Infrastructure.csproj | 2 - .../UmsApiServiceBootstrappers.cs | 6 +- .../appsettings.Development.json | 5 - .../Ums.Presentation/appsettings.UAT.json | 5 - .../ums.api/Ums.Presentation/appsettings.json | 5 - 47 files changed, 46 insertions(+), 5829 deletions(-) delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerAccessEnforcementPolicyRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerApprovalRequestRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerApprovalWorkflowRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerDocumentTypeRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerNotificationRuleRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerUserDocumentRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Audit/SqlServerAuditRecordRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerPermissionTemplateRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerProfileRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerRoleRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerSystemSuiteRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerTemplateAssignmentRuleRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerAppConfigurationRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerFeatureFlagRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerIdpConfigurationRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerParameterRepositories.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerTenantRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerTenantSignupRequestRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerUserAccountRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerUserManagementDelegationRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/SqlServerTenantParameterRepository.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260521_sqlserver_authorization_profiles.sql delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260521_sqlserver_identity_aggregates.sql delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260521_sqlserver_platform_outbox.sql delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260522_sqlserver_identity_delegations.sql delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260523_sqlserver_approvals.sql delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260523_sqlserver_audit_records.sql delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260523_sqlserver_configuration_aggregates.sql delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260524_sqlserver_approvals_ep07_tables.sql delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260524_sqlserver_authorization_advanced.sql delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260527_sqlserver_iga_promotion_impact_rowversion.sql delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260527_sqlserver_standardize_schema_names.sql delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260528_sqlserver_authorization_roles.sql delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/SqlServerDistributedLockProvider.cs delete mode 100644 src/apps/ums.api/Ums.Infrastructure/Persistence/SqlServerSchemaBootstrapper.cs diff --git a/src/apps/ums.api/Ums.Application.Test/Common/Reliability/TransactionalAtomicityTests.cs b/src/apps/ums.api/Ums.Application.Test/Common/Reliability/TransactionalAtomicityTests.cs index 0035fa34..989371c0 100644 --- a/src/apps/ums.api/Ums.Application.Test/Common/Reliability/TransactionalAtomicityTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Common/Reliability/TransactionalAtomicityTests.cs @@ -116,7 +116,7 @@ public async Task T02_WhenSaveChangesFails_AggregateEventsAreAlreadyCleared_Risk var eventsBeforeSave = config.DomainEvents.GetUncommittedChanges().Count; Assert.Equal(1, eventsBeforeSave); // one AppConfigCreatedEvent - // Simulate the SaveEntitiesAsync logic from SqlServerAppConfigurationRepository + // Simulate the SaveEntitiesAsync logic from PostgreSqlAppConfigurationRepository // (reproduced here because we cannot call the SQL repository without DB) var outboxMessages = config.DomainEvents.GetUncommittedChanges().ToList(); Assert.Single(outboxMessages); // outbox materialized diff --git a/src/apps/ums.api/Ums.ContractTest/Infrastructure/ContractTestWebApplicationFactory.cs b/src/apps/ums.api/Ums.ContractTest/Infrastructure/ContractTestWebApplicationFactory.cs index 15c8c474..68d83cff 100644 --- a/src/apps/ums.api/Ums.ContractTest/Infrastructure/ContractTestWebApplicationFactory.cs +++ b/src/apps/ums.api/Ums.ContractTest/Infrastructure/ContractTestWebApplicationFactory.cs @@ -31,9 +31,6 @@ static ContractTestWebApplicationFactory() { Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development"); Environment.SetEnvironmentVariable("Persistence__Provider", "InMemory"); - Environment.SetEnvironmentVariable("Persistence__UseSqlServerIdentityStores", "false"); - Environment.SetEnvironmentVariable("Persistence__UseSqlServerAuthorizationStores", "false"); - Environment.SetEnvironmentVariable("Persistence__UseSqlServerConfigurationStores", "false"); Environment.SetEnvironmentVariable("Persistence__SeedDevData", "true"); Environment.SetEnvironmentVariable("Persistence__EnableOutbox", "false"); Environment.SetEnvironmentVariable("Persistence__InitializePlatformStoreOnStartup","false"); @@ -49,9 +46,6 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) cfg.AddInMemoryCollection(new Dictionary { ["Persistence:Provider"] = PersistenceProvider.InMemory.ToString(), - ["Persistence:UseSqlServerIdentityStores"] = bool.FalseString, - ["Persistence:UseSqlServerAuthorizationStores"] = bool.FalseString, - ["Persistence:UseSqlServerConfigurationStores"] = bool.FalseString, ["Persistence:SeedDevData"] = bool.TrueString, ["Persistence:EnableOutbox"] = bool.FalseString, ["Persistence:InitializePlatformStoreOnStartup"]= bool.FalseString, diff --git a/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.cs b/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.cs index ab9cca62..c95b5471 100644 --- a/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.cs +++ b/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.cs @@ -57,7 +57,7 @@ public static IServiceCollection AddInfrastructure( .Validate( options => options.Provider == PersistenceProvider.InMemory || !string.IsNullOrWhiteSpace(configuration.GetConnectionString("DefaultConnection")), - "ConnectionStrings:DefaultConnection is required when Persistence.Provider is SqlServer, Sqlite or PostgreSql.") + "ConnectionStrings:DefaultConnection is required when Persistence.Provider is Sqlite or PostgreSql.") .ValidateOnStart(); services.AddHttpContextAccessor(); @@ -219,52 +219,12 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => }); // REC-04: Cross-aggregate transaction scope - if (persistence.Provider == PersistenceProvider.SqlServer || persistence.Provider == PersistenceProvider.Sqlite || persistence.Provider == PersistenceProvider.PostgreSql) + if (persistence.Provider == PersistenceProvider.Sqlite || persistence.Provider == PersistenceProvider.PostgreSql) services.AddScoped(); else services.AddSingleton(); - if (persistence.Provider == PersistenceProvider.SqlServer) - { - var connectionString = configuration.GetConnectionString("DefaultConnection") - ?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection must be configured for SQL Server persistence."); - - services.AddScoped(); - services.AddScoped(); // FIX-08: auto-stamp audit columns - - // REC-08: Polly resilience pipeline — circuit breaker on top of EF Core's transient retry - services.AddResiliencePipeline("ums-sql", pipelineBuilder => - { - pipelineBuilder - .AddRetry(new Polly.Retry.RetryStrategyOptions - { - MaxRetryAttempts = 3, - Delay = TimeSpan.FromMilliseconds(200), - BackoffType = Polly.DelayBackoffType.Exponential, - }) - .AddCircuitBreaker(new Polly.CircuitBreaker.CircuitBreakerStrategyOptions - { - FailureRatio = 0.5, - SamplingDuration = TimeSpan.FromSeconds(30), - MinimumThroughput = 10, - BreakDuration = TimeSpan.FromSeconds(60), - }); - }); - - services.AddDbContext((serviceProvider, options) => - { - options.UseSqlServer(connectionString, sqlServer => - { - sqlServer.MigrationsHistoryTable("__EFMigrationsHistory", UmsPlatformDbContext.DefaultSchema); - sqlServer.EnableRetryOnFailure(3); // REC-08: reduced; circuit breaker handles sustained failures - }); - - options.AddInterceptors( - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService()); - }); - } - else if (persistence.Provider == PersistenceProvider.Sqlite) + if (persistence.Provider == PersistenceProvider.Sqlite) { var connectionString = configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection must be configured for SQLite persistence."); @@ -336,15 +296,14 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => options.UseNpgsql(connectionString, pgOptions => pgOptions.EnableRetryOnFailure(3))); } - if ((persistence.Provider == PersistenceProvider.SqlServer && persistence.UseSqlServerIdentityStores) || - (persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteIdentityStores) || + if ((persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteIdentityStores) || (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlIdentityStores)) { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } else { @@ -364,15 +323,14 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => services.AddSingleton(sp => sp.GetRequiredService()); } - if ((persistence.Provider == PersistenceProvider.SqlServer && persistence.UseSqlServerAuthorizationStores) || - (persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteAuthorizationStores) || + if ((persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteAuthorizationStores) || (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlAuthorizationStores)) { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } else { @@ -392,16 +350,15 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => services.AddSingleton(sp => sp.GetRequiredService()); } - if ((persistence.Provider == PersistenceProvider.SqlServer && persistence.UseSqlServerConfigurationStores) || - (persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteConfigurationStores) || + if ((persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteConfigurationStores) || (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlConfigurationStores)) { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } else { @@ -420,9 +377,9 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => services.AddSingleton(sp => sp.GetRequiredService()); } - if (persistence.Provider == PersistenceProvider.SqlServer || persistence.Provider == PersistenceProvider.Sqlite || persistence.Provider == PersistenceProvider.PostgreSql) + if (persistence.Provider == PersistenceProvider.Sqlite || persistence.Provider == PersistenceProvider.PostgreSql) { - services.AddScoped(); + services.AddScoped(); } else { @@ -430,16 +387,15 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => services.AddSingleton(sp => sp.GetRequiredService()); } - if ((persistence.Provider == PersistenceProvider.SqlServer && persistence.UseSqlServerApprovalsStores) || - (persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteApprovalsStores) || + if ((persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteApprovalsStores) || (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlApprovalsStores)) { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } else { @@ -542,21 +498,9 @@ public static IServiceCollection AddInfrastructureHealthChecks( var builder = services.AddHealthChecks(); - if (persistence.Provider == PersistenceProvider.SqlServer || persistence.Provider == PersistenceProvider.Sqlite || persistence.Provider == PersistenceProvider.PostgreSql) + if (persistence.Provider == PersistenceProvider.Sqlite || persistence.Provider == PersistenceProvider.PostgreSql) { - if (persistence.Provider == PersistenceProvider.SqlServer) - { - var connectionString = configuration.GetConnectionString("DefaultConnection"); - if (!string.IsNullOrWhiteSpace(connectionString)) - { - builder.AddSqlServer( - connectionString, - name: "sql_server", - tags: ["ready", "db"]); - } - } - - if (persistence.Provider == PersistenceProvider.PostgreSql) + if (persistence.Provider == PersistenceProvider.PostgreSql) { var connectionString = configuration.GetConnectionString("DefaultConnection"); if (!string.IsNullOrWhiteSpace(connectionString)) diff --git a/src/apps/ums.api/Ums.Infrastructure/Hosting/PersistenceRuntimeReporter.cs b/src/apps/ums.api/Ums.Infrastructure/Hosting/PersistenceRuntimeReporter.cs index 4af4c1d5..4e2288c7 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Hosting/PersistenceRuntimeReporter.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Hosting/PersistenceRuntimeReporter.cs @@ -14,29 +14,29 @@ public Task StartAsync(CancellationToken cancellationToken) var options = persistenceOptions.Value; logger.LogInformation( - "UMS persistence configured with provider {Provider}, aggregate store mode {AggregateStoreMode}, identity SQL stores {UseSqlServerIdentityStores}, authorization SQL stores {UseSqlServerAuthorizationStores}, outbox enabled {EnableOutbox}.", + "UMS persistence configured with provider {Provider}, aggregate store mode {AggregateStoreMode}, identity PostgreSQL stores {UsePostgreSqlIdentityStores}, authorization PostgreSQL stores {UsePostgreSqlAuthorizationStores}, outbox enabled {EnableOutbox}.", options.Provider, options.AggregateStoreMode, - options.UseSqlServerIdentityStores, - options.UseSqlServerAuthorizationStores, + options.UsePostgreSqlIdentityStores, + options.UsePostgreSqlAuthorizationStores, options.EnableOutbox); - if (options.Provider == PersistenceProvider.SqlServer + if (options.Provider == PersistenceProvider.PostgreSql && options.AggregateStoreMode == AggregateStoreMode.InMemory - && !options.UseSqlServerIdentityStores) + && !options.UsePostgreSqlIdentityStores) { logger.LogWarning( - "SQL Server is configured as the platform provider, but aggregate repositories still run in-memory. This is a valid transitional modular-monolith mode, not the final production persistence model."); + "PostgreSQL is configured as the platform provider, but aggregate repositories still run in-memory. This is a valid transitional modular-monolith mode, not the final production persistence model."); } - if (options.Provider == PersistenceProvider.SqlServer && options.UseSqlServerIdentityStores) + if (options.Provider == PersistenceProvider.PostgreSql && options.UsePostgreSqlIdentityStores) { - logger.LogInformation("Identity aggregates are configured to run on SQL Server repositories while the remaining contexts stay in transitional mode."); + logger.LogInformation("Identity aggregates are configured to run on PostgreSQL repositories while the remaining contexts stay in transitional mode."); } - if (options.Provider == PersistenceProvider.SqlServer && options.UseSqlServerAuthorizationStores) + if (options.Provider == PersistenceProvider.PostgreSql && options.UsePostgreSqlAuthorizationStores) { - logger.LogInformation("Authorization profile aggregates are configured to run on SQL Server repositories."); + logger.LogInformation("Authorization profile aggregates are configured to run on PostgreSQL repositories."); } return Task.CompletedTask; diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerAccessEnforcementPolicyRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerAccessEnforcementPolicyRepository.cs deleted file mode 100644 index 79289a56..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerAccessEnforcementPolicyRepository.cs +++ /dev/null @@ -1,143 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Approvals; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence.Approvals.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Approvals; - -using AccessEnforcementPolicyAggregate = Ums.Domain.Approvals.AccessEnforcementPolicy.AccessEnforcementPolicy; - -public sealed class SqlServerAccessEnforcementPolicyRepository : IAccessEnforcementPolicyRepository, IUnitOfWork -{ - private readonly UmsPlatformDbContext _dbContext; - private readonly HashSet _trackedAggregates = []; - - public SqlServerAccessEnforcementPolicyRepository(UmsPlatformDbContext dbContext) - { - _dbContext = dbContext; - } - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await _dbContext.Set() - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - => GetByIdAsync(id, cancellationToken); - - public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) - { - IQueryable query = _dbContext.Set(); - - if (tenantId.HasValue) - { - query = query.Where(x => x.TenantId == tenantId.Value); - } - - var records = await query.ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) - { - var records = await _dbContext.Set() - .Where(x => x.TenantId == tenantId) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public Task AddAsync(AccessEnforcementPolicyAggregate aggregate, CancellationToken cancellationToken = default) - { - _dbContext.Set().Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(AccessEnforcementPolicyAggregate aggregate, CancellationToken cancellationToken = default) - { - var existing = await _dbContext.Set() - .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"Access enforcement policy {aggregate.Props.Id.GetValue()} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => _dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await _dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await _dbContext.SaveChangesAsync(cancellationToken); - } - catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException ex) - { - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() { } - - private static AccessEnforcementPolicyAggregate Rehydrate(AccessEnforcementPolicyRecord record) - => ApprovalsAggregateFactory.RehydrateAccessEnforcementPolicy(record); - - private static AccessEnforcementPolicyRecord ToRecord(AccessEnforcementPolicyAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new AccessEnforcementPolicyRecord - { - Id = aggregate.Props.Id.GetValue(), - TenantId = aggregate.Props.TenantId.GetValue(), - ProfileId = aggregate.ProfileId?.GetValue(), - RoleId = aggregate.RoleId?.GetValue(), - EnforcementActionId = aggregate.EnforcementAction.Id, - IsActive = aggregate.IsActive, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan - }; - } - - private static void Apply(AccessEnforcementPolicyRecord target, AccessEnforcementPolicyAggregate source) - { - var replacement = ToRecord(source); - - target.TenantId = replacement.TenantId; - target.ProfileId = replacement.ProfileId; - target.RoleId = replacement.RoleId; - target.EnforcementActionId = replacement.EnforcementActionId; - target.IsActive = replacement.IsActive; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerApprovalRequestRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerApprovalRequestRepository.cs deleted file mode 100644 index b4603445..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerApprovalRequestRepository.cs +++ /dev/null @@ -1,178 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Approvals; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence.Approvals.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Approvals; - -using ApprovalRequestAggregate = Ums.Domain.Approvals.ApprovalRequest.ApprovalRequest; - -public sealed class SqlServerApprovalRequestRepository : IApprovalRequestRepository, IUnitOfWork -{ - private readonly UmsPlatformDbContext _dbContext; - private readonly HashSet _trackedAggregates = []; - - public SqlServerApprovalRequestRepository(UmsPlatformDbContext dbContext) - { - _dbContext = dbContext; - } - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await _dbContext.Set() - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - => GetByIdAsync(id, cancellationToken); - - public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) - { - var query = _dbContext.Set().AsQueryable(); - - if (tenantId.HasValue) - { - query = query.Join(_dbContext.Set(), - req => req.WorkflowId, - wf => wf.Id, - (req, wf) => req) - .Where(x => _dbContext.Set() - .Any(wf => wf.Id == x.WorkflowId && wf.TenantId == tenantId.Value)); - } - - var records = await query.ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) - { - // Join with ApprovalWorkflow to filter by TenantId - var records = await _dbContext.Set() - .Join(_dbContext.Set(), - req => req.WorkflowId, - wf => wf.Id, - (req, wf) => new { req, wf }) - .Where(x => x.wf.TenantId == tenantId) - .Select(x => x.req) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public Task AddAsync(ApprovalRequestAggregate aggregate, CancellationToken cancellationToken = default) - { - _dbContext.Set().Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(ApprovalRequestAggregate aggregate, CancellationToken cancellationToken = default) - { - var existing = await _dbContext.Set() - .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"Approval request {aggregate.Props.Id.GetValue()} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => _dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await _dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await _dbContext.SaveChangesAsync(cancellationToken); - } - catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException ex) - { - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() - { - } - - private static ApprovalRequestAggregate Rehydrate(ApprovalRequestRecord record) - => ApprovalsAggregateFactory.RehydrateRequest(record); - - public async Task ExistsPendingForScopeAsync( - Guid userId, Guid systemId, Guid? branchId, CancellationToken cancellationToken = default) - { - return await _dbContext.Set() - .AnyAsync(x => - x.TargetUserId == userId && - x.RequestedSystemId == systemId && - x.RequestedBranchId == branchId && - x.StatusId == ApprovalStatus.Pending.Id, - cancellationToken); - } - - private static ApprovalRequestRecord ToRecord(ApprovalRequestAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new ApprovalRequestRecord - { - Id = aggregate.Props.Id.GetValue(), - WorkflowId = aggregate.WorkflowId.GetValue(), - TargetUserId = aggregate.TargetUserId.GetValue(), - TargetProfileId = aggregate.TargetProfileId?.GetValue(), - StatusId = aggregate.Status.Id, - RequestedSystemId = aggregate.RequestedSystemId.GetValue(), - RequestedBranchId = aggregate.RequestedBranchId?.GetValue(), - RequestedRoleId = aggregate.RequestedRoleId.GetValue(), - Justification = aggregate.Justification, - GrantedRoleId = aggregate.GrantedRoleId?.GetValue(), - DecisionReason = aggregate.DecisionReason, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan - }; - } - - private static void Apply(ApprovalRequestRecord target, ApprovalRequestAggregate source) - { - var replacement = ToRecord(source); - - target.WorkflowId = replacement.WorkflowId; - target.TargetUserId = replacement.TargetUserId; - target.TargetProfileId = replacement.TargetProfileId; - target.StatusId = replacement.StatusId; - target.RequestedSystemId = replacement.RequestedSystemId; - target.RequestedBranchId = replacement.RequestedBranchId; - target.RequestedRoleId = replacement.RequestedRoleId; - target.Justification = replacement.Justification; - target.GrantedRoleId = replacement.GrantedRoleId; - target.DecisionReason = replacement.DecisionReason; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerApprovalWorkflowRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerApprovalWorkflowRepository.cs deleted file mode 100644 index 62adaaff..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerApprovalWorkflowRepository.cs +++ /dev/null @@ -1,188 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Approvals; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence; -using Ums.Infrastructure.Persistence.Approvals.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Approvals; - -using ApprovalWorkflowAggregate = Ums.Domain.Approvals.ApprovalWorkflow.ApprovalWorkflow; - -public sealed class SqlServerApprovalWorkflowRepository : IApprovalWorkflowRepository, IUnitOfWork -{ - private readonly UmsPlatformDbContext _dbContext; - private readonly HashSet _trackedAggregates = []; - - public SqlServerApprovalWorkflowRepository(UmsPlatformDbContext dbContext) - { - _dbContext = dbContext; - } - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await _dbContext.Set() - .Include(x => x.RequiredDocuments) - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - => GetByIdAsync(id, cancellationToken); - - public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) - { - IQueryable query = _dbContext.Set().Include(x => x.RequiredDocuments); - - if (tenantId.HasValue) - { - query = query.Where(x => x.TenantId == tenantId.Value); - } - - var records = await query.ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) - { - var records = await _dbContext.Set() - .Include(x => x.RequiredDocuments) - .Where(x => x.TenantId == tenantId) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public Task AddAsync(ApprovalWorkflowAggregate aggregate, CancellationToken cancellationToken = default) - { - _dbContext.Set().Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(ApprovalWorkflowAggregate aggregate, CancellationToken cancellationToken = default) - { - var existing = await _dbContext.Set() - .Include(x => x.RequiredDocuments) - .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"Approval workflow {aggregate.Props.Id.GetValue()} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => _dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await _dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await _dbContext.SaveChangesAsync(cancellationToken); - } - catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException ex) - { - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() - { - } - - private static ApprovalWorkflowAggregate Rehydrate(ApprovalWorkflowRecord record) - => ApprovalsAggregateFactory.RehydrateWorkflow(record, record.RequiredDocuments); - - private static ApprovalWorkflowRecord ToRecord(ApprovalWorkflowAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new ApprovalWorkflowRecord - { - Id = aggregate.Props.Id.GetValue(), - TenantId = aggregate.Props.TenantId.GetValue(), - SystemSuiteId = aggregate.Props.SystemSuiteId?.GetValue(), - Code = aggregate.Code.GetValue(), - Name = aggregate.Name.GetValue(), - Description = aggregate.Description.GetValue(), - TargetUserCategoryId = aggregate.TargetUserCategory.Id, - RequiresApproval = aggregate.RequiresApproval, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - RequiredDocuments = aggregate.RequiredDocuments.Select(x => - { - var a = x.Props.Audit.GetValue(); - return new ApprovalRequiredDocumentRecord - { - Id = x.Props.Id.GetValue(), - WorkflowId = x.WorkflowId.GetValue(), - DocumentTypeId = x.DocumentTypeId.GetValue(), - IsMandatory = x.IsMandatory, - CreatedBy = a.CreatedBy, - CreatedAtUtc = a.CreatedAt, - UpdatedBy = a.UpdatedBy, - UpdatedAtUtc = a.UpdatedAt, - AuditTimeSpan = a.TimeSpan - }; - }).ToList() - }; - } - - private void Apply(ApprovalWorkflowRecord target, ApprovalWorkflowAggregate source) - { - var replacement = ToRecord(source); - - target.TenantId = replacement.TenantId; - target.SystemSuiteId = replacement.SystemSuiteId; - target.Code = replacement.Code; - target.Name = replacement.Name; - target.Description = replacement.Description; - target.TargetUserCategoryId = replacement.TargetUserCategoryId; - target.RequiresApproval = replacement.RequiresApproval; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - - EfChildCollectionReconciler.ReconcileById( - _dbContext, - target.RequiredDocuments, - replacement.RequiredDocuments, - document => document.Id, - UpdateRequiredDocument); - } - - private static void UpdateRequiredDocument(ApprovalRequiredDocumentRecord target, ApprovalRequiredDocumentRecord source) - { - target.WorkflowId = source.WorkflowId; - target.DocumentTypeId = source.DocumentTypeId; - target.IsMandatory = source.IsMandatory; - target.CreatedBy = source.CreatedBy; - target.CreatedAtUtc = source.CreatedAtUtc; - target.UpdatedBy = source.UpdatedBy; - target.UpdatedAtUtc = source.UpdatedAtUtc; - target.AuditTimeSpan = source.AuditTimeSpan; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerDocumentTypeRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerDocumentTypeRepository.cs deleted file mode 100644 index c5ac5ab0..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerDocumentTypeRepository.cs +++ /dev/null @@ -1,143 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Approvals; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence.Approvals.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Approvals; - -using DocumentTypeAggregate = Ums.Domain.Approvals.DocumentType.DocumentType; - -public sealed class SqlServerDocumentTypeRepository : IDocumentTypeRepository, IUnitOfWork -{ - private readonly UmsPlatformDbContext _dbContext; - private readonly HashSet _trackedAggregates = []; - - public SqlServerDocumentTypeRepository(UmsPlatformDbContext dbContext) - { - _dbContext = dbContext; - } - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await _dbContext.Set() - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - => GetByIdAsync(id, cancellationToken); - - public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) - { - IQueryable query = _dbContext.Set(); - - if (tenantId.HasValue) - { - query = query.Where(x => x.TenantId == tenantId.Value); - } - - var records = await query.ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) - { - var records = await _dbContext.Set() - .Where(x => x.TenantId == tenantId) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public Task AddAsync(DocumentTypeAggregate aggregate, CancellationToken cancellationToken = default) - { - _dbContext.Set().Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(DocumentTypeAggregate aggregate, CancellationToken cancellationToken = default) - { - var existing = await _dbContext.Set() - .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"Document type {aggregate.Props.Id.GetValue()} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => _dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await _dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await _dbContext.SaveChangesAsync(cancellationToken); - } - catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException ex) - { - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() { } - - private static DocumentTypeAggregate Rehydrate(DocumentTypeRecord record) - => ApprovalsAggregateFactory.RehydrateDocumentType(record); - - private static DocumentTypeRecord ToRecord(DocumentTypeAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new DocumentTypeRecord - { - Id = aggregate.Props.Id.GetValue(), - TenantId = aggregate.Props.TenantId.GetValue(), - Code = aggregate.Code.GetValue(), - Name = aggregate.Name.GetValue(), - Description = aggregate.Description.GetValue(), - CriticityId = aggregate.Criticity.Id, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan - }; - } - - private static void Apply(DocumentTypeRecord target, DocumentTypeAggregate source) - { - var replacement = ToRecord(source); - - target.TenantId = replacement.TenantId; - target.Code = replacement.Code; - target.Name = replacement.Name; - target.Description = replacement.Description; - target.CriticityId = replacement.CriticityId; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerNotificationRuleRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerNotificationRuleRepository.cs deleted file mode 100644 index 8ff309c1..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerNotificationRuleRepository.cs +++ /dev/null @@ -1,158 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Approvals; -using Ums.Domain.Enums; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence.Approvals.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Approvals; - -using NotificationRuleAggregate = Ums.Domain.Approvals.NotificationRule.NotificationRule; - -public sealed class SqlServerNotificationRuleRepository : INotificationRuleRepository, IUnitOfWork -{ - private readonly UmsPlatformDbContext _dbContext; - private readonly HashSet _trackedAggregates = []; - - public SqlServerNotificationRuleRepository(UmsPlatformDbContext dbContext) - { - _dbContext = dbContext; - } - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await _dbContext.Set() - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - => GetByIdAsync(id, cancellationToken); - - public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) - { - IQueryable query = _dbContext.Set(); - - if (tenantId.HasValue) - { - query = query.Where(x => x.TenantId == tenantId.Value); - } - - var records = await query.ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) - { - var records = await _dbContext.Set() - .Where(x => x.TenantId == tenantId) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public Task ExistsDuplicateAsync(Guid tenantId, string channel, string recipient, CancellationToken cancellationToken = default) - { - NotificationChannel? channelEnum; - try { channelEnum = DomainEnumerationMapper.FromName(channel); } - catch { return Task.FromResult(false); } - - return _dbContext.Set() - .AnyAsync(x => x.TenantId == tenantId - && x.ChannelId == channelEnum.Id - && x.Recipient == recipient - && x.IsActive, - cancellationToken); - } - - public Task AddAsync(NotificationRuleAggregate aggregate, CancellationToken cancellationToken = default) - { - _dbContext.Set().Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(NotificationRuleAggregate aggregate, CancellationToken cancellationToken = default) - { - var existing = await _dbContext.Set() - .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"Notification rule {aggregate.Props.Id.GetValue()} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => _dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await _dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await _dbContext.SaveChangesAsync(cancellationToken); - } - catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException ex) - { - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() - { - } - - private static NotificationRuleAggregate Rehydrate(NotificationRuleRecord record) - => ApprovalsAggregateFactory.RehydrateRule(record); - - private static NotificationRuleRecord ToRecord(NotificationRuleAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new NotificationRuleRecord - { - Id = aggregate.Props.Id.GetValue(), - TenantId = aggregate.Props.TenantId.GetValue(), - ChannelId = aggregate.Channel.Id, - Recipient = aggregate.Recipient.GetValue(), - IsActive = aggregate.IsActive, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan - }; - } - - private static void Apply(NotificationRuleRecord target, NotificationRuleAggregate source) - { - var replacement = ToRecord(source); - - target.TenantId = replacement.TenantId; - target.ChannelId = replacement.ChannelId; - target.Recipient = replacement.Recipient; - target.IsActive = replacement.IsActive; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerUserDocumentRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerUserDocumentRepository.cs deleted file mode 100644 index 98b3a845..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/SqlServerUserDocumentRepository.cs +++ /dev/null @@ -1,165 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Approvals; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence.Approvals.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Approvals; - -using UserDocumentAggregate = Ums.Domain.Approvals.UserDocument.UserDocument; - -public sealed class SqlServerUserDocumentRepository : IUserDocumentRepository, IUnitOfWork -{ - private readonly UmsPlatformDbContext _dbContext; - private readonly HashSet _trackedAggregates = []; - - public SqlServerUserDocumentRepository(UmsPlatformDbContext dbContext) - { - _dbContext = dbContext; - } - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await _dbContext.Set() - .Include(x => x.Notifications) - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - => GetByIdAsync(id, cancellationToken); - - public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) - { - var records = await _dbContext.Set() - .Include(x => x.Notifications) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) - { - var records = await _dbContext.Set() - .Include(x => x.Notifications) - .Where(x => x.UserId == userId) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public Task AddAsync(UserDocumentAggregate aggregate, CancellationToken cancellationToken = default) - { - _dbContext.Set().Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(UserDocumentAggregate aggregate, CancellationToken cancellationToken = default) - { - var existing = await _dbContext.Set() - .Include(x => x.Notifications) - .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"User document {aggregate.Props.Id.GetValue()} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => _dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await _dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await _dbContext.SaveChangesAsync(cancellationToken); - } - catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException ex) - { - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() { } - - private static UserDocumentAggregate Rehydrate(UserDocumentRecord record) - => ApprovalsAggregateFactory.RehydrateUserDocument(record, record.Notifications); - - private static UserDocumentRecord ToRecord(UserDocumentAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new UserDocumentRecord - { - Id = aggregate.Props.Id.GetValue(), - UserId = aggregate.UserId.GetValue(), - DocumentTypeId = aggregate.DocumentTypeId.GetValue(), - IssueDate = aggregate.IssueDate, - ExpirationDate = aggregate.ExpirationDate, - StatusId = aggregate.Status.Id, - CriticityId = aggregate.Criticity.Id, - FileStoragePath = aggregate.FileStoragePath.GetValue(), - FileChecksum = aggregate.FileChecksum, - NotificationStep = aggregate.NotificationStep, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - Notifications = aggregate.Notifications.Select(n => new AccessNotificationRecord - { - Id = n.Props.Id.GetValue(), - UserDocumentId = aggregate.Props.Id.GetValue(), - Step = n.Step, - ChannelId = n.Channel.Id, - DaysRemaining = n.DaysRemaining, - SentAt = n.SentAt - }).ToList() - }; - } - - private static void Apply(UserDocumentRecord target, UserDocumentAggregate source) - { - var replacement = ToRecord(source); - - target.UserId = replacement.UserId; - target.DocumentTypeId = replacement.DocumentTypeId; - target.IssueDate = replacement.IssueDate; - target.ExpirationDate = replacement.ExpirationDate; - target.StatusId = replacement.StatusId; - target.CriticityId = replacement.CriticityId; - target.FileStoragePath = replacement.FileStoragePath; - target.FileChecksum = replacement.FileChecksum; - target.NotificationStep = replacement.NotificationStep; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - - // Sync notifications: add new entries; existing ones are immutable (append-only) - var existingIds = target.Notifications.Select(n => n.Id).ToHashSet(); - foreach (var n in replacement.Notifications.Where(n => !existingIds.Contains(n.Id))) - { - target.Notifications.Add(n); - } - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Audit/SqlServerAuditRecordRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Audit/SqlServerAuditRecordRepository.cs deleted file mode 100644 index 2c123d98..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Audit/SqlServerAuditRecordRepository.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Audit.AuditRecord; -using Ums.Infrastructure.Persistence.Audit.Entities; -using Ums.Infrastructure.Persistence.Reflection; -using BeyondNetCode.Shell.Ddd.Interfaces; - -namespace Ums.Infrastructure.Persistence.Audit; - -using AuditRecordAggregate = Ums.Domain.Audit.AuditRecord.AuditRecord; - -public sealed class SqlServerAuditRecordRepository : IAuditRecordRepository, BeyondNetCode.Shell.Ddd.Interfaces.IUnitOfWork -{ - private readonly UmsPlatformDbContext _dbContext; - private readonly HashSet _trackedAggregates = []; - - public SqlServerAuditRecordRepository(UmsPlatformDbContext dbContext) - { - _dbContext = dbContext; - } - - BeyondNetCode.Shell.Ddd.Interfaces.IUnitOfWork IRepository.UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await _dbContext.Set() - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public Task AppendAsync(AuditRecordAggregate record, CancellationToken ct = default) - { - _dbContext.Set().Add(ToRecord(record)); - _trackedAggregates.Add(record); - return Task.CompletedTask; - } - - public async Task> QueryByEntityAsync( - Guid entityId, string entityType, Guid rootTenantId, DateTime from, DateTime to, CancellationToken ct = default) - { - var records = await _dbContext.Set() - .Where(r => r.AffectedEntityId == entityId && - r.AffectedEntityType == entityType && - r.RootTenantId == rootTenantId && - r.WhenOccurred >= from && - r.WhenOccurred <= to) - .OrderByDescending(r => r.WhenOccurred) - .ToListAsync(ct); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> QueryByActorAsync( - Guid actorId, DateTime from, DateTime to, CancellationToken ct = default) - { - var records = await _dbContext.Set() - .Where(r => r.WhoActed == actorId && - r.WhenOccurred >= from && - r.WhenOccurred <= to) - .OrderByDescending(r => r.WhenOccurred) - .ToListAsync(ct); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> QueryByEventTypeAsync( - string eventType, Guid rootTenantId, DateTime from, DateTime to, CancellationToken ct = default) - { - var query = _dbContext.Set() - .Where(r => r.RootTenantId == rootTenantId && - r.WhenOccurred >= from && - r.WhenOccurred <= to); - - if (!string.Equals(eventType, "*", StringComparison.Ordinal)) - { - query = query.Where(r => r.EventType == eventType); - } - - var records = await query.OrderByDescending(r => r.WhenOccurred).ToListAsync(ct); - return records.Select(Rehydrate).ToList(); - } - - // IUnitOfWork Implementation - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => _dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(object entity, CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - await _dbContext.SaveChangesAsync(cancellationToken); - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() - { - // Managed by EF Core lifecycle - } - - private static AuditRecordAggregate Rehydrate(AuditRecordRecord record) - => AuditAggregateFactory.RehydrateAuditRecord(record); - - private static AuditRecordRecord ToRecord(AuditRecordAggregate aggregate) - { - return new AuditRecordRecord - { - Id = aggregate.Props.Id.GetValue(), - WhoActed = aggregate.WhoActed, - SubjectTypeId = aggregate.SubjectType.Id, - WhenOccurred = aggregate.WhenOccurred, - WhatChanged = aggregate.WhatChanged, - EventType = aggregate.EventType, - AuditResultId = aggregate.AuditResult.Id, - AffectedEntityId = aggregate.AffectedEntityId, - AffectedEntityType = aggregate.AffectedEntityType, - RootTenantId = aggregate.RootTenantId, - Metadata = aggregate.Metadata - }; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerPermissionTemplateRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerPermissionTemplateRepository.cs deleted file mode 100644 index f3991bc9..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerPermissionTemplateRepository.cs +++ /dev/null @@ -1,218 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Authorization; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence; -using Ums.Infrastructure.Persistence.Authorization.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Authorization; - -using PermissionTemplateAggregate = Ums.Domain.Authorization.Template.PermissionTemplate; - -public sealed class SqlServerPermissionTemplateRepository(UmsPlatformDbContext dbContext) : IPermissionTemplateRepository, IUnitOfWork -{ - private readonly HashSet _trackedAggregates = []; - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.PermissionTemplates - .AsSplitQuery() - .Include(x => x.Items) - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public async Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.PermissionTemplates - .AsSplitQuery() - .Include(x => x.Items) - .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) - { - IQueryable query = dbContext.PermissionTemplates.AsSplitQuery().Include(x => x.Items); - - if (tenantId.HasValue) - { - query = query.Where(x => x.TenantId == tenantId.Value); - } - - var records = await query.OrderBy(x => x.RoleId).ThenBy(x => x.Version).ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) - { - var records = await dbContext.PermissionTemplates - .AsSplitQuery() - .Include(x => x.Items) - .Where(x => x.TenantId == tenantId) - .OrderBy(x => x.RoleId) - .ThenBy(x => x.Version) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public Task AddAsync(PermissionTemplateAggregate aggregate, CancellationToken cancellationToken = default) - { - dbContext.PermissionTemplates.Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(PermissionTemplateAggregate aggregate, CancellationToken cancellationToken = default) - { - var existing = await dbContext.PermissionTemplates - .Include(x => x.Items) - .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"Permission template {aggregate.Props.Id.GetValue()} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await dbContext.SaveChangesAsync(cancellationToken); - } - catch (DbUpdateConcurrencyException ex) - { - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.PermissionTemplates - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - if (record is null) return false; - - dbContext.PermissionTemplates.Remove(record); - return true; - } - - // ── Dependency guard queries ──────────────────────────────────────────── - - public Task CountPublishedByRoleAsync(Guid roleId, CancellationToken cancellationToken = default) - => dbContext.PermissionTemplates.CountAsync( - t => t.RoleId == roleId && t.StatusId == 2 /* Published */, - cancellationToken); - - public Task CountItemsByTargetAsync(Guid targetId, CancellationToken cancellationToken = default) - => dbContext.PermissionTemplateItems.CountAsync( - i => i.TargetId == targetId && i.IsActive, - cancellationToken); - - public void Dispose() => dbContext.Dispose(); - - private static PermissionTemplateAggregate Rehydrate(PermissionTemplateRecord record) - => AuthorizationAggregateFactory.RehydratePermissionTemplate(record, record.Items); - - private static PermissionTemplateRecord ToRecord(PermissionTemplateAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new PermissionTemplateRecord - { - Id = aggregate.Props.Id.GetValue(), - TenantId = aggregate.Props.TenantId.GetValue(), - RoleId = aggregate.Props.RoleId.GetValue(), - SystemSuiteId = aggregate.Props.SystemSuiteId.GetValue(), - Version = aggregate.Props.Version.GetValue(), - StatusId = aggregate.Props.Status.Id, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - Items = aggregate.Items.Select(x => - { - var a = x.Props.Audit.GetValue(); - return new PermissionTemplateItemRecord - { - Id = x.Props.Id.GetValue(), - TemplateId = x.Props.TemplateId.GetValue(), - TargetTypeId = x.Props.TargetType.Id, - TargetId = x.Props.TargetId.GetValue(), - ActionId = x.Props.ActionId.GetValue(), - IsAllowed = x.Props.IsAllowed, - IsDenied = x.Props.IsDenied, - IsActive = x.Props.IsActive, - CreatedBy = a.CreatedBy, - CreatedAtUtc = a.CreatedAt, - UpdatedBy = a.UpdatedBy, - UpdatedAtUtc = a.UpdatedAt, - AuditTimeSpan = a.TimeSpan, - }; - }).ToList(), - }; - } - - private void Apply(PermissionTemplateRecord target, PermissionTemplateAggregate source) - { - var replacement = ToRecord(source); - - target.TenantId = replacement.TenantId; - target.RoleId = replacement.RoleId; - target.SystemSuiteId = replacement.SystemSuiteId; - target.Version = replacement.Version; - target.StatusId = replacement.StatusId; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - - EfChildCollectionReconciler.ReconcileById( - dbContext, - target.Items, - replacement.Items, - item => item.Id, - UpdateItem); - } - - private static void UpdateItem(PermissionTemplateItemRecord target, PermissionTemplateItemRecord source) - { - target.TemplateId = source.TemplateId; - target.TargetTypeId = source.TargetTypeId; - target.TargetId = source.TargetId; - target.ActionId = source.ActionId; - target.IsAllowed = source.IsAllowed; - target.IsDenied = source.IsDenied; - target.IsActive = source.IsActive; - target.CreatedBy = source.CreatedBy; - target.CreatedAtUtc = source.CreatedAtUtc; - target.UpdatedBy = source.UpdatedBy; - target.UpdatedAtUtc = source.UpdatedAtUtc; - target.AuditTimeSpan = source.AuditTimeSpan; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerProfileRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerProfileRepository.cs deleted file mode 100644 index ef2502ca..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerProfileRepository.cs +++ /dev/null @@ -1,231 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Authorization; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence; -using Ums.Infrastructure.Persistence.Authorization.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Authorization; - -using ProfileAggregate = Ums.Domain.Authorization.Profile.Profile; - -public sealed class SqlServerProfileRepository(UmsPlatformDbContext dbContext) : IProfileRepository, IUnitOfWork -{ - private readonly HashSet _trackedAggregates = []; - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.Profiles - .AsSplitQuery() - .Include(x => x.Permissions) - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public async Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.Profiles - .AsSplitQuery() - .Include(x => x.Permissions) - .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) - { - IQueryable query = dbContext.Profiles.AsSplitQuery().Include(x => x.Permissions); - - if (tenantId.HasValue) - { - query = query.Where(x => x.TenantId == tenantId.Value); - } - - var records = await query.OrderBy(x => x.UserId).ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) - { - var records = await dbContext.Profiles - .AsSplitQuery() - .Include(x => x.Permissions) - .Where(x => x.TenantId == tenantId) - .OrderBy(x => x.UserId) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) - { - var records = await dbContext.Profiles - .AsSplitQuery() - .Include(x => x.Permissions) - .Where(x => x.UserId == userId) - .OrderBy(x => x.RoleId) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public Task AddAsync(ProfileAggregate aggregate, CancellationToken cancellationToken = default) - { - dbContext.Profiles.Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(ProfileAggregate aggregate, CancellationToken cancellationToken = default) - { - var existing = await dbContext.Profiles - .Include(x => x.Permissions) - .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"Profile {aggregate.Props.Id.GetValue()} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await dbContext.SaveChangesAsync(cancellationToken); - } - catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException ex) - { - // FIX-03: surface optimistic concurrency failures as 409 Conflict - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() => dbContext.Dispose(); - - // ── Dependency guard queries ──────────────────────────────────────────── - - public Task CountActiveByRoleAsync(Guid roleId, CancellationToken cancellationToken = default) - => dbContext.Profiles.CountAsync(p => p.RoleId == roleId && p.IsActive, cancellationToken); - - public Task CountActiveByTemplateAsync(Guid templateId, CancellationToken cancellationToken = default) - => dbContext.ProfilePermissions - .Where(pp => pp.TemplateId == templateId) - .Select(pp => pp.ProfileId) - .Distinct() - .Join(dbContext.Profiles.Where(p => p.IsActive), - ppId => ppId, p => p.Id, - (_, p) => p.Id) - .CountAsync(cancellationToken); - - public Task CountActiveByUserAsync(Guid userId, CancellationToken cancellationToken = default) - => dbContext.Profiles.CountAsync(p => p.UserId == userId && p.IsActive, cancellationToken); - - private static ProfileAggregate Rehydrate(ProfileRecord record) - => AuthorizationAggregateFactory.RehydrateProfile(record, record.Permissions); - - private static ProfileRecord ToRecord(ProfileAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new ProfileRecord - { - Id = aggregate.Props.Id.GetValue(), - TenantId = aggregate.Props.TenantId.GetValue(), - UserId = aggregate.Props.UserId.GetValue(), - RoleId = aggregate.Props.RoleId.GetValue(), - BranchId = aggregate.Props.BranchId?.GetValue(), - ScopeId = aggregate.Scope.Id, - IsActive = aggregate.IsActive, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - Permissions = aggregate.Permissions.Select(permission => - { - var a = permission.Props.Audit.GetValue(); - return new ProfilePermissionRecord - { - Id = permission.Props.Id.GetValue(), - ProfileId = permission.Props.ProfileId.GetValue(), - TemplateId = permission.Props.TemplateId.GetValue(), - TargetTypeId = permission.TargetType.Id, - TargetId = permission.TargetId.GetValue(), - ActionId = permission.ActionId.GetValue(), - IsAllowed = permission.IsAllowed, - IsDenied = permission.IsDenied, - IsActive = permission.IsActive, - IsOverride = permission.IsOverride, - CreatedBy = a.CreatedBy, - CreatedAtUtc = a.CreatedAt, - UpdatedBy = a.UpdatedBy, - UpdatedAtUtc = a.UpdatedAt, - AuditTimeSpan = a.TimeSpan, - }; - }).ToList(), - }; - } - - private void Apply(ProfileRecord target, ProfileAggregate source) - { - var replacement = ToRecord(source); - - target.TenantId = replacement.TenantId; - target.UserId = replacement.UserId; - target.RoleId = replacement.RoleId; - target.BranchId = replacement.BranchId; - target.ScopeId = replacement.ScopeId; - target.IsActive = replacement.IsActive; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - - EfChildCollectionReconciler.ReconcileById( - dbContext, - target.Permissions, - replacement.Permissions, - permission => permission.Id, - UpdatePermission); - } - - private static void UpdatePermission(ProfilePermissionRecord target, ProfilePermissionRecord source) - { - target.ProfileId = source.ProfileId; - target.TemplateId = source.TemplateId; - target.TargetTypeId = source.TargetTypeId; - target.TargetId = source.TargetId; - target.ActionId = source.ActionId; - target.IsAllowed = source.IsAllowed; - target.IsDenied = source.IsDenied; - target.IsActive = source.IsActive; - target.IsOverride = source.IsOverride; - target.CreatedBy = source.CreatedBy; - target.CreatedAtUtc = source.CreatedAtUtc; - target.UpdatedBy = source.UpdatedBy; - target.UpdatedAtUtc = source.UpdatedAtUtc; - target.AuditTimeSpan = source.AuditTimeSpan; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerRoleRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerRoleRepository.cs deleted file mode 100644 index 0939baac..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerRoleRepository.cs +++ /dev/null @@ -1,146 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Authorization; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence.Authorization.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Authorization; - -using RoleAggregate = Ums.Domain.Authorization.Role.Role; - -public sealed class SqlServerRoleRepository(UmsPlatformDbContext dbContext) : IRoleRepository, IUnitOfWork -{ - private readonly HashSet _trackedAggregates = []; - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.Roles.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - return record is null ? null : AuthorizationAggregateFactory.RehydrateRole(record); - } - - public async Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.Roles.FirstOrDefaultAsync( - x => x.TenantId == tenantId && x.Id == id, cancellationToken); - return record is null ? null : AuthorizationAggregateFactory.RehydrateRole(record); - } - - public async Task GetByCodeAsync(Guid systemSuiteId, Code code, CancellationToken cancellationToken = default) - { - var codeValue = code.GetValue(); - var record = await dbContext.Roles.FirstOrDefaultAsync( - x => x.SystemSuiteId == systemSuiteId && x.Code == codeValue, - cancellationToken); - return record is null ? null : AuthorizationAggregateFactory.RehydrateRole(record); - } - - public async Task> GetBySystemSuiteIdAsync(Guid systemSuiteId, CancellationToken cancellationToken = default) - { - var records = await dbContext.Roles - .Where(x => x.SystemSuiteId == systemSuiteId) - .OrderBy(x => x.HierarchyLevel) - .ThenBy(x => x.PromotionOrder) - .ThenBy(x => x.Value) - .ToListAsync(cancellationToken); - return records.Select(AuthorizationAggregateFactory.RehydrateRole).ToList(); - } - - public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) - { - var records = await dbContext.Roles - .Where(x => x.TenantId == tenantId) - .OrderBy(x => x.Value) - .ToListAsync(cancellationToken); - return records.Select(AuthorizationAggregateFactory.RehydrateRole).ToList(); - } - - public Task AddAsync(RoleAggregate aggregate, CancellationToken cancellationToken = default) - { - dbContext.Roles.Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(RoleAggregate aggregate, CancellationToken cancellationToken = default) - { - var target = await dbContext.Roles.FirstOrDefaultAsync( - x => x.Id == aggregate.Props.Id.GetValue(), - cancellationToken) - ?? throw new InvalidOperationException($"Role {aggregate.Props.Id.GetValue()} does not exist."); - Apply(target, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - await dbContext.SaveChangesAsync(cancellationToken); - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() => dbContext.Dispose(); - - // ── Dependency guard queries ──────────────────────────────────────────── - - public Task CountActiveChildRolesAsync(Guid parentRoleId, CancellationToken cancellationToken = default) - => dbContext.Roles.CountAsync(r => r.ParentRoleId == parentRoleId && r.IsActive, cancellationToken); - - private static RoleRecord ToRecord(RoleAggregate aggregate) - { - var props = aggregate.Props; - if (props == null) throw new InvalidOperationException("Role aggregate has null Props"); - - var audit = props.Audit?.GetValue(); - var roleId = props.Id?.GetValue() ?? Guid.Empty; - var now = DateTime.UtcNow; - - return new RoleRecord - { - Id = props.Id?.GetValue() ?? Guid.Empty, - TenantId = aggregate.TenantId?.GetValue() ?? Guid.Empty, - SystemSuiteId = aggregate.SystemSuiteId?.GetValue() ?? Guid.Empty, - ParentRoleId = aggregate.ParentRoleId?.GetValue(), - Code = aggregate.Code?.GetValue() ?? string.Empty, - Value = aggregate.Value?.GetValue() ?? string.Empty, - Description = aggregate.Description?.GetValue() ?? string.Empty, - HierarchyLevel = aggregate.HierarchyLevel, - PromotionOrder = aggregate.PromotionOrder, - IsActive = aggregate.IsActive, - CreatedBy = audit?.CreatedBy ?? "system", - CreatedAtUtc = audit?.CreatedAt ?? now, - UpdatedBy = audit?.UpdatedBy ?? "system", - UpdatedAtUtc = audit?.UpdatedAt ?? now, - AuditTimeSpan = audit?.TimeSpan ?? "00:00:00", - }; - } - - private static void Apply(RoleRecord target, RoleAggregate aggregate) - { - var source = ToRecord(aggregate); - target.ParentRoleId = source.ParentRoleId; - target.Value = source.Value; - target.Description = source.Description; - target.HierarchyLevel = source.HierarchyLevel; - target.PromotionOrder = source.PromotionOrder; - target.IsActive = source.IsActive; - target.UpdatedBy = source.UpdatedBy; - target.UpdatedAtUtc = source.UpdatedAtUtc; - target.AuditTimeSpan = source.AuditTimeSpan; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerSystemSuiteRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerSystemSuiteRepository.cs deleted file mode 100644 index 4c9c1c3b..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerSystemSuiteRepository.cs +++ /dev/null @@ -1,512 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Authorization; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence.Authorization.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Authorization; - -using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; - -public sealed class SqlServerSystemSuiteRepository(UmsPlatformDbContext dbContext) : ISystemSuiteRepository, IUnitOfWork -{ - private readonly HashSet _trackedAggregates = []; - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.SystemSuites - .AsSplitQuery() - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) - .Include(x => x.AppSettings) - .Include(x => x.Actions) - .Include(x => x.DomainResources) - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public async Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.SystemSuites - .AsSplitQuery() - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) - .Include(x => x.AppSettings) - .Include(x => x.Actions) - .Include(x => x.DomainResources) - .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public async Task GetByCodeAsync(Code code, CancellationToken cancellationToken = default) - { - var record = await dbContext.SystemSuites - .AsSplitQuery() - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) - .Include(x => x.AppSettings) - .Include(x => x.Actions) - .Include(x => x.DomainResources) - .FirstOrDefaultAsync(x => x.Code == code.GetValue(), cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) - { - IQueryable query = dbContext.SystemSuites.AsSplitQuery() - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) - .Include(x => x.AppSettings) - .Include(x => x.Actions) - .Include(x => x.DomainResources); - - if (tenantId.HasValue) - { - query = query.Where(x => x.TenantId == tenantId.Value); - } - - var records = await query.OrderBy(x => x.Code).ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) - { - var records = await dbContext.SystemSuites - .AsSplitQuery() - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) - .Include(x => x.AppSettings) - .Include(x => x.Actions) - .Include(x => x.DomainResources) - .Where(x => x.TenantId == tenantId) - .OrderBy(x => x.Code) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public Task AddAsync(SystemSuiteAggregate aggregate, CancellationToken cancellationToken = default) - { - dbContext.SystemSuites.Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(SystemSuiteAggregate aggregate, CancellationToken cancellationToken = default) - { - var id = aggregate.Props.Id.GetValue(); - - // Prefer the already-tracked entity loaded by GetByIdAsync in the same request scope. - // Issuing a second query returns the same instances via EF Core's identity map, but - // the Clear()+Add(same-PK) pattern in Apply then creates a Deleted↔Added conflict in - // the change tracker that causes a false DbUpdateConcurrencyException. - var existing = - dbContext.ChangeTracker - .Entries() - .FirstOrDefault(e => e.Entity.Id == id) - ?.Entity - ?? await dbContext.SystemSuites - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) - .Include(x => x.AppSettings) - .Include(x => x.Actions) - .Include(x => x.DomainResources) - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken) - ?? throw new InvalidOperationException($"System suite {id} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await dbContext.SaveChangesAsync(cancellationToken); - } - catch (DbUpdateConcurrencyException ex) - { - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() => dbContext.Dispose(); - - private static SystemSuiteAggregate Rehydrate(SystemSuiteRecord record) - => AuthorizationAggregateFactory.RehydrateSystemSuite(record, record.Modules, record.AppSettings, record.Actions, record.DomainResources); - - private static SystemSuiteRecord ToRecord(SystemSuiteAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new SystemSuiteRecord - { - Id = aggregate.Props.Id.GetValue(), - TenantId = aggregate.Props.TenantId.GetValue(), - Code = aggregate.Props.Code.GetValue(), - Name = aggregate.Props.Name.GetValue(), - Description = aggregate.Props.Description.GetValue(), - StatusId = aggregate.Props.Status.Id, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - Modules = aggregate.Modules.Select(ToRecord).ToList(), - AppSettings = aggregate.AppSettings.Select(x => new SystemSuiteAppSettingRecord - { - Id = Guid.NewGuid(), - SystemSuiteId = aggregate.Props.Id.GetValue(), - ConfigKey = x.Key.GetValue(), - ConfigValue = x.Value.GetValue(), - ScopeId = x.Scope.Id, - }).ToList(), - Actions = aggregate.Actions.Select(x => - { - var a = x.Props.Audit.GetValue(); - return new SystemSuiteActionRecord - { - Id = x.Props.Id.GetValue(), - TenantId = x.Props.TenantId.GetValue(), - SystemSuiteId = x.Props.SystemSuiteId!.GetValue(), - ModuleId = x.Props.ModuleId?.GetValue(), - Code = x.Props.Code.GetValue(), - Name = x.Props.Name.GetValue(), - CreatedBy = a.CreatedBy, - CreatedAtUtc = a.CreatedAt, - UpdatedBy = a.UpdatedBy, - UpdatedAtUtc = a.UpdatedAt, - AuditTimeSpan = a.TimeSpan, - }; - }).ToList(), - DomainResources = aggregate.DomainResources.Select(x => - { - var a = x.Props.Audit.GetValue(); - return new SystemSuiteDomainResourceRecord - { - Id = x.Props.Id.GetValue(), - SystemSuiteId = x.Props.SystemSuiteId.GetValue(), - ModuleId = x.Props.ModuleId?.GetValue(), - ParentResourceId = x.Props.ParentResourceId?.GetValue(), - Type = x.Props.Type.Name, - Code = x.Props.Code.GetValue(), - Name = x.Props.Name.GetValue(), - Description = x.Props.Description.GetValue(), - CreatedBy = a.CreatedBy, - CreatedAtUtc = a.CreatedAt, - UpdatedBy = a.UpdatedBy, - UpdatedAtUtc = a.UpdatedAt, - AuditTimeSpan = a.TimeSpan, - }; - }).ToList(), - }; - } - - private static SystemSuiteModuleRecord ToRecord(Ums.Domain.Authorization.SystemSuite.Module.Module module) - { - var audit = module.Props.Audit.GetValue(); - return new SystemSuiteModuleRecord - { - Id = module.Props.Id.GetValue(), - SystemSuiteId = module.Props.SystemId.GetValue(), - Code = module.Props.Code.GetValue(), - Name = module.Props.Name.GetValue(), - Description = module.Props.Description.GetValue(), - StatusId = module.Props.Status.Id, - SortOrder = module.Props.SortOrder, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - Menus = module.Menus.Select(ToRecord).ToList(), - }; - } - - private static SystemSuiteMenuRecord ToRecord(Ums.Domain.Authorization.SystemSuite.Menu.Menu menu) - { - var audit = menu.Props.Audit.GetValue(); - return new SystemSuiteMenuRecord - { - Id = menu.Props.Id.GetValue(), - ModuleId = menu.Props.ModuleId.GetValue(), - Code = menu.Props.Code.GetValue(), - Label = menu.Props.Label.GetValue(), - Description = menu.Props.Description.GetValue(), - SortOrder = menu.Props.SortOrder, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - SubMenus = menu.SubMenus.Select(ToRecord).ToList(), - }; - } - - private static SystemSuiteSubMenuRecord ToRecord(Ums.Domain.Authorization.SystemSuite.SubMenu.SubMenu subMenu) - { - var audit = subMenu.Props.Audit.GetValue(); - return new SystemSuiteSubMenuRecord - { - Id = subMenu.Props.Id.GetValue(), - MenuId = subMenu.Props.MenuId.GetValue(), - Code = subMenu.Props.Code.GetValue(), - Label = subMenu.Props.Label.GetValue(), - Description = subMenu.Props.Description.GetValue(), - SortOrder = subMenu.Props.SortOrder, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - Options = subMenu.Options.Select(ToRecord).ToList(), - }; - } - - private static SystemSuiteOptionRecord ToRecord(Ums.Domain.Authorization.SystemSuite.Option.Option option) - { - var audit = option.Props.Audit.GetValue(); - return new SystemSuiteOptionRecord - { - Id = option.Props.Id.GetValue(), - SubMenuId = option.Props.SubMenuId.GetValue(), - Code = option.Props.Code.GetValue(), - Label = option.Props.Label.GetValue(), - Description = option.Props.Description.GetValue(), - ActionCode = option.Props.ActionCode.GetValue(), - SortOrder = option.Props.SortOrder, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - }; - } - - private void Apply(SystemSuiteRecord target, SystemSuiteAggregate source) - { - var replacement = ToRecord(source); - - // ── Scalar properties ──────────────────────────────────────────────── - target.TenantId = replacement.TenantId; - target.Code = replacement.Code; - target.Name = replacement.Name; - target.Description = replacement.Description; - target.StatusId = replacement.StatusId; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - - // ── Modules ─────────────────────────────────────────────────────────── - ReconcileModules(target.Modules, replacement.Modules); - - // ── AppSettings ─────────────────────────────────────────────────────── - // Key: (ConfigKey, ScopeId) — NOT Id. - // ToRecord() generates Id = Guid.NewGuid() on every call, so using Id - // as the key would always treat every existing setting as Deleted and every - // replacement as a new INSERT, causing DbUpdateConcurrencyException when - // EF Core finds a non-default GUID that doesn't exist in the database. - ReconcileByKey( - target.AppSettings, - replacement.AppSettings, - s => (s.ConfigKey, s.ScopeId), - (existing, rep) => - { - existing.ConfigKey = rep.ConfigKey; - existing.ConfigValue = rep.ConfigValue; - existing.ScopeId = rep.ScopeId; - }); - - // ── Actions ─────────────────────────────────────────────────────────── - ReconcileByKey( - target.Actions, - replacement.Actions, - a => a.Id, - (existing, rep) => - { - existing.Code = rep.Code; - existing.Name = rep.Name; - existing.ModuleId = rep.ModuleId; - existing.UpdatedBy = rep.UpdatedBy; - existing.UpdatedAtUtc = rep.UpdatedAtUtc; - existing.AuditTimeSpan = rep.AuditTimeSpan; - }); - - // ── DomainResources ─────────────────────────────────────────────────── - ReconcileByKey( - target.DomainResources, - replacement.DomainResources, - d => d.Id, - (existing, rep) => - { - existing.ModuleId = rep.ModuleId; - existing.Type = rep.Type; - existing.Code = rep.Code; - existing.Name = rep.Name; - existing.Description = rep.Description; - existing.UpdatedBy = rep.UpdatedBy; - existing.UpdatedAtUtc = rep.UpdatedAtUtc; - existing.AuditTimeSpan = rep.AuditTimeSpan; - }); - } - - /// - /// Reconciles a tracked EF Core child collection against a replacement set. - /// - /// EF Core change-tracking subtlety (FIX-09): - /// When a new entity with a manually-set non-default GUID is added to a tracked - /// navigation collection, EF Core's DetectChanges() may assign - /// EntityState.Modified (assuming the GUID already exists in the database) - /// instead of EntityState.Added. This causes an UPDATE on a non-existent row - /// → 0 rows affected → false DbUpdateConcurrencyException. - /// - /// The fix: explicitly set EntityState.Added on every genuinely new entity - /// so EF Core always generates INSERT, never UPDATE. - /// - private void ReconcileByKey( - IList tracked, - IList replacement, - Func keySelector, - Action update) - where TKey : notnull - { - var replacementByKey = replacement.ToDictionary(keySelector); - var trackedByKey = tracked.ToDictionary(keySelector); - - // Remove entities no longer present in the aggregate. - foreach (var (key, entity) in trackedByKey) - { - if (!replacementByKey.ContainsKey(key)) - tracked.Remove(entity); - } - - // Update existing in-place or explicitly INSERT new. - foreach (var (key, repEntity) in replacementByKey) - { - if (trackedByKey.TryGetValue(key, out var existingEntity)) - { - update(existingEntity, repEntity); // update in-place — no PK conflict - } - else - { - tracked.Add(repEntity); - - // FIX-09: Force EntityState.Added so EF Core generates INSERT. - // DetectChanges() alone would assign Modified for entities with a - // pre-set non-default GUID, trying to UPDATE a row that does not exist. - dbContext.Entry(repEntity).State = EntityState.Added; - } - } - } - - /// - /// Reconciles the Modules collection and recursively reconciles all - /// Menus → SubMenus → Options within each module. - /// - private void ReconcileModules( - IList tracked, - IList replacement) - { - ReconcileByKey( - tracked, - replacement, - m => m.Id, - (existing, rep) => - { - existing.Code = rep.Code; - existing.Name = rep.Name; - existing.Description = rep.Description; - existing.StatusId = rep.StatusId; - existing.SortOrder = rep.SortOrder; - existing.UpdatedBy = rep.UpdatedBy; - existing.UpdatedAtUtc = rep.UpdatedAtUtc; - existing.AuditTimeSpan = rep.AuditTimeSpan; - - ReconcileMenus(existing.Menus, rep.Menus); - }); - } - - private void ReconcileMenus( - IList tracked, - IList replacement) - { - ReconcileByKey( - tracked, - replacement, - m => m.Id, - (existing, rep) => - { - existing.Code = rep.Code; - existing.Label = rep.Label; - existing.Description = rep.Description; - existing.SortOrder = rep.SortOrder; - existing.UpdatedBy = rep.UpdatedBy; - existing.UpdatedAtUtc = rep.UpdatedAtUtc; - existing.AuditTimeSpan = rep.AuditTimeSpan; - - ReconcileSubMenus(existing.SubMenus, rep.SubMenus); - }); - } - - private void ReconcileSubMenus( - IList tracked, - IList replacement) - { - ReconcileByKey( - tracked, - replacement, - sm => sm.Id, - (existing, rep) => - { - existing.Code = rep.Code; - existing.Label = rep.Label; - existing.Description = rep.Description; - existing.SortOrder = rep.SortOrder; - existing.UpdatedBy = rep.UpdatedBy; - existing.UpdatedAtUtc = rep.UpdatedAtUtc; - existing.AuditTimeSpan = rep.AuditTimeSpan; - - ReconcileOptions(existing.Options, rep.Options); - }); - } - - private void ReconcileOptions( - IList tracked, - IList replacement) - { - ReconcileByKey( - tracked, - replacement, - o => o.Id, - (existing, rep) => - { - existing.Code = rep.Code; - existing.Label = rep.Label; - existing.Description = rep.Description; - existing.ActionCode = rep.ActionCode; - existing.SortOrder = rep.SortOrder; - existing.UpdatedBy = rep.UpdatedBy; - existing.UpdatedAtUtc = rep.UpdatedAtUtc; - existing.AuditTimeSpan = rep.AuditTimeSpan; - }); - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerTemplateAssignmentRuleRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerTemplateAssignmentRuleRepository.cs deleted file mode 100644 index 0fd8bde0..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/SqlServerTemplateAssignmentRuleRepository.cs +++ /dev/null @@ -1,140 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Authorization; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence; -using Ums.Infrastructure.Persistence.Authorization.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Authorization; - -using AssignmentRuleAggregate = Ums.Domain.Authorization.AssignmentRule.TemplateAssignmentRule; - -public sealed class SqlServerTemplateAssignmentRuleRepository(UmsPlatformDbContext dbContext) - : ITemplateAssignmentRuleRepository, IUnitOfWork -{ - private readonly HashSet _trackedAggregates = []; - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.TemplateAssignmentRules - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - => GetByIdAsync(id, cancellationToken); - - public async Task> GetByTenantIdAsync( - Guid tenantId, CancellationToken cancellationToken = default) - { - var records = await dbContext.TemplateAssignmentRules - .Where(x => x.TenantId == tenantId) - .OrderByDescending(x => x.Priority) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetActiveByTenantAndRoleAsync( - Guid tenantId, Guid roleId, CancellationToken cancellationToken = default) - { - var activeStatusId = (int)Ums.Domain.Authorization.AssignmentRule.TemplateAssignmentRuleStatus.Active; - - var records = await dbContext.TemplateAssignmentRules - .Where(x => x.TenantId == tenantId && x.RoleId == roleId && x.StatusId == activeStatusId) - .OrderByDescending(x => x.Priority) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public Task ExistsActiveWithPriorityAsync( - Guid tenantId, int priority, CancellationToken cancellationToken = default) - { - var activeStatusId = (int)Ums.Domain.Authorization.AssignmentRule.TemplateAssignmentRuleStatus.Active; - - return dbContext.TemplateAssignmentRules - .AnyAsync(x => x.TenantId == tenantId && x.Priority == priority && x.StatusId == activeStatusId, - cancellationToken); - } - - public Task AddAsync(AssignmentRuleAggregate aggregate, CancellationToken cancellationToken = default) - { - dbContext.TemplateAssignmentRules.Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(AssignmentRuleAggregate aggregate, CancellationToken cancellationToken = default) - { - var existing = await dbContext.TemplateAssignmentRules - .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"TemplateAssignmentRule {aggregate.Props.Id.GetValue()} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - await dbContext.SaveChangesAsync(cancellationToken); - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() { } - - private static AssignmentRuleAggregate Rehydrate(TemplateAssignmentRuleRecord record) - => AuthorizationAggregateFactory.RehydrateAssignmentRule(record); - - private static TemplateAssignmentRuleRecord ToRecord(AssignmentRuleAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new TemplateAssignmentRuleRecord - { - Id = aggregate.Props.Id.GetValue(), - TenantId = aggregate.Props.TenantId.GetValue(), - TemplateId = aggregate.Props.TemplateId.GetValue(), - RoleId = aggregate.Props.RoleId.GetValue(), - Priority = aggregate.Priority, - StatusId = (int)aggregate.Status, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - }; - } - - private static void Apply(TemplateAssignmentRuleRecord target, AssignmentRuleAggregate source) - { - var replacement = ToRecord(source); - target.TenantId = replacement.TenantId; - target.TemplateId = replacement.TemplateId; - target.RoleId = replacement.RoleId; - target.Priority = replacement.Priority; - target.StatusId = replacement.StatusId; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerAppConfigurationRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerAppConfigurationRepository.cs deleted file mode 100644 index d1a3d818..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerAppConfigurationRepository.cs +++ /dev/null @@ -1,178 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Configuration; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence.Configuration.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Configuration; - -using AppConfigurationAggregate = Ums.Domain.Configuration.AppConfiguration.AppConfiguration; - -public sealed class SqlServerAppConfigurationRepository(UmsPlatformDbContext dbContext) : IAppConfigurationRepository, IUnitOfWork -{ - private readonly HashSet _trackedAggregates = []; - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.AppConfigurations - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - => GetByIdAsync(id, cancellationToken); - - public async Task GetByScopeAndCodeAsync(Guid? tenantId, Guid? systemSuiteId, Guid? moduleId, string code, CancellationToken cancellationToken = default) - { - IQueryable query = dbContext.AppConfigurations; - - if (tenantId.HasValue) - { - query = query.IgnoreQueryFilters(); - } - - var record = await query - .FirstOrDefaultAsync(x => - x.TenantId == tenantId - && x.SystemSuiteId == systemSuiteId - && x.ModuleId == moduleId - && x.Code == code, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) - { - IQueryable query = dbContext.AppConfigurations; - - if (tenantId.HasValue) - { - query = query.IgnoreQueryFilters().Where(x => x.TenantId == tenantId.Value); - } - - var records = await query.OrderBy(x => x.Code).ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public Task AddAsync(AppConfigurationAggregate aggregate, CancellationToken cancellationToken = default) - { - dbContext.AppConfigurations.Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(AppConfigurationAggregate aggregate, CancellationToken cancellationToken = default) - => await UpdateAsync(aggregate, expectedRowVersion: null, cancellationToken); - - /// - public async Task UpdateAsync( - AppConfigurationAggregate aggregate, - byte[]? expectedRowVersion, - CancellationToken cancellationToken = default) - { - var existing = await dbContext.AppConfigurations - .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"AppConfiguration {aggregate.Props.Id.GetValue()} does not exist."); - - Apply(existing, aggregate); - - // REC-10: If the caller supplied a RowVersion from the If-Match header, override EF Core's - // tracked "original" value so the generated UPDATE includes a WHERE RowVersion = @client_rv. - // A concurrent modification will cause 0 rows affected → DbUpdateConcurrencyException → HTTP 409. - if (expectedRowVersion is { Length: > 0 }) - { - dbContext.Entry(existing).Property(x => x.RowVersion).OriginalValue = expectedRowVersion; - } - - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await dbContext.SaveChangesAsync(cancellationToken); - } - catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException ex) - { - // FIX-03: surface optimistic concurrency failures as 409 Conflict - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() => dbContext.Dispose(); - - private static AppConfigurationAggregate Rehydrate(AppConfigurationRecord record) - => ConfigurationAggregateFactory.RehydrateAppConfiguration(record); - - private static AppConfigurationRecord ToRecord(AppConfigurationAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new AppConfigurationRecord - { - Id = aggregate.Props.Id.GetValue(), - TenantId = aggregate.Props.TenantId?.GetValue(), - SystemSuiteId = aggregate.Props.SystemSuiteId?.GetValue(), - ModuleId = aggregate.Props.ModuleId?.GetValue(), - Code = aggregate.Props.Code.GetValue(), - Value = aggregate.Props.Value.GetValue(), - Description = aggregate.Props.Description.GetValue(), - ScopeId = aggregate.Props.Scope.Id, - IsInheritable = aggregate.Props.IsInheritable, - IsEncrypted = aggregate.Props.IsEncrypted, - IsNonOverridable = aggregate.Props.IsNonOverridable, - Version = aggregate.Props.Version, - StatusId = aggregate.Props.Status.Id, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - }; - } - - private static void Apply(AppConfigurationRecord target, AppConfigurationAggregate source) - { - var replacement = ToRecord(source); - - target.TenantId = replacement.TenantId; - target.SystemSuiteId = replacement.SystemSuiteId; - target.ModuleId = replacement.ModuleId; - target.Code = replacement.Code; - target.Value = replacement.Value; - target.Description = replacement.Description; - target.ScopeId = replacement.ScopeId; - target.IsInheritable = replacement.IsInheritable; - target.IsEncrypted = replacement.IsEncrypted; - target.IsNonOverridable = replacement.IsNonOverridable; - target.Version = replacement.Version; - target.StatusId = replacement.StatusId; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerFeatureFlagRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerFeatureFlagRepository.cs deleted file mode 100644 index 603b2028..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerFeatureFlagRepository.cs +++ /dev/null @@ -1,230 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Configuration; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence; -using Ums.Infrastructure.Persistence.Configuration.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Configuration; - -using FeatureFlagAggregate = Ums.Domain.Configuration.FeatureFlag.FeatureFlag; - -public sealed class SqlServerFeatureFlagRepository(UmsPlatformDbContext dbContext) : IFeatureFlagRepository, IUnitOfWork -{ - private readonly HashSet _trackedAggregates = []; - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.FeatureFlags - .AsSplitQuery() - .Include(x => x.EvaluationLogs) - .Include(x => x.Criteria) - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - => GetByIdAsync(id, cancellationToken); - - public async Task GetByCodeAsync(string flagCode, CancellationToken cancellationToken = default) - { - var record = await dbContext.FeatureFlags - .AsSplitQuery() - .Include(x => x.EvaluationLogs) - .Include(x => x.Criteria) - .FirstOrDefaultAsync(x => x.FlagCode == flagCode, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public async Task GetBySystemSuiteAndCodeAsync(Guid systemSuiteId, string flagCode, CancellationToken cancellationToken = default) - { - var record = await dbContext.FeatureFlags - .AsSplitQuery() - .Include(x => x.EvaluationLogs) - .Include(x => x.Criteria) - .FirstOrDefaultAsync(x => x.SystemSuiteId == systemSuiteId && x.FlagCode == flagCode, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) - { - IQueryable query = dbContext.FeatureFlags.AsSplitQuery() - .Include(x => x.EvaluationLogs) - .Include(x => x.Criteria); - - if (tenantId.HasValue) - { - query = query.Where(x => x.TenantId == tenantId.Value); - } - - var records = await query.OrderBy(x => x.FlagCode).ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetBySystemSuiteIdAsync(Guid systemSuiteId, CancellationToken cancellationToken = default) - { - var records = await dbContext.FeatureFlags - .AsSplitQuery() - .Include(x => x.EvaluationLogs) - .Include(x => x.Criteria) - .Where(x => x.SystemSuiteId == systemSuiteId) - .OrderBy(x => x.FlagCode) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public Task AddAsync(FeatureFlagAggregate aggregate, CancellationToken cancellationToken = default) - { - dbContext.FeatureFlags.Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(FeatureFlagAggregate aggregate, CancellationToken cancellationToken = default) - { - var existing = await dbContext.FeatureFlags - .Include(x => x.EvaluationLogs) - .Include(x => x.Criteria) - .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"FeatureFlag {aggregate.Props.Id.GetValue()} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await dbContext.SaveChangesAsync(cancellationToken); - } - catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException ex) - { - // FIX-03: surface optimistic concurrency failures as 409 Conflict - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() => dbContext.Dispose(); - - private static FeatureFlagAggregate Rehydrate(FeatureFlagRecord record) - => ConfigurationAggregateFactory.RehydrateFeatureFlag(record, record.EvaluationLogs, record.Criteria); - - private static FeatureFlagRecord ToRecord(FeatureFlagAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new FeatureFlagRecord - { - Id = aggregate.Props.Id.GetValue(), - SystemSuiteId = aggregate.Props.SystemSuiteId.GetValue(), - TenantId = aggregate.Props.TenantId?.GetValue(), - FlagCode = aggregate.Props.FlagCode, - FlagTypeId = aggregate.Props.FlagType.Id, - FlagTargets = aggregate.Props.FlagTargets, - StatusId = aggregate.Props.Status.Id, - LinkedResourceTypeId = aggregate.Props.LinkedResourceType?.Id, - LinkedResourceId = aggregate.Props.LinkedResourceId?.GetValue(), - RolloutPercentage = aggregate.Props.RolloutPercentage, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - EvaluationLogs = aggregate.EvaluationLog.Select(log => new FeatureFlagEvaluationLogRecord - { - Id = log.Props.Id.GetValue(), - FeatureFlagId = aggregate.Props.Id.GetValue(), - EvaluatedBy = log.Props.EvaluatedBy, - Result = log.Props.Result, - Context = log.Props.Context, - EvaluatedAtUtc = log.Props.EvaluatedAt, - }).ToList(), - Criteria = aggregate.Criteria.Select(c => new FeatureFlagCriteriaRecord - { - Id = c.Props.Id.GetValue(), - FeatureFlagId = aggregate.Props.Id.GetValue(), - CriteriaType = c.CriteriaType, - Operator = c.Operator, - Value = c.Value, - CreatedAtUtc = c.CreatedAtUtc, - }).ToList(), - }; - } - - private void Apply(FeatureFlagRecord target, FeatureFlagAggregate source) - { - var replacement = ToRecord(source); - - target.SystemSuiteId = replacement.SystemSuiteId; - target.TenantId = replacement.TenantId; - target.FlagCode = replacement.FlagCode; - target.FlagTypeId = replacement.FlagTypeId; - target.FlagTargets = replacement.FlagTargets; - target.StatusId = replacement.StatusId; - target.LinkedResourceTypeId = replacement.LinkedResourceTypeId; - target.LinkedResourceId = replacement.LinkedResourceId; - target.RolloutPercentage = replacement.RolloutPercentage; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - - EfChildCollectionReconciler.ReconcileById( - dbContext, - target.EvaluationLogs, - replacement.EvaluationLogs, - log => log.Id, - UpdateEvaluationLog); - - EfChildCollectionReconciler.ReconcileById( - dbContext, - target.Criteria, - replacement.Criteria, - criterion => criterion.Id, - UpdateCriterion); - } - - private static void UpdateEvaluationLog(FeatureFlagEvaluationLogRecord target, FeatureFlagEvaluationLogRecord source) - { - target.FeatureFlagId = source.FeatureFlagId; - target.EvaluatedBy = source.EvaluatedBy; - target.Result = source.Result; - target.Context = source.Context; - target.EvaluatedAtUtc = source.EvaluatedAtUtc; - } - - private static void UpdateCriterion(FeatureFlagCriteriaRecord target, FeatureFlagCriteriaRecord source) - { - target.FeatureFlagId = source.FeatureFlagId; - target.CriteriaType = source.CriteriaType; - target.Operator = source.Operator; - target.Value = source.Value; - target.CreatedAtUtc = source.CreatedAtUtc; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerIdpConfigurationRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerIdpConfigurationRepository.cs deleted file mode 100644 index 115428e1..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerIdpConfigurationRepository.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Configuration; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence.Configuration.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Configuration; - -using IdpConfigurationAggregate = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration; - -public sealed class SqlServerIdpConfigurationRepository(UmsPlatformDbContext dbContext) : IIdpConfigurationRepository, IUnitOfWork -{ - private readonly HashSet _trackedAggregates = []; - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.IdpConfigurations - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - => GetByIdAsync(id, cancellationToken); - - public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) - { - IQueryable query = dbContext.IdpConfigurations; - - if (tenantId.HasValue) - { - query = query.Where(x => x.TenantId == tenantId.Value); - } - - var records = await query.OrderBy(x => x.TenantId).ThenBy(x => x.ResolutionPriority).ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) - { - var records = await dbContext.IdpConfigurations - .Where(x => x.TenantId == tenantId) - .OrderBy(x => x.ResolutionPriority) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public Task AddAsync(IdpConfigurationAggregate aggregate, CancellationToken cancellationToken = default) - { - dbContext.IdpConfigurations.Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(IdpConfigurationAggregate aggregate, CancellationToken cancellationToken = default) - { - var existing = await dbContext.IdpConfigurations - .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"IdpConfiguration {aggregate.Props.Id.GetValue()} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await dbContext.SaveChangesAsync(cancellationToken); - } - catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException ex) - { - // FIX-03: surface optimistic concurrency failures as 409 Conflict - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() => dbContext.Dispose(); - - private static IdpConfigurationAggregate Rehydrate(IdpConfigurationRecord record) - => ConfigurationAggregateFactory.RehydrateIdpConfiguration(record); - - private static IdpConfigurationRecord ToRecord(IdpConfigurationAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new IdpConfigurationRecord - { - Id = aggregate.Props.Id.GetValue(), - TenantId = aggregate.Props.TenantId.GetValue(), - SystemSuiteId = aggregate.Props.SystemSuiteId.GetValue(), - ProviderTypeId = aggregate.Props.ProviderType.Id, - DomainHintsJson = JsonSerializer.Serialize(aggregate.Props.DomainHints), - ConfigPayload = aggregate.Props.ConfigPayload, - SecretRef = aggregate.Props.SecretRef, - StatusId = aggregate.Props.Status.Id, - ResolutionPriority = aggregate.Props.ResolutionPriority, - FallbackToId = aggregate.Props.FallbackToId, - Version = aggregate.Props.Version, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - }; - } - - private static void Apply(IdpConfigurationRecord target, IdpConfigurationAggregate source) - { - var replacement = ToRecord(source); - - target.TenantId = replacement.TenantId; - target.SystemSuiteId = replacement.SystemSuiteId; - target.ProviderTypeId = replacement.ProviderTypeId; - target.DomainHintsJson = replacement.DomainHintsJson; - target.ConfigPayload = replacement.ConfigPayload; - target.SecretRef = replacement.SecretRef; - target.StatusId = replacement.StatusId; - target.ResolutionPriority = replacement.ResolutionPriority; - target.FallbackToId = replacement.FallbackToId; - target.Version = replacement.Version; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerParameterRepositories.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerParameterRepositories.cs deleted file mode 100644 index b526739b..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/SqlServerParameterRepositories.cs +++ /dev/null @@ -1,226 +0,0 @@ -namespace Ums.Infrastructure.Persistence.Configuration; - -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Configuration; -using Ums.Domain.Configuration.Parameter; -using Ums.Domain.Kernel; -using Ums.Domain.Kernel.ValueObjects; -using Ums.Infrastructure.Persistence.Configuration.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -public sealed class SqlServerParameterDefinitionRepository(UmsPlatformDbContext db) - : IParameterDefinitionRepository -{ - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - var r = await db.ParameterDefinitions.FirstOrDefaultAsync(x => x.Id == id, ct); - return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterDefinition(r); - } - - public async Task GetByCodeAsync(string code, CancellationToken ct = default) - { - var upper = code.ToUpperInvariant(); - var r = await db.ParameterDefinitions.FirstOrDefaultAsync(x => x.Code == upper, ct); - return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterDefinition(r); - } - - public async Task> GetAllAsync(CancellationToken ct = default) - { - var records = await db.ParameterDefinitions.OrderBy(x => x.DisplayOrder).ToListAsync(ct); - return records.Select(ConfigurationAggregateFactory.RehydrateParameterDefinition).ToList(); - } - - public async Task AddAsync(ParameterDefinition d, CancellationToken ct = default) - => await db.ParameterDefinitions.AddAsync(ToRecord(d), ct); - - public async Task UpdateAsync(ParameterDefinition d, CancellationToken ct = default) - { - var existing = await db.ParameterDefinitions - .FirstOrDefaultAsync(x => x.Id == d.Props.Id.GetValue(), ct) - ?? throw new InvalidOperationException($"ParameterDefinition {d.Props.Id.GetValue()} not found."); - Apply(existing, d); - } - - public Task CountByCodeAsync(string code, CancellationToken ct = default) - => db.ParameterDefinitions.CountAsync(x => x.Code == code.ToUpperInvariant(), ct); - - public Task CountGlobalValuesAsync(Guid definitionId, CancellationToken ct = default) - => db.ParameterGlobalValues.CountAsync(x => x.ParameterDefinitionId == definitionId, ct); - - public Task CountTenantValuesAsync(Guid definitionId, CancellationToken ct = default) - => db.ParameterTenantValues.CountAsync(x => x.ParameterDefinitionId == definitionId, ct); - - public async Task SaveChangesAsync(CancellationToken ct = default) - { - await db.SaveChangesAsync(ct); - return true; - } - - private static ParameterDefinitionRecord ToRecord(ParameterDefinition d) - { - var audit = d.Props.Audit.GetValue(); - return new ParameterDefinitionRecord - { - Id = d.Props.Id.GetValue(), - Code = d.Props.Code.GetValue(), - Name = d.Props.Name.Value, - Description = d.Props.Description.GetValue(), - DataTypeId = d.Props.DataType.Id, - DefaultValue = d.Props.DefaultValue.Value, - ScopeId = d.Props.Scope.Id, - IsActive = d.Props.IsActive, - IsMandatory = d.Props.IsMandatory, - DisplayOrder = d.Props.DisplayOrder, - Version = d.Props.Version, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - }; - } - - private static void Apply(ParameterDefinitionRecord t, ParameterDefinition d) - { - var audit = d.Props.Audit.GetValue(); - t.Name = d.Props.Name.Value; - t.Description = d.Props.Description.GetValue(); - t.DefaultValue = d.Props.DefaultValue.Value; - t.ScopeId = d.Props.Scope.Id; - t.IsActive = d.Props.IsActive; - t.IsMandatory = d.Props.IsMandatory; - t.DisplayOrder = d.Props.DisplayOrder; - t.Version = d.Props.Version; - t.UpdatedBy = audit.UpdatedBy; - t.UpdatedAtUtc = audit.UpdatedAt; - t.AuditTimeSpan = audit.TimeSpan; - } -} - -public sealed class SqlServerParameterGlobalValueRepository(UmsPlatformDbContext db) - : IParameterGlobalValueRepository -{ - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - var r = await db.ParameterGlobalValues.FirstOrDefaultAsync(x => x.Id == id, ct); - return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterGlobalValue(r); - } - - public async Task GetByDefinitionIdAsync(Guid definitionId, CancellationToken ct = default) - { - var r = await db.ParameterGlobalValues - .FirstOrDefaultAsync(x => x.ParameterDefinitionId == definitionId, ct); - return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterGlobalValue(r); - } - - public async Task AddAsync(ParameterGlobalValue v, CancellationToken ct = default) - => await db.ParameterGlobalValues.AddAsync(ToRecord(v), ct); - - public async Task UpdateAsync(ParameterGlobalValue v, CancellationToken ct = default) - { - var existing = await db.ParameterGlobalValues - .FirstOrDefaultAsync(x => x.Id == v.Props.Id.GetValue(), ct) - ?? throw new InvalidOperationException($"ParameterGlobalValue {v.Props.Id.GetValue()} not found."); - Apply(existing, v); - } - - public async Task SaveChangesAsync(CancellationToken ct = default) - { - await db.SaveChangesAsync(ct); - return true; - } - - private static ParameterGlobalValueRecord ToRecord(ParameterGlobalValue v) - { - var audit = v.Props.Audit.GetValue(); - return new ParameterGlobalValueRecord - { - Id = v.Props.Id.GetValue(), - ParameterDefinitionId = v.Props.ParameterDefinitionId.GetValue(), - EffectiveValue = v.Props.Value.Value, - StatusId = v.Props.Status.Id, - Version = v.Props.Version, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - }; - } - - private static void Apply(ParameterGlobalValueRecord t, ParameterGlobalValue v) - { - var audit = v.Props.Audit.GetValue(); - t.EffectiveValue = v.Props.Value.Value; - t.StatusId = v.Props.Status.Id; - t.Version = v.Props.Version; - t.UpdatedBy = audit.UpdatedBy; - t.UpdatedAtUtc = audit.UpdatedAt; - t.AuditTimeSpan = audit.TimeSpan; - } -} - -public sealed class SqlServerParameterTenantValueRepository(UmsPlatformDbContext db) - : IParameterTenantValueRepository -{ - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - var r = await db.ParameterTenantValues.FirstOrDefaultAsync(x => x.Id == id, ct); - return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterTenantValue(r); - } - - public async Task GetByTenantAndDefinitionAsync( - Guid tenantId, Guid definitionId, CancellationToken ct = default) - { - var r = await db.ParameterTenantValues - .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.ParameterDefinitionId == definitionId, ct); - return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterTenantValue(r); - } - - public async Task AddAsync(ParameterTenantValue v, CancellationToken ct = default) - => await db.ParameterTenantValues.AddAsync(ToRecord(v), ct); - - public async Task UpdateAsync(ParameterTenantValue v, CancellationToken ct = default) - { - var existing = await db.ParameterTenantValues - .FirstOrDefaultAsync(x => x.Id == v.Props.Id.GetValue(), ct) - ?? throw new InvalidOperationException($"ParameterTenantValue {v.Props.Id.GetValue()} not found."); - Apply(existing, v); - } - - public async Task SaveChangesAsync(CancellationToken ct = default) - { - await db.SaveChangesAsync(ct); - return true; - } - - private static ParameterTenantValueRecord ToRecord(ParameterTenantValue v) - { - var audit = v.Props.Audit.GetValue(); - return new ParameterTenantValueRecord - { - Id = v.Props.Id.GetValue(), - TenantId = v.Props.TenantId.GetValue(), - ParameterDefinitionId = v.Props.ParameterDefinitionId.GetValue(), - OverrideValue = v.Props.Value.Value, - StatusId = v.Props.Status.Id, - Version = v.Props.Version, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - }; - } - - private static void Apply(ParameterTenantValueRecord t, ParameterTenantValue v) - { - var audit = v.Props.Audit.GetValue(); - t.OverrideValue = v.Props.Value.Value; - t.StatusId = v.Props.Status.Id; - t.Version = v.Props.Version; - t.UpdatedBy = audit.UpdatedBy; - t.UpdatedAtUtc = audit.UpdatedAt; - t.AuditTimeSpan = audit.TimeSpan; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerTenantRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerTenantRepository.cs deleted file mode 100644 index 1a8cdb50..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerTenantRepository.cs +++ /dev/null @@ -1,449 +0,0 @@ -using System.Data; -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Identity; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence; -using Ums.Infrastructure.Persistence.Identity.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Identity; - -using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; - -public sealed class SqlServerTenantRepository(UmsPlatformDbContext dbContext) : ITenantRepository, IUnitOfWork -{ - private readonly HashSet _trackedAggregates = []; - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.Tenants - .AsSingleQuery() - .Include(x => x.Branches) - .Include(x => x.IdentityProviders) - .Include(x => x.Branding) - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - if (record is not null) - { - await LoadSqliteTenantChildrenAsync(record, cancellationToken); - } - - return record is null ? null : Rehydrate(record); - } - - public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - => GetByIdAsync(id, cancellationToken); - - // G-161: verificación de unicidad autoritativa que IGNORA el filtro global por inquilino (ver - // PostgreSqlTenantRepository). Devuelve solo un booleano y se usa en la vía de escritura acotada. - public Task BranchCodeExistsAsync(Guid tenantId, string code, CancellationToken cancellationToken = default) - => dbContext.TenantBranches - .IgnoreQueryFilters() - .AnyAsync(b => b.TenantId == tenantId && b.Code == code, cancellationToken); - - public async Task GetByCodeAsync(string code, CancellationToken cancellationToken = default) - { - var record = await dbContext.Tenants - .Include(x => x.Branches) - .Include(x => x.IdentityProviders) - .Include(x => x.Branding) - .FirstOrDefaultAsync(x => x.Code == code, cancellationToken); - - if (record is not null) - { - await LoadSqliteTenantChildrenAsync(record, cancellationToken); - } - - return record is null ? null : Rehydrate(record); - } - - public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) - { - var baseQuery = dbContext.Tenants.AsQueryable(); - - if (tenantId.HasValue) - { - // Scope to the tenant itself and any direct child tenants - baseQuery = baseQuery.Where(t => t.Id == tenantId.Value || t.ParentTenantId == tenantId.Value); - } - - var records = await baseQuery - .AsSplitQuery() - .Include(x => x.Branches) - .Include(x => x.IdentityProviders) - .Include(x => x.Branding) - .OrderBy(x => x.Name) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - /// - public async Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( - int page, int pageSize, string? search, string? status, string sortBy, string sortOrder, - Guid? tenantId = null, CancellationToken cancellationToken = default) - { - // REC-12: Apply all filtering at the DB level before Skip/Take to avoid loading full tables. - var query = dbContext.Tenants.AsQueryable(); - - // Tenant isolation: scope to the tenant itself and its direct children - if (tenantId.HasValue) - { - query = query.Where(t => t.Id == tenantId.Value || t.ParentTenantId == tenantId.Value); - } - - // --- Filtering --- - if (!string.IsNullOrWhiteSpace(search)) - { - var lower = search.ToLower(); - query = (sortBy.ToLower()) switch - { - "code" => query.Where(t => t.Code.ToLower().Contains(lower)), - _ => query.Where(t => t.Name.ToLower().Contains(lower)), - }; - } - - if (!string.IsNullOrWhiteSpace(status) && - !string.Equals(status, "all", StringComparison.OrdinalIgnoreCase)) - { - // StatusId is stored as int; compare by name via enum lookup if mapping exists - // For now filter by string representation at application level after fetch - // (StatusId-to-name mapping is in the domain enum). - // We do the simple case: filter after fetching the paged batch (small set). - } - - // --- Total count before pagination --- - var totalCount = await query.CountAsync(cancellationToken); - - // --- Sorting --- - query = (sortBy?.ToLower(), sortOrder?.ToLower()) switch - { - ("code", "desc") => query.OrderByDescending(t => t.Code), - ("code", _) => query.OrderBy(t => t.Code), - ("name", "desc") => query.OrderByDescending(t => t.Name), - _ => query.OrderBy(t => t.Name), - }; - - // --- Pagination (DB-level Skip/Take) --- - var records = await query - .AsSplitQuery() - .Include(x => x.Branches) - .Include(x => x.IdentityProviders) - .Include(x => x.Branding) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToListAsync(cancellationToken); - - return (records.Select(Rehydrate).ToList(), totalCount); - } - - /// - /// - /// Uses EF change tracking so soft-delete changes are committed together with the - /// TenantDeletedEvent outbox message in a single SaveChangesAsync call by the caller. - /// The caller MUST invoke UpdateAsync first (to populate _trackedAggregates) and then - /// call this method before SaveEntitiesAsync. - /// - public async Task SoftDeleteAsync(Guid id, string deletedBy, CancellationToken cancellationToken = default) - { - var record = await dbContext.Tenants - .IgnoreQueryFilters() - .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted, cancellationToken); - - if (record is null) return false; - - var now = DateTime.UtcNow; - record.IsDeleted = true; - record.DeletedAtUtc = now; - record.DeletedBy = deletedBy; - return true; - } - - public async Task AddAsync(TenantAggregate aggregate, CancellationToken cancellationToken = default) - { - dbContext.Tenants.Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - await Task.CompletedTask; - } - - public async Task UpdateAsync(TenantAggregate aggregate, CancellationToken cancellationToken = default) - { - var existing = await dbContext.Tenants - .Include(x => x.Branches) - .Include(x => x.IdentityProviders) - .Include(x => x.Branding) - .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"Tenant {aggregate.Props.Id.GetValue()} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - // Capture outbox messages BEFORE committing so events are not lost on save failure. - // MarkChangesAsCommitted() is called AFTER SaveChangesAsync succeeds (FIX-01). - foreach (var aggregate in _trackedAggregates) - { - await dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await dbContext.SaveChangesAsync(cancellationToken); - } - catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException ex) - { - // FIX-03: surface optimistic concurrency failures as 409 Conflict - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() => dbContext.Dispose(); - - private static TenantAggregate Rehydrate(TenantRecord record) - => IdentityAggregateFactory.RehydrateTenant(record, record.Branches, record.IdentityProviders, record.Branding); - - private async Task LoadSqliteTenantChildrenAsync(TenantRecord record, CancellationToken cancellationToken) - { - if (!dbContext.Database.IsSqlite()) - { - return; - } - - var tenantId = record.Id.ToString(); - - record.Branches = await LoadSqliteTenantBranchesAsync(tenantId, cancellationToken); - - record.IdentityProviders = await dbContext.TenantIdentityProviders - .FromSqlInterpolated($"SELECT * FROM TenantIdentityProviders WHERE lower(TenantId) = lower({tenantId})") - .ToListAsync(cancellationToken); - - record.Branding = await dbContext.TenantBrandings - .FromSqlInterpolated($"SELECT * FROM TenantBrandings WHERE lower(TenantId) = lower({tenantId})") - .FirstOrDefaultAsync(cancellationToken); - } - - private async Task> LoadSqliteTenantBranchesAsync( - string tenantId, - CancellationToken cancellationToken) - { - var connection = dbContext.Database.GetDbConnection(); - if (connection.State != ConnectionState.Open) - { - await connection.OpenAsync(cancellationToken); - } - - await using var command = connection.CreateCommand(); - command.CommandText = """ - SELECT Id, TenantId, Code, Name, GeofencingMetadata, IsActive, CreatedBy, CreatedAtUtc, UpdatedBy, UpdatedAtUtc, AuditTimeSpan - FROM TenantBranches - WHERE lower(TenantId) = lower($tenantId) - ORDER BY Code - """; - - var parameter = command.CreateParameter(); - parameter.ParameterName = "$tenantId"; - parameter.Value = tenantId; - command.Parameters.Add(parameter); - - var branches = new List(); - await using var reader = await command.ExecuteReaderAsync(cancellationToken); - - while (await reader.ReadAsync(cancellationToken)) - { - branches.Add(new TenantBranchRecord - { - Id = Guid.Parse(reader.GetString(0)), - TenantId = Guid.Parse(reader.GetString(1)), - Code = reader.GetString(2), - Name = reader.GetString(3), - GeofencingMetadata = reader.IsDBNull(4) ? null : reader.GetString(4), - IsActive = reader.GetBoolean(5), - CreatedBy = reader.GetString(6), - CreatedAtUtc = reader.GetDateTime(7), - UpdatedBy = reader.IsDBNull(8) ? null : reader.GetString(8), - UpdatedAtUtc = reader.IsDBNull(9) ? null : reader.GetDateTime(9), - AuditTimeSpan = reader.GetString(10), - }); - } - - return branches; - } - - private static TenantRecord ToRecord(TenantAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - - return new TenantRecord - { - Id = aggregate.Props.Id.GetValue(), - Code = aggregate.Code.GetValue(), - Name = aggregate.Name.GetValue(), - OrganizationTypeId = aggregate.Type.Id, - IdpStrategyId = aggregate.IdpStrategy.Id, - CompanyReference = aggregate.CompanyReference?.GetValue(), - ParentTenantId = aggregate.ParentTenantId?.GetValue(), - IsManagementOwner = aggregate.IsManagementOwner, - StatusId = aggregate.Status.Id, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - Branches = aggregate.Branches.Select(branch => - { - var a = branch.Props.Audit.GetValue(); - return new TenantBranchRecord - { - Id = branch.Props.Id.GetValue(), - TenantId = branch.Props.TenantId.GetValue(), - Code = branch.Code.GetValue(), - Name = branch.Name.GetValue(), - GeofencingMetadata = branch.GeofencingMetadata?.GetValue(), - IsActive = branch.IsActive, - CreatedBy = a.CreatedBy, - CreatedAtUtc = a.CreatedAt, - UpdatedBy = a.UpdatedBy, - UpdatedAtUtc = a.UpdatedAt, - AuditTimeSpan = a.TimeSpan, - }; - }).ToList(), - IdentityProviders = aggregate.IdentityProviders.Select(provider => - { - var a = provider.Props.Audit.GetValue(); - return new TenantIdentityProviderRecord - { - Id = provider.Props.Id.GetValue(), - TenantId = provider.Props.TenantId.GetValue(), - Code = provider.Code.GetValue(), - Name = provider.Name.GetValue(), - Description = provider.Description.GetValue(), - StrategyId = provider.Strategy.Id, - IsActive = provider.IsActive, - CreatedBy = a.CreatedBy, - CreatedAtUtc = a.CreatedAt, - UpdatedBy = a.UpdatedBy, - UpdatedAtUtc = a.UpdatedAt, - AuditTimeSpan = a.TimeSpan, - }; - }).ToList(), - Branding = aggregate.Branding is null ? null : ToBrandingRecord(aggregate.Branding), - }; - } - - private static TenantBrandingRecord ToBrandingRecord(Ums.Domain.Identity.Tenant.Branding.Branding branding) - { - var audit = branding.Props.Audit.GetValue(); - return new TenantBrandingRecord - { - Id = branding.Props.Id.GetValue(), - TenantId = branding.Props.TenantId.GetValue(), - Logo = branding.Logo.GetValue(), - LogoFormatId = branding.LogoFormat.Id, - PrimaryColor = branding.PrimaryColor.GetValue(), - BackgroundStyleId = branding.BackgroundStyle.Id, - HeadlineText = branding.HeadlineText.GetValue(), - SecondaryText = branding.SecondaryText.GetValue(), - PrimaryButtonLabel = branding.PrimaryButtonLabel.GetValue(), - FooterText = branding.FooterText.GetValue(), - CustomDomain = branding.CustomDomain?.GetValue(), - DnsVerificationStatusId = branding.DnsVerificationStatus.Id, - DnsCnameTarget = branding.DnsCnameTarget.GetValue(), - MagicLinkFallbackEnabled = branding.MagicLinkFallbackEnabled, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - }; - } - - private void Apply(TenantRecord target, TenantAggregate source) - { - var replacement = ToRecord(source); - - target.Code = replacement.Code; - target.Name = replacement.Name; - target.OrganizationTypeId = replacement.OrganizationTypeId; - target.IdpStrategyId = replacement.IdpStrategyId; - target.CompanyReference = replacement.CompanyReference; - target.ParentTenantId = replacement.ParentTenantId; - target.IsManagementOwner = replacement.IsManagementOwner; - target.StatusId = replacement.StatusId; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - - UpsertBranches(target.Branches, replacement.Branches); - UpsertIdentityProviders(target.IdentityProviders, replacement.IdentityProviders); - - target.Branding = replacement.Branding; - } - - private void UpsertBranches(ICollection target, IEnumerable source) - { - EfChildCollectionReconciler.ReconcileById( - dbContext, - target, - source, - branch => branch.Id, - UpdateBranch); - } - - private void UpsertIdentityProviders(ICollection target, IEnumerable source) - { - EfChildCollectionReconciler.ReconcileById( - dbContext, - target, - source, - provider => provider.Id, - UpdateIdentityProvider); - } - - private static void UpdateBranch(TenantBranchRecord target, TenantBranchRecord source) - { - target.TenantId = source.TenantId; - target.Code = source.Code; - target.Name = source.Name; - target.GeofencingMetadata = source.GeofencingMetadata; - target.IsActive = source.IsActive; - target.CreatedBy = source.CreatedBy; - target.CreatedAtUtc = source.CreatedAtUtc; - target.UpdatedBy = source.UpdatedBy; - target.UpdatedAtUtc = source.UpdatedAtUtc; - target.AuditTimeSpan = source.AuditTimeSpan; - } - - private static void UpdateIdentityProvider(TenantIdentityProviderRecord target, TenantIdentityProviderRecord source) - { - target.TenantId = source.TenantId; - target.Code = source.Code; - target.Name = source.Name; - target.Description = source.Description; - target.StrategyId = source.StrategyId; - target.IsActive = source.IsActive; - target.CreatedBy = source.CreatedBy; - target.CreatedAtUtc = source.CreatedAtUtc; - target.UpdatedBy = source.UpdatedBy; - target.UpdatedAtUtc = source.UpdatedAtUtc; - target.AuditTimeSpan = source.AuditTimeSpan; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerTenantSignupRequestRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerTenantSignupRequestRepository.cs deleted file mode 100644 index 9e1d552e..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerTenantSignupRequestRepository.cs +++ /dev/null @@ -1,173 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System.Reflection; -using Ums.Domain.Identity; -using Ums.Domain.Identity.TenantSignupRequest; -using Ums.Domain.Kernel; -using Ums.Domain.Kernel.ValueObjects; -using Ums.Infrastructure.Persistence; -using Ums.Infrastructure.Persistence.Identity.Entities; -using Ums.Infrastructure.Persistence.Reflection; -using BeyondNetCode.Shell.Ddd.ValueObjects.Audit; -using TenantSignupRequestAggregate = Ums.Domain.Identity.TenantSignupRequest.TenantSignupRequest; - -namespace Ums.Infrastructure.Persistence.Identity; - -public sealed class SqlServerTenantSignupRequestRepository(UmsPlatformDbContext dbContext) : ITenantSignupRequestRepository, IUnitOfWork -{ - private readonly HashSet _trackedAggregates = []; - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.Set() - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public async Task> GetAllAsync(CancellationToken cancellationToken = default) - { - var records = await dbContext.Set() - .OrderByDescending(x => x.CreatedAtUtc) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetPendingAsync(CancellationToken cancellationToken = default) - { - var records = await dbContext.Set() - .Where(x => x.StatusId == TenantSignupRequestStatus.Pending.Id) - .OrderByDescending(x => x.CreatedAtUtc) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public Task AddAsync(TenantSignupRequestAggregate aggregate, CancellationToken cancellationToken = default) - { - dbContext.Set().Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(TenantSignupRequestAggregate aggregate, CancellationToken cancellationToken = default) - { - var existing = await dbContext.Set() - .FirstOrDefaultAsync(x => x.Id == aggregate.GetId().GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"Tenant signup request {aggregate.GetId().GetValue()} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await dbContext.SaveChangesAsync(cancellationToken); - } - catch (DbUpdateConcurrencyException ex) - { - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() => dbContext.Dispose(); - - private static TenantSignupRequestAggregate Rehydrate(TenantSignupRequestRecord record) - { - var audit = AuditValueObject.Load(new AuditProps - { - CreatedBy = record.CreatedBy, - CreatedAt = record.CreatedAtUtc, - UpdatedBy = record.UpdatedBy, - UpdatedAt = record.UpdatedAtUtc, - TimeSpan = record.AuditTimeSpan - }); - - var idValueObject = CreateIdValueObject(record.Id); - var props = (TenantSignupRequestProps)Activator.CreateInstance( - typeof(TenantSignupRequestProps), - idValueObject, - Name.Create(record.CompanyName), - CompanyReference.Create(record.CompanyReference), - Name.Create(record.ContactName), - Email.Create(record.ContactEmail), - DomainEnumerationMapper.FromValue(record.StatusId), - record.ApprovedTenantId.HasValue ? TenantId.Load(record.ApprovedTenantId.Value) : null, - audit)!; - - var aggregate = new TenantSignupRequestAggregate(props); - aggregate.BrokenRules.Clear(); - return aggregate; - } - - private static TenantSignupRequestRecord ToRecord(TenantSignupRequestAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new TenantSignupRequestRecord - { - Id = aggregate.Props.Id.GetValue(), - CompanyName = aggregate.CompanyName.GetValue(), - CompanyReference = aggregate.CompanyReference.GetValue(), - ContactName = aggregate.ContactName.GetValue(), - ContactEmail = aggregate.ContactEmail.GetValue(), - StatusId = aggregate.Status.Id, - ApprovedTenantId = aggregate.ApprovedTenantId?.GetValue(), - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - }; - } - - private void Apply(TenantSignupRequestRecord target, TenantSignupRequestAggregate source) - { - var replacement = ToRecord(source); - target.CompanyName = replacement.CompanyName; - target.CompanyReference = replacement.CompanyReference; - target.ContactName = replacement.ContactName; - target.ContactEmail = replacement.ContactEmail; - target.StatusId = replacement.StatusId; - target.ApprovedTenantId = replacement.ApprovedTenantId; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - } - - private static object CreateIdValueObject(Guid id) - { - var idType = typeof(TenantSignupRequestProps).GetProperty(nameof(TenantSignupRequestProps.Id))!.PropertyType; - var loadMethod = idType.GetMethod( - "Load", - BindingFlags.Public | BindingFlags.Static, - binder: null, - types: [typeof(Guid)], - modifiers: null); - - if (loadMethod is null) - { - throw new InvalidOperationException($"Could not resolve IdValueObject.Load(Guid) for {idType.FullName}."); - } - - return loadMethod.Invoke(null, new object[] { id })!; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerUserAccountRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerUserAccountRepository.cs deleted file mode 100644 index 7de44121..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerUserAccountRepository.cs +++ /dev/null @@ -1,356 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Identity; -using Ums.Domain.Kernel; -using Ums.Domain.Kernel.ValueObjects; -using Ums.Infrastructure.Persistence; -using Ums.Infrastructure.Persistence.Identity.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Identity; - -using UserAccountAggregate = Ums.Domain.Identity.UserAccount.UserAccount; - -public sealed class SqlServerUserAccountRepository(UmsPlatformDbContext dbContext) : IUserAccountRepository, IUnitOfWork -{ - private readonly HashSet _trackedAggregates = []; - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.UserAccounts - .AsSplitQuery() - .Include(x => x.MfaEnrollments) - .Include(x => x.PasswordCredentials) - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - => GetByIdAsync(id, cancellationToken); - - public async Task GetByEmailAsync(Email email, CancellationToken cancellationToken = default) - { - var record = await dbContext.UserAccounts - .AsSplitQuery() - .Include(x => x.MfaEnrollments) - .Include(x => x.PasswordCredentials) - .FirstOrDefaultAsync(x => x.Email == email.GetValue(), cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public async Task GetByTenantAndEmailAsync( - Guid tenantId, - Email email, - bool includeDeleted = false, - CancellationToken cancellationToken = default) - { - IQueryable query = dbContext.UserAccounts - .AsSplitQuery() - .Include(x => x.MfaEnrollments) - .Include(x => x.PasswordCredentials); - - if (includeDeleted) - { - query = query.IgnoreQueryFilters(); - } - - var record = await query.FirstOrDefaultAsync( - x => x.TenantId == tenantId && x.Email == email.GetValue(), - cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) - { - IQueryable query = dbContext.UserAccounts.AsSplitQuery() - .Include(x => x.MfaEnrollments) - .Include(x => x.PasswordCredentials); - - if (tenantId.HasValue) - { - query = query.Where(x => x.TenantId == tenantId.Value); - } - - var records = await query.OrderBy(x => x.Email).ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - /// - public async Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( - int page, int pageSize, string? search, string? status, string sortBy, string sortOrder, - Guid? tenantId = null, CancellationToken cancellationToken = default) - { - // REC-12: DB-level pagination — only retrieve the requested page from SQL Server. - var query = dbContext.UserAccounts.AsQueryable(); - - if (tenantId.HasValue) - query = query.Where(u => u.TenantId == tenantId.Value); - - if (!string.IsNullOrWhiteSpace(search)) - { - var lower = search.ToLower(); - query = query.Where(u => u.Email.ToLower().Contains(lower)); - } - - if (!string.IsNullOrWhiteSpace(status) && - !string.Equals(status, "all", StringComparison.OrdinalIgnoreCase)) - { - // StatusId filtering would require the int value; application filters after fetch - // for status since it maps int → enum name at the domain layer. - } - - var totalCount = await query.CountAsync(cancellationToken); - - query = (sortBy?.ToLower(), sortOrder?.ToLower()) switch - { - ("email", "desc") => query.OrderByDescending(u => u.Email), - _ => query.OrderBy(u => u.Email), - }; - - var records = await query - .AsSplitQuery() - .Include(x => x.MfaEnrollments) - .Include(x => x.PasswordCredentials) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToListAsync(cancellationToken); - - return (records.Select(Rehydrate).ToList(), totalCount); - } - - public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) - { - var records = await dbContext.UserAccounts - .AsSplitQuery() - .Include(x => x.MfaEnrollments) - .Include(x => x.PasswordCredentials) - .Where(x => x.TenantId == tenantId) - .OrderBy(x => x.Email) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - /// - /// - /// Design note: Uses EF change tracking (not ExecuteUpdateAsync) so the soft-delete and - /// GDPR anonymization changes are batched with the outbox message in a single - /// SaveChangesAsync call when the caller later invokes SaveEntitiesAsync. - /// - /// The caller MUST first call UpdateAsync (to register the aggregate in _trackedAggregates - /// for outbox message creation) and then call SoftDeleteAsync (which overwrites the email - /// with the anonymized token). Both modifications target the same EF identity-map instance. - /// - public async Task SoftDeleteAsync(Guid id, string deletedBy, CancellationToken cancellationToken = default) - { - // IgnoreQueryFilters so we can detect an already-deleted row (return false = idempotent). - // EF's identity map returns the same tracked instance that UpdateAsync already loaded, - // so modifying `record` here is additive to the changes Apply() already staged. - var record = await dbContext.UserAccounts - .IgnoreQueryFilters() - .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted, cancellationToken); - - if (record is null) return false; - - var now = DateTime.UtcNow; - record.IsDeleted = true; - record.DeletedAtUtc = now; - record.DeletedBy = deletedBy; - // GDPR: replace PII with a deterministic, irreversible token (SHA-256 of the GUID). - record.Email = BuildAnonymizedEmail(id); - record.IdentityReference = null; - record.AnonymizedAtUtc = now; - // EF change tracker now has IsDeleted=true + anonymized email pending; SaveChangesAsync - // (called from SaveEntitiesAsync by the handler) will commit everything atomically. - return true; - } - - private static string BuildAnonymizedEmail(Guid id) - { - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(id.ToString())); - var prefix = Convert.ToHexString(hash)[..16].ToLower(); - return $"gdpr_del_{prefix}@anonymized.invalid"; - } - - public Task AddAsync(UserAccountAggregate aggregate, CancellationToken cancellationToken = default) - { - dbContext.UserAccounts.Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(UserAccountAggregate aggregate, CancellationToken cancellationToken = default) - { - var existing = await dbContext.UserAccounts - .Include(x => x.MfaEnrollments) - .Include(x => x.PasswordCredentials) - .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"User account {aggregate.Props.Id.GetValue()} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await dbContext.SaveChangesAsync(cancellationToken); - } - catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException ex) - { - // FIX-03: surface optimistic concurrency failures as 409 Conflict - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() => dbContext.Dispose(); - - // ── Dependency guard queries ──────────────────────────────────────────── - - public Task CountActiveByTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) - => dbContext.UserAccounts.CountAsync( - u => u.TenantId == tenantId && u.StatusId == 2 /* Active */, - cancellationToken); - - private static UserAccountAggregate Rehydrate(UserAccountRecord record) - => IdentityAggregateFactory.RehydrateUserAccount(record, record.MfaEnrollments, record.PasswordCredentials); - - private static UserAccountRecord ToRecord(UserAccountAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new UserAccountRecord - { - Id = aggregate.Props.Id.GetValue(), - TenantId = aggregate.Props.TenantId.GetValue(), - BranchId = aggregate.Props.BranchId?.GetValue(), - Email = aggregate.Email.GetValue(), - DisplayName = aggregate.DisplayName?.GetValue(), - CategoryId = aggregate.Category.Id, - StatusId = aggregate.Status.Id, - IdentityReference = aggregate.IdentityReference?.GetValue(), - IdentityReferenceTypeId = aggregate.IdentityReferenceType?.Id, - ExpiresAtUtc = aggregate.ExpiresAt.HasValue ? aggregate.ExpiresAt.Value.UtcDateTime : null, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - MfaEnrollments = aggregate.MfaEnrollments.Select(x => - { - var a = x.Props.Audit.GetValue(); - return new UserAccountMfaEnrollmentRecord - { - Id = x.Props.Id.GetValue(), - UserAccountId = x.Props.UserAccountId.GetValue(), - MethodId = x.Method.Id, - StatusId = x.Status.Id, - CreatedBy = a.CreatedBy, - CreatedAtUtc = a.CreatedAt, - UpdatedBy = a.UpdatedBy, - UpdatedAtUtc = a.UpdatedAt, - AuditTimeSpan = a.TimeSpan, - }; - }).ToList(), - PasswordCredentials = aggregate.PasswordCredentials.Select(x => - { - var a = x.Props.Audit.GetValue(); - return new UserAccountPasswordCredentialRecord - { - Id = x.Props.Id.GetValue(), - UserAccountId = x.Props.UserAccountId.GetValue(), - PasswordHash = x.PasswordHash.GetValue(), - IsActive = x.IsActive, - CreatedBy = a.CreatedBy, - CreatedAtUtc = a.CreatedAt, - UpdatedBy = a.UpdatedBy, - UpdatedAtUtc = a.UpdatedAt, - AuditTimeSpan = a.TimeSpan, - }; - }).ToList(), - }; - } - - private void Apply(UserAccountRecord target, UserAccountAggregate source) - { - var replacement = ToRecord(source); - - target.TenantId = replacement.TenantId; - target.BranchId = replacement.BranchId; - target.Email = replacement.Email; - target.DisplayName = replacement.DisplayName; - target.CategoryId = replacement.CategoryId; - target.StatusId = replacement.StatusId; - target.IdentityReference = replacement.IdentityReference; - target.IdentityReferenceTypeId = replacement.IdentityReferenceTypeId; - target.ExpiresAtUtc = replacement.ExpiresAtUtc; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - - EfChildCollectionReconciler.ReconcileById( - dbContext, - target.MfaEnrollments, - replacement.MfaEnrollments, - enrollment => enrollment.Id, - UpdateMfaEnrollment); - - EfChildCollectionReconciler.ReconcileById( - dbContext, - target.PasswordCredentials, - replacement.PasswordCredentials, - credential => credential.Id, - UpdatePasswordCredential); - } - - private static void UpdateMfaEnrollment(UserAccountMfaEnrollmentRecord target, UserAccountMfaEnrollmentRecord source) - { - target.UserAccountId = source.UserAccountId; - target.MethodId = source.MethodId; - target.StatusId = source.StatusId; - target.CreatedBy = source.CreatedBy; - target.CreatedAtUtc = source.CreatedAtUtc; - target.UpdatedBy = source.UpdatedBy; - target.UpdatedAtUtc = source.UpdatedAtUtc; - target.AuditTimeSpan = source.AuditTimeSpan; - } - - private static void UpdatePasswordCredential(UserAccountPasswordCredentialRecord target, UserAccountPasswordCredentialRecord source) - { - target.UserAccountId = source.UserAccountId; - target.PasswordHash = source.PasswordHash; - target.IsActive = source.IsActive; - target.CreatedBy = source.CreatedBy; - target.CreatedAtUtc = source.CreatedAtUtc; - target.UpdatedBy = source.UpdatedBy; - target.UpdatedAtUtc = source.UpdatedAtUtc; - target.AuditTimeSpan = source.AuditTimeSpan; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerUserManagementDelegationRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerUserManagementDelegationRepository.cs deleted file mode 100644 index 655a042e..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/SqlServerUserManagementDelegationRepository.cs +++ /dev/null @@ -1,193 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System.Text.Json; -using Ums.Domain.Identity; -using Ums.Domain.Identity.UserManagementDelegation; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence.Identity.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Identity; - -using UserManagementDelegationAggregate = Ums.Domain.Identity.UserManagementDelegation.UserManagementDelegation; - -public sealed class SqlServerUserManagementDelegationRepository(UmsPlatformDbContext dbContext) - : IUserManagementDelegationRepository, IUnitOfWork -{ - private readonly HashSet _trackedAggregates = []; - - public IUnitOfWork UnitOfWork => this; - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.UserManagementDelegations - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - => GetByIdAsync(id, cancellationToken); - - public async Task> GetByDelegatedAdminAsync(Guid delegatedAdminId, Guid tenantId, CancellationToken cancellationToken = default) - { - var records = await dbContext.UserManagementDelegations - .Where(x => x.DelegatedAdminId == delegatedAdminId && x.TenantId == tenantId) - .OrderByDescending(x => x.CreatedAtUtc) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetByDelegatingAdminAsync(Guid delegatingAdminId, Guid tenantId, CancellationToken cancellationToken = default) - { - var records = await dbContext.UserManagementDelegations - .Where(x => x.DelegatingAdminId == delegatingAdminId && x.TenantId == tenantId) - .OrderByDescending(x => x.CreatedAtUtc) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetActiveAsync(Guid tenantId, CancellationToken cancellationToken = default) - { - var activeStatusId = DelegationStatus.Active.Id; - var records = await dbContext.UserManagementDelegations - .Where(x => x.TenantId == tenantId && x.StatusId == activeStatusId) - .OrderByDescending(x => x.ValidUntil) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetExpiredActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default) - { - var activeStatusId = DelegationStatus.Active.Id; - var records = await dbContext.UserManagementDelegations - .Where(x => x.StatusId == activeStatusId && x.ValidUntil <= asOf) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) - { - IQueryable query = dbContext.UserManagementDelegations; - - if (tenantId.HasValue) - { - query = query.Where(x => x.TenantId == tenantId.Value); - } - - var records = await query.OrderByDescending(x => x.CreatedAtUtc).ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public Task AddAsync(UserManagementDelegationAggregate aggregate, CancellationToken cancellationToken = default) - { - dbContext.UserManagementDelegations.Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public async Task UpdateAsync(UserManagementDelegationAggregate aggregate, CancellationToken cancellationToken = default) - { - var existing = await dbContext.UserManagementDelegations - .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) - ?? throw new InvalidOperationException($"Delegation {aggregate.Props.Id.GetValue()} does not exist."); - - Apply(existing, aggregate); - _trackedAggregates.Add(aggregate); - } - - public Task SaveChangesAsync(CancellationToken cancellationToken = default) - => dbContext.SaveChangesAsync(cancellationToken); - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await dbContext.SaveChangesAsync(cancellationToken); - } - catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException ex) - { - // FIX-03: surface optimistic concurrency failures as 409 Conflict - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() => dbContext.Dispose(); - - private static UserManagementDelegationAggregate Rehydrate(UserManagementDelegationRecord record) - => IdentityAggregateFactory.RehydrateUserManagementDelegation(record); - - private static UserManagementDelegationRecord ToRecord(UserManagementDelegationAggregate aggregate) - { - var audit = aggregate.Props.Audit.GetValue(); - return new UserManagementDelegationRecord - { - Id = aggregate.Props.Id.GetValue(), - TenantId = aggregate.Props.TenantId.GetValue(), - DelegatingAdminId = aggregate.Props.DelegatingAdminId.GetValue(), - DelegatedAdminId = aggregate.Props.DelegatedAdminId.GetValue(), - ScopeTypeId = aggregate.Props.ScopeType.Id, - ScopeId = aggregate.Props.ScopeId, - AllowedActionsJson = JsonSerializer.Serialize(aggregate.Props.AllowedActions.Select(a => a.Id).ToList()), - ValidFrom = aggregate.Props.ValidFrom, - ValidUntil = aggregate.Props.ValidUntil, - MaxDurationDays = aggregate.Props.MaxDurationDays, - RequiresApproval = aggregate.Props.RequiresApproval, - ApprovalRequestId = aggregate.Props.ApprovalRequestId, - StatusId = aggregate.Props.Status.Id, - RevokedAt = aggregate.Props.RevokedAt, - RevokedBy = aggregate.Props.RevokedBy, - RevocationReason = aggregate.Props.RevocationReason, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - }; - } - - private static void Apply(UserManagementDelegationRecord target, UserManagementDelegationAggregate source) - { - var replacement = ToRecord(source); - - target.TenantId = replacement.TenantId; - target.DelegatingAdminId = replacement.DelegatingAdminId; - target.DelegatedAdminId = replacement.DelegatedAdminId; - target.ScopeTypeId = replacement.ScopeTypeId; - target.ScopeId = replacement.ScopeId; - target.AllowedActionsJson = replacement.AllowedActionsJson; - target.ValidFrom = replacement.ValidFrom; - target.ValidUntil = replacement.ValidUntil; - target.MaxDurationDays = replacement.MaxDurationDays; - target.RequiresApproval = replacement.RequiresApproval; - target.ApprovalRequestId = replacement.ApprovalRequestId; - target.StatusId = replacement.StatusId; - target.RevokedAt = replacement.RevokedAt; - target.RevokedBy = replacement.RevokedBy; - target.RevocationReason = replacement.RevocationReason; - target.CreatedBy = replacement.CreatedBy; - target.CreatedAtUtc = replacement.CreatedAtUtc; - target.UpdatedBy = replacement.UpdatedBy; - target.UpdatedAtUtc = replacement.UpdatedAtUtc; - target.AuditTimeSpan = replacement.AuditTimeSpan; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/SqlServerTenantParameterRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/SqlServerTenantParameterRepository.cs deleted file mode 100644 index acd2639b..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/SqlServerTenantParameterRepository.cs +++ /dev/null @@ -1,180 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Ums.Domain.Identity.Repositories.TenantParameter; -using Ums.Domain.Identity.Tenant.TenantParameter; -using Ums.Domain.Kernel; -using Ums.Infrastructure.Persistence; -using Ums.Infrastructure.Persistence.Identity.Entities; -using Ums.Infrastructure.Persistence.Reflection; - -namespace Ums.Infrastructure.Persistence.Identity.TenantParameter; - -using TenantParameterAggregate = Ums.Domain.Identity.Tenant.TenantParameter.TenantParameter; - -public sealed class SqlServerTenantParameterRepository : ITenantParameterRepository, IUnitOfWork -{ - private readonly UmsPlatformDbContext dbContext; - private readonly HashSet _trackedAggregates = []; - - public SqlServerTenantParameterRepository(UmsPlatformDbContext dbContext) - { - this.dbContext = dbContext; - } - - public IUnitOfWork UnitOfWork => this; - - public async Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - return await dbContext.SaveChangesAsync(cancellationToken); - } - - public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) - { - foreach (var aggregate in _trackedAggregates) - { - await dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); - } - - try - { - await dbContext.SaveChangesAsync(cancellationToken); - } - catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException ex) - { - var entry = ex.Entries.FirstOrDefault(); - var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); - throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); - } - - foreach (var aggregate in _trackedAggregates) - { - aggregate.DomainEvents.MarkChangesAsCommitted(); - } - - _trackedAggregates.Clear(); - return true; - } - - public void Dispose() - { - } - - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.TenantParameters - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - Task IAggregateRepository.GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken) - { - return GetByIdAsync(tenantId, id, cancellationToken); - } - - public async Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) - { - var record = await dbContext.TenantParameters - .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == id, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public async Task GetByCodeAsync(Guid tenantId, string code, CancellationToken cancellationToken = default) - { - var record = await dbContext.TenantParameters - .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Code == code, cancellationToken); - - return record is null ? null : Rehydrate(record); - } - - public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) - { - var records = await dbContext.TenantParameters - .Where(x => x.TenantId == tenantId) - .OrderBy(x => x.CategoryId) - .ThenBy(x => x.Code) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetActiveByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) - { - var records = await dbContext.TenantParameters - .Where(x => x.TenantId == tenantId && x.IsActive) - .OrderBy(x => x.CategoryId) - .ThenBy(x => x.Code) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task> GetByCategoryAsync(Guid tenantId, string category, CancellationToken cancellationToken = default) - { - var categoryEnum = TenantParameterCategory.TryFromString(category); - if (categoryEnum is null) - { - return []; - } - - var records = await dbContext.TenantParameters - .Where(x => x.TenantId == tenantId && x.IsActive && x.CategoryId == categoryEnum.Id) - .OrderBy(x => x.Code) - .ToListAsync(cancellationToken); - - return records.Select(Rehydrate).ToList(); - } - - public async Task ExistsActiveCodeAsync(Guid tenantId, string code, CancellationToken cancellationToken = default) - { - return await dbContext.TenantParameters - .AnyAsync(x => x.TenantId == tenantId && x.Code == code && x.IsActive, cancellationToken); - } - - public Task AddAsync(TenantParameterAggregate aggregate, CancellationToken cancellationToken = default) - { - dbContext.TenantParameters.Add(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public Task UpdateAsync(TenantParameterAggregate aggregate, CancellationToken cancellationToken = default) - { - dbContext.TenantParameters.Update(ToRecord(aggregate)); - _trackedAggregates.Add(aggregate); - return Task.CompletedTask; - } - - public Task DeleteAsync(TenantParameterAggregate aggregate, CancellationToken cancellationToken = default) - { - dbContext.TenantParameters.Remove(ToRecord(aggregate)); - _trackedAggregates.Remove(aggregate); - return Task.CompletedTask; - } - - private static TenantParameterAggregate Rehydrate(TenantParameterRecord record) - => IdentityAggregateFactory.RehydrateTenantParameter(record); - - private static TenantParameterRecord ToRecord(TenantParameterAggregate aggregate) - { - return new TenantParameterRecord - { - Id = aggregate.GetId().GetValue(), - TenantId = aggregate.TenantId.GetValue(), - Code = aggregate.Code.GetValue(), - Description = aggregate.Description.GetValue(), - Value = aggregate.Value, - ValueTypeId = aggregate.ValueType.Id, - CategoryId = aggregate.Category.Id, - IsActive = aggregate.IsActive, - IsSensitive = aggregate.IsSensitive, - DefaultValue = aggregate.DefaultValue, - AllowedValues = aggregate.AllowedValues, - CreatedBy = aggregate.Props.Audit.GetValue().CreatedBy, - CreatedAtUtc = aggregate.Props.Audit.GetValue().CreatedAt, - UpdatedBy = aggregate.Props.Audit.GetValue().UpdatedBy, - UpdatedAtUtc = aggregate.Props.Audit.GetValue().UpdatedAt, - AuditTimeSpan = aggregate.Props.Audit.GetValue().TimeSpan - }; - } -} \ No newline at end of file diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260521_sqlserver_authorization_profiles.sql b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260521_sqlserver_authorization_profiles.sql deleted file mode 100644 index a13741fe..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260521_sqlserver_authorization_profiles.sql +++ /dev/null @@ -1,56 +0,0 @@ -IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'ums_authorization') -BEGIN - EXEC('CREATE SCHEMA [ums_authorization]'); -END -GO - -IF OBJECT_ID('[ums_authorization].[Profiles]', 'U') IS NULL -BEGIN - CREATE TABLE [ums_authorization].[Profiles] - ( - [Id] uniqueidentifier NOT NULL, - [TenantId] uniqueidentifier NOT NULL, - [UserId] uniqueidentifier NOT NULL, - [RoleId] uniqueidentifier NOT NULL, - [BranchId] uniqueidentifier NULL, - [ScopeId] int NOT NULL, - [IsActive] bit NOT NULL, - [CreatedBy] nvarchar(100) NOT NULL, - [CreatedAtUtc] datetime2 NOT NULL, - [UpdatedBy] nvarchar(100) NULL, - [UpdatedAtUtc] datetime2 NULL, - [AuditTimeSpan] nvarchar(100) NOT NULL, - CONSTRAINT [PK_Profiles] PRIMARY KEY ([Id]) - ); - CREATE INDEX [IX_Profiles_TenantId] ON [ums_authorization].[Profiles]([TenantId]); - CREATE INDEX [IX_Profiles_UserId] ON [ums_authorization].[Profiles]([UserId]); - CREATE INDEX [IX_Profiles_TenantId_UserId_RoleId_BranchId] ON [ums_authorization].[Profiles]([TenantId], [UserId], [RoleId], [BranchId]); -END -GO - -IF OBJECT_ID('[ums_authorization].[ProfilePermissions]', 'U') IS NULL -BEGIN - CREATE TABLE [ums_authorization].[ProfilePermissions] - ( - [Id] uniqueidentifier NOT NULL, - [ProfileId] uniqueidentifier NOT NULL, - [TemplateId] uniqueidentifier NOT NULL, - [TargetTypeId] int NOT NULL, - [TargetId] uniqueidentifier NOT NULL, - [ActionId] uniqueidentifier NOT NULL, - [IsAllowed] bit NOT NULL, - [IsDenied] bit NOT NULL, - [IsActive] bit NOT NULL, - [IsOverride] bit NOT NULL, - [CreatedBy] nvarchar(100) NOT NULL, - [CreatedAtUtc] datetime2 NOT NULL, - [UpdatedBy] nvarchar(100) NULL, - [UpdatedAtUtc] datetime2 NULL, - [AuditTimeSpan] nvarchar(100) NOT NULL, - CONSTRAINT [PK_ProfilePermissions] PRIMARY KEY ([Id]), - CONSTRAINT [FK_ProfilePermissions_Profiles] FOREIGN KEY ([ProfileId]) REFERENCES [ums_authorization].[Profiles]([Id]) ON DELETE CASCADE - ); - CREATE INDEX [IX_ProfilePermissions_ProfileId] ON [ums_authorization].[ProfilePermissions]([ProfileId]); - CREATE INDEX [IX_ProfilePermissions_ProfileId_TemplateId_ActionId_TargetId] ON [ums_authorization].[ProfilePermissions]([ProfileId], [TemplateId], [ActionId], [TargetId]); -END -GO diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260521_sqlserver_identity_aggregates.sql b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260521_sqlserver_identity_aggregates.sql deleted file mode 100644 index 7702423c..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260521_sqlserver_identity_aggregates.sql +++ /dev/null @@ -1,170 +0,0 @@ -IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'ums_identity') -BEGIN - EXEC('CREATE SCHEMA [ums_identity]'); -END -GO - -IF OBJECT_ID('[ums_identity].[Tenants]', 'U') IS NULL -BEGIN - CREATE TABLE [ums_identity].[Tenants] - ( - [Id] uniqueidentifier NOT NULL, - [Code] nvarchar(100) NOT NULL, - [Name] nvarchar(200) NOT NULL, - [OrganizationTypeId] int NOT NULL, - [IdpStrategyId] int NOT NULL, - [CompanyReference] nvarchar(100) NULL, - [ParentTenantId] uniqueidentifier NULL, - [StatusId] int NOT NULL, - [CreatedBy] nvarchar(100) NOT NULL, - [CreatedAtUtc] datetime2 NOT NULL, - [UpdatedBy] nvarchar(100) NULL, - [UpdatedAtUtc] datetime2 NULL, - [AuditTimeSpan] nvarchar(100) NOT NULL, - CONSTRAINT [PK_Tenants] PRIMARY KEY ([Id]) - ); - CREATE UNIQUE INDEX [IX_Tenants_Code] ON [ums_identity].[Tenants]([Code]); - CREATE INDEX [IX_Tenants_ParentTenantId] ON [ums_identity].[Tenants]([ParentTenantId]); -END -GO - -IF OBJECT_ID('[ums_identity].[TenantBranches]', 'U') IS NULL -BEGIN - CREATE TABLE [ums_identity].[TenantBranches] - ( - [Id] uniqueidentifier NOT NULL, - [TenantId] uniqueidentifier NOT NULL, - [Code] nvarchar(100) NOT NULL, - [Name] nvarchar(200) NOT NULL, - [GeofencingMetadata] nvarchar(4000) NULL, - [IsActive] bit NOT NULL, - [CreatedBy] nvarchar(100) NOT NULL, - [CreatedAtUtc] datetime2 NOT NULL, - [UpdatedBy] nvarchar(100) NULL, - [UpdatedAtUtc] datetime2 NULL, - [AuditTimeSpan] nvarchar(100) NOT NULL, - CONSTRAINT [PK_TenantBranches] PRIMARY KEY ([Id]), - CONSTRAINT [FK_TenantBranches_Tenants] FOREIGN KEY ([TenantId]) REFERENCES [ums_identity].[Tenants]([Id]) ON DELETE CASCADE - ); - CREATE UNIQUE INDEX [IX_TenantBranches_TenantId_Code] ON [ums_identity].[TenantBranches]([TenantId], [Code]); -END -GO - -IF OBJECT_ID('[ums_identity].[TenantIdentityProviders]', 'U') IS NULL -BEGIN - CREATE TABLE [ums_identity].[TenantIdentityProviders] - ( - [Id] uniqueidentifier NOT NULL, - [TenantId] uniqueidentifier NOT NULL, - [Code] nvarchar(100) NOT NULL, - [Name] nvarchar(200) NOT NULL, - [Description] nvarchar(1000) NOT NULL, - [StrategyId] int NOT NULL, - [IsActive] bit NOT NULL, - [CreatedBy] nvarchar(100) NOT NULL, - [CreatedAtUtc] datetime2 NOT NULL, - [UpdatedBy] nvarchar(100) NULL, - [UpdatedAtUtc] datetime2 NULL, - [AuditTimeSpan] nvarchar(100) NOT NULL, - CONSTRAINT [PK_TenantIdentityProviders] PRIMARY KEY ([Id]), - CONSTRAINT [FK_TenantIdentityProviders_Tenants] FOREIGN KEY ([TenantId]) REFERENCES [ums_identity].[Tenants]([Id]) ON DELETE CASCADE - ); - CREATE UNIQUE INDEX [IX_TenantIdentityProviders_TenantId_Code] ON [ums_identity].[TenantIdentityProviders]([TenantId], [Code]); -END -GO - -IF OBJECT_ID('[ums_identity].[TenantBrandings]', 'U') IS NULL -BEGIN - CREATE TABLE [ums_identity].[TenantBrandings] - ( - [Id] uniqueidentifier NOT NULL, - [TenantId] uniqueidentifier NOT NULL, - [Logo] nvarchar(4000) NOT NULL, - [LogoFormatId] int NOT NULL, - [PrimaryColor] nvarchar(20) NOT NULL, - [BackgroundStyleId] int NOT NULL, - [HeadlineText] nvarchar(200) NOT NULL, - [SecondaryText] nvarchar(500) NOT NULL, - [PrimaryButtonLabel] nvarchar(100) NOT NULL, - [FooterText] nvarchar(200) NOT NULL, - [CustomDomain] nvarchar(255) NULL, - [DnsVerificationStatusId] int NOT NULL, - [DnsCnameTarget] nvarchar(255) NOT NULL, - [MagicLinkFallbackEnabled] bit NOT NULL, - [CreatedBy] nvarchar(100) NOT NULL, - [CreatedAtUtc] datetime2 NOT NULL, - [UpdatedBy] nvarchar(100) NULL, - [UpdatedAtUtc] datetime2 NULL, - [AuditTimeSpan] nvarchar(100) NOT NULL, - CONSTRAINT [PK_TenantBrandings] PRIMARY KEY ([Id]), - CONSTRAINT [FK_TenantBrandings_Tenants] FOREIGN KEY ([TenantId]) REFERENCES [ums_identity].[Tenants]([Id]) ON DELETE CASCADE - ); - CREATE UNIQUE INDEX [IX_TenantBrandings_TenantId] ON [ums_identity].[TenantBrandings]([TenantId]); - CREATE UNIQUE INDEX [IX_TenantBrandings_CustomDomain] ON [ums_identity].[TenantBrandings]([CustomDomain]) WHERE [CustomDomain] IS NOT NULL; -END -GO - -IF OBJECT_ID('[ums_identity].[UserAccounts]', 'U') IS NULL -BEGIN - CREATE TABLE [ums_identity].[UserAccounts] - ( - [Id] uniqueidentifier NOT NULL, - [TenantId] uniqueidentifier NOT NULL, - [BranchId] uniqueidentifier NULL, - [Email] nvarchar(255) NOT NULL, - [CategoryId] int NOT NULL, - [StatusId] int NOT NULL, - [IdentityReference] nvarchar(255) NULL, - [IdentityReferenceTypeId] int NULL, - [CreatedBy] nvarchar(100) NOT NULL, - [CreatedAtUtc] datetime2 NOT NULL, - [UpdatedBy] nvarchar(100) NULL, - [UpdatedAtUtc] datetime2 NULL, - [AuditTimeSpan] nvarchar(100) NOT NULL, - CONSTRAINT [PK_UserAccounts] PRIMARY KEY ([Id]) - ); - CREATE UNIQUE INDEX [IX_UserAccounts_TenantId_Email] ON [ums_identity].[UserAccounts]([TenantId], [Email]); - CREATE INDEX [IX_UserAccounts_Email] ON [ums_identity].[UserAccounts]([Email]); - CREATE INDEX [IX_UserAccounts_TenantId] ON [ums_identity].[UserAccounts]([TenantId]); -END -GO - -IF OBJECT_ID('[ums_identity].[UserAccountMfaEnrollments]', 'U') IS NULL -BEGIN - CREATE TABLE [ums_identity].[UserAccountMfaEnrollments] - ( - [Id] uniqueidentifier NOT NULL, - [UserAccountId] uniqueidentifier NOT NULL, - [MethodId] int NOT NULL, - [StatusId] int NOT NULL, - [CreatedBy] nvarchar(100) NOT NULL, - [CreatedAtUtc] datetime2 NOT NULL, - [UpdatedBy] nvarchar(100) NULL, - [UpdatedAtUtc] datetime2 NULL, - [AuditTimeSpan] nvarchar(100) NOT NULL, - CONSTRAINT [PK_UserAccountMfaEnrollments] PRIMARY KEY ([Id]), - CONSTRAINT [FK_UserAccountMfaEnrollments_UserAccounts] FOREIGN KEY ([UserAccountId]) REFERENCES [ums_identity].[UserAccounts]([Id]) ON DELETE CASCADE - ); - CREATE INDEX [IX_UserAccountMfaEnrollments_UserAccountId_MethodId] ON [ums_identity].[UserAccountMfaEnrollments]([UserAccountId], [MethodId]); -END -GO - -IF OBJECT_ID('[ums_identity].[UserAccountPasswordCredentials]', 'U') IS NULL -BEGIN - CREATE TABLE [ums_identity].[UserAccountPasswordCredentials] - ( - [Id] uniqueidentifier NOT NULL, - [UserAccountId] uniqueidentifier NOT NULL, - [PasswordHash] nvarchar(500) NOT NULL, - [IsActive] bit NOT NULL, - [CreatedBy] nvarchar(100) NOT NULL, - [CreatedAtUtc] datetime2 NOT NULL, - [UpdatedBy] nvarchar(100) NULL, - [UpdatedAtUtc] datetime2 NULL, - [AuditTimeSpan] nvarchar(100) NOT NULL, - CONSTRAINT [PK_UserAccountPasswordCredentials] PRIMARY KEY ([Id]), - CONSTRAINT [FK_UserAccountPasswordCredentials_UserAccounts] FOREIGN KEY ([UserAccountId]) REFERENCES [ums_identity].[UserAccounts]([Id]) ON DELETE CASCADE - ); - CREATE INDEX [IX_UserAccountPasswordCredentials_UserAccountId] ON [ums_identity].[UserAccountPasswordCredentials]([UserAccountId]); -END -GO diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260521_sqlserver_platform_outbox.sql b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260521_sqlserver_platform_outbox.sql deleted file mode 100644 index 28494f57..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260521_sqlserver_platform_outbox.sql +++ /dev/null @@ -1,46 +0,0 @@ -IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'ums_platform') -BEGIN - EXEC('CREATE SCHEMA [ums_platform]'); -END -GO - -IF OBJECT_ID('[ums_platform].[OutboxMessages]', 'U') IS NULL -BEGIN - CREATE TABLE [ums_platform].[OutboxMessages] - ( - [Id] uniqueidentifier NOT NULL, - [AggregateId] uniqueidentifier NOT NULL, - [AggregateName] nvarchar(200) NOT NULL, - [EventName] nvarchar(200) NOT NULL, - [EventType] nvarchar(500) NOT NULL, - [Payload] nvarchar(max) NOT NULL, - [TenantId] uniqueidentifier NULL, - [OccurredOnUtc] datetime2 NOT NULL, - [ProcessedOnUtc] datetime2 NULL, - [RetryCount] int NOT NULL CONSTRAINT [DF_OutboxMessages_RetryCount] DEFAULT (0), - [LastError] nvarchar(4000) NULL, - CONSTRAINT [PK_OutboxMessages] PRIMARY KEY ([Id]) - ); -END -GO - -IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_OutboxMessages_Dispatch' AND object_id = OBJECT_ID('[ums_platform].[OutboxMessages]')) -BEGIN - CREATE INDEX [IX_OutboxMessages_Dispatch] - ON [ums_platform].[OutboxMessages] ([ProcessedOnUtc], [OccurredOnUtc]); -END -GO - -IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_OutboxMessages_TenantId' AND object_id = OBJECT_ID('[ums_platform].[OutboxMessages]')) -BEGIN - CREATE INDEX [IX_OutboxMessages_TenantId] - ON [ums_platform].[OutboxMessages] ([TenantId]); -END -GO - -IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_OutboxMessages_Aggregate' AND object_id = OBJECT_ID('[ums_platform].[OutboxMessages]')) -BEGIN - CREATE INDEX [IX_OutboxMessages_Aggregate] - ON [ums_platform].[OutboxMessages] ([AggregateName], [AggregateId]); -END -GO diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260522_sqlserver_identity_delegations.sql b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260522_sqlserver_identity_delegations.sql deleted file mode 100644 index 98ab89db..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260522_sqlserver_identity_delegations.sql +++ /dev/null @@ -1,84 +0,0 @@ --- ============================================================ --- Migration: UserManagementDelegations table --- Date: 2026-05-22 --- Context: Identity BC — FS-14 Delegated Administration --- Schema: ums_identity --- ============================================================ - -IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'ums_identity') -BEGIN - EXEC('CREATE SCHEMA [ums_identity]'); -END -GO - -IF OBJECT_ID('[ums_identity].[UserManagementDelegations]', 'U') IS NULL -BEGIN - CREATE TABLE [ums_identity].[UserManagementDelegations] - ( - -- Identity - [Id] uniqueidentifier NOT NULL, - [TenantId] uniqueidentifier NOT NULL, - - -- Participants - [DelegatingAdminId] uniqueidentifier NOT NULL, - [DelegatedAdminId] uniqueidentifier NOT NULL, - - -- Scope - [ScopeTypeId] int NOT NULL, - [ScopeId] uniqueidentifier NULL, - - -- Permissions - [AllowedActionsJson] nvarchar(500) NOT NULL, - - -- Validity window - [ValidFrom] datetimeoffset NOT NULL, - [ValidUntil] datetimeoffset NOT NULL, - [MaxDurationDays] int NULL, - - -- Approval - [RequiresApproval] bit NOT NULL DEFAULT 0, - [ApprovalRequestId] uniqueidentifier NULL, - - -- Lifecycle - [StatusId] int NOT NULL, - [RevokedAt] datetimeoffset NULL, - [RevokedBy] uniqueidentifier NULL, - [RevocationReason] nvarchar(500) NULL, - - -- Audit - [CreatedBy] nvarchar(100) NOT NULL, - [CreatedAtUtc] datetime2 NOT NULL, - [UpdatedBy] nvarchar(100) NULL, - [UpdatedAtUtc] datetime2 NULL, - [AuditTimeSpan] nvarchar(100) NOT NULL, - - CONSTRAINT [PK_UserManagementDelegations] PRIMARY KEY ([Id]), - - -- INV-DEL2: delegating admin cannot delegate to themselves - CONSTRAINT [CK_UserManagementDelegations_NoSelfDelegation] - CHECK ([DelegatingAdminId] <> [DelegatedAdminId]), - - -- INV-DEL3: validity window must be positive - CONSTRAINT [CK_UserManagementDelegations_ValidWindow] - CHECK ([ValidUntil] > [ValidFrom]) - ); - - -- Hot-path: find active delegations for a given delegated admin (scope validation) - CREATE INDEX [IX_UserManagementDelegations_DelegatedAdminId_Status] - ON [ums_identity].[UserManagementDelegations] ([DelegatedAdminId], [StatusId]) - INCLUDE ([TenantId], [AllowedActionsJson], [ScopeTypeId], [ScopeId], [ValidFrom], [ValidUntil]); - - -- Dashboard: load all delegations a given admin has granted - CREATE INDEX [IX_UserManagementDelegations_DelegatingAdminId] - ON [ums_identity].[UserManagementDelegations] ([DelegatingAdminId]); - - -- Tenant-scoped listing (admin portal) - CREATE INDEX [IX_UserManagementDelegations_TenantId_StatusId] - ON [ums_identity].[UserManagementDelegations] ([TenantId], [StatusId]); - - -- Background expiry worker: find active delegations past their ValidUntil - CREATE INDEX [IX_UserManagementDelegations_Active_ValidUntil] - ON [ums_identity].[UserManagementDelegations] ([StatusId], [ValidUntil]) - WHERE [StatusId] = 1; -- 1 = Active -END -GO diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260523_sqlserver_approvals.sql b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260523_sqlserver_approvals.sql deleted file mode 100644 index 11c1bbbf..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260523_sqlserver_approvals.sql +++ /dev/null @@ -1,111 +0,0 @@ --- ========================================================================= --- Migration: UMS Approvals Bounded Context Tables --- Date: 2026-05-23 --- Bounded Context: Approvals Bounded Context --- Schema: approvals --- ========================================================================= - -IF SCHEMA_ID('approvals') IS NULL -BEGIN - EXEC('CREATE SCHEMA approvals;'); -END -GO - --- 1. approvals.ApprovalWorkflows Table -IF OBJECT_ID('approvals.ApprovalWorkflows', 'U') IS NULL -BEGIN - CREATE TABLE approvals.ApprovalWorkflows ( - Id UNIQUEIDENTIFIER NOT NULL, - TenantId UNIQUEIDENTIFIER NOT NULL, - SystemSuiteId UNIQUEIDENTIFIER NULL, - Code NVARCHAR(50) NOT NULL, - Name NVARCHAR(100) NOT NULL, - Description NVARCHAR(255) NOT NULL, - TargetUserCategoryId INT NOT NULL, - RequiresApproval BIT NOT NULL, - CreatedBy NVARCHAR(100) NOT NULL, - CreatedAtUtc DATETIME2 NOT NULL, - UpdatedBy NVARCHAR(100) NULL, - UpdatedAtUtc DATETIME2 NULL, - AuditTimeSpan NVARCHAR(100) NOT NULL, - RowVersion ROWVERSION NOT NULL, - CONSTRAINT PK_ApprovalWorkflows PRIMARY KEY (Id) - ); - - CREATE INDEX IX_ApprovalWorkflows_TenantId ON approvals.ApprovalWorkflows (TenantId); - CREATE INDEX IX_ApprovalWorkflows_Code ON approvals.ApprovalWorkflows (Code); - CREATE UNIQUE INDEX UK_ApprovalWorkflows_TenantId_Code ON approvals.ApprovalWorkflows (TenantId, Code); -END -GO - --- 2. approvals.ApprovalRequiredDocuments Table -IF OBJECT_ID('approvals.ApprovalRequiredDocuments', 'U') IS NULL -BEGIN - CREATE TABLE approvals.ApprovalRequiredDocuments ( - Id UNIQUEIDENTIFIER NOT NULL, - WorkflowId UNIQUEIDENTIFIER NOT NULL, - DocumentTypeId UNIQUEIDENTIFIER NOT NULL, - IsMandatory BIT NOT NULL, - CreatedBy NVARCHAR(100) NOT NULL, - CreatedAtUtc DATETIME2 NOT NULL, - UpdatedBy NVARCHAR(100) NULL, - UpdatedAtUtc DATETIME2 NULL, - AuditTimeSpan NVARCHAR(100) NOT NULL, - CONSTRAINT PK_ApprovalRequiredDocuments PRIMARY KEY (Id), - CONSTRAINT FK_ApprovalRequiredDocuments_ApprovalWorkflows FOREIGN KEY (WorkflowId) - REFERENCES approvals.ApprovalWorkflows (Id) ON DELETE CASCADE - ); - - CREATE INDEX IX_ApprovalRequiredDocuments_WorkflowId ON approvals.ApprovalRequiredDocuments (WorkflowId); - CREATE INDEX IX_ApprovalRequiredDocuments_DocumentTypeId ON approvals.ApprovalRequiredDocuments (DocumentTypeId); - CREATE UNIQUE INDEX UK_ApprovalRequiredDocuments_WorkflowId_DocumentTypeId ON approvals.ApprovalRequiredDocuments (WorkflowId, DocumentTypeId); -END -GO - --- 3. approvals.ApprovalRequests Table -IF OBJECT_ID('approvals.ApprovalRequests', 'U') IS NULL -BEGIN - CREATE TABLE approvals.ApprovalRequests ( - Id UNIQUEIDENTIFIER NOT NULL, - WorkflowId UNIQUEIDENTIFIER NOT NULL, - TargetUserId UNIQUEIDENTIFIER NOT NULL, - TargetProfileId UNIQUEIDENTIFIER NULL, - StatusId INT NOT NULL, - CreatedBy NVARCHAR(100) NOT NULL, - CreatedAtUtc DATETIME2 NOT NULL, - UpdatedBy NVARCHAR(100) NULL, - UpdatedAtUtc DATETIME2 NULL, - AuditTimeSpan NVARCHAR(100) NOT NULL, - RowVersion ROWVERSION NOT NULL, - CONSTRAINT PK_ApprovalRequests PRIMARY KEY (Id), - CONSTRAINT FK_ApprovalRequests_ApprovalWorkflows FOREIGN KEY (WorkflowId) - REFERENCES approvals.ApprovalWorkflows (Id) ON DELETE CASCADE - ); - - CREATE INDEX IX_ApprovalRequests_WorkflowId ON approvals.ApprovalRequests (WorkflowId); - CREATE INDEX IX_ApprovalRequests_TargetUserId ON approvals.ApprovalRequests (TargetUserId); - CREATE INDEX IX_ApprovalRequests_TargetProfileId ON approvals.ApprovalRequests (TargetProfileId); -END -GO - --- 4. approvals.NotificationRules Table -IF OBJECT_ID('approvals.NotificationRules', 'U') IS NULL -BEGIN - CREATE TABLE approvals.NotificationRules ( - Id UNIQUEIDENTIFIER NOT NULL, - TenantId UNIQUEIDENTIFIER NOT NULL, - ChannelId INT NOT NULL, - Recipient NVARCHAR(255) NOT NULL, - IsActive BIT NOT NULL, - CreatedBy NVARCHAR(100) NOT NULL, - CreatedAtUtc DATETIME2 NOT NULL, - UpdatedBy NVARCHAR(100) NULL, - UpdatedAtUtc DATETIME2 NULL, - AuditTimeSpan NVARCHAR(100) NOT NULL, - RowVersion ROWVERSION NOT NULL, - CONSTRAINT PK_NotificationRules PRIMARY KEY (Id) - ); - - CREATE INDEX IX_NotificationRules_TenantId ON approvals.NotificationRules (TenantId); -END -GO diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260523_sqlserver_audit_records.sql b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260523_sqlserver_audit_records.sql deleted file mode 100644 index 689ae5bc..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260523_sqlserver_audit_records.sql +++ /dev/null @@ -1,59 +0,0 @@ --- ========================================================================= --- Migration: UMS AuditRecords Table and Immutability Trigger --- Date: 2026-05-23 --- Bounded Context: Audit Bounded Context --- Schema: audit --- ========================================================================= - -IF SCHEMA_ID('audit') IS NULL -BEGIN - EXEC('CREATE SCHEMA audit;'); -END -GO - -IF OBJECT_ID('audit.AuditRecords', 'U') IS NULL -BEGIN - CREATE TABLE audit.AuditRecords ( - Id UNIQUEIDENTIFIER NOT NULL, - WhoActed UNIQUEIDENTIFIER NOT NULL, - SubjectTypeId INT NOT NULL, - WhenOccurred DATETIME2 NOT NULL, - WhatChanged NVARCHAR(4000) NOT NULL, - EventType NVARCHAR(255) NOT NULL, - AuditResultId INT NOT NULL, - AffectedEntityId UNIQUEIDENTIFIER NOT NULL, - AffectedEntityType NVARCHAR(255) NOT NULL, - RootTenantId UNIQUEIDENTIFIER NOT NULL, - Metadata NVARCHAR(4000) NULL, - CreatedBy NVARCHAR(100) NOT NULL, - CreatedAtUtc DATETIME2 NOT NULL, - UpdatedBy NVARCHAR(100) NULL, - UpdatedAtUtc DATETIME2 NULL, - AuditTimeSpan NVARCHAR(100) NOT NULL, - CONSTRAINT PK_AuditRecords PRIMARY KEY (Id) - ); - - CREATE INDEX IX_AuditRecords_WhoActed ON audit.AuditRecords (WhoActed); - CREATE INDEX IX_AuditRecords_AffectedEntityId ON audit.AuditRecords (AffectedEntityId); - CREATE INDEX IX_AuditRecords_RootTenantId ON audit.AuditRecords (RootTenantId); - CREATE INDEX IX_AuditRecords_EventType ON audit.AuditRecords (EventType); - CREATE INDEX IX_AuditRecords_Affected ON audit.AuditRecords (AffectedEntityId, AffectedEntityType); -END -GO - --- Enforce Audit Immutability at the SQL Engine Layer -IF OBJECT_ID('audit.trg_audit_records_immutable', 'TR') IS NOT NULL -BEGIN - DROP TRIGGER audit.trg_audit_records_immutable; -END -GO - -CREATE TRIGGER trg_audit_records_immutable -ON audit.AuditRecords -AFTER UPDATE, DELETE -AS -BEGIN - RAISERROR ('Audit log is immutable. UPDATE and DELETE are prohibited on audit.AuditRecords.', 16, 1); - ROLLBACK TRANSACTION; -END; -GO diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260523_sqlserver_configuration_aggregates.sql b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260523_sqlserver_configuration_aggregates.sql deleted file mode 100644 index 6e456c09..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260523_sqlserver_configuration_aggregates.sql +++ /dev/null @@ -1,108 +0,0 @@ -IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'ums_configuration') -BEGIN - EXEC('CREATE SCHEMA [ums_configuration]'); -END -GO - -IF OBJECT_ID('[ums_configuration].[AppConfigurations]', 'U') IS NULL -BEGIN - CREATE TABLE [ums_configuration].[AppConfigurations] - ( - [Id] uniqueidentifier NOT NULL, - [TenantId] uniqueidentifier NULL, - [SystemSuiteId] uniqueidentifier NULL, - [ModuleId] uniqueidentifier NULL, - [Code] nvarchar(100) NOT NULL, - [Value] nvarchar(4000) NOT NULL, - [Description] nvarchar(1000) NOT NULL, - [ScopeId] int NOT NULL, - [IsInheritable] bit NOT NULL, - [IsEncrypted] bit NOT NULL, - [Version] nvarchar(50) NOT NULL, - [StatusId] int NOT NULL, - [CreatedBy] nvarchar(100) NOT NULL, - [CreatedAtUtc] datetime2 NOT NULL, - [UpdatedBy] nvarchar(100) NULL, - [UpdatedAtUtc] datetime2 NULL, - [AuditTimeSpan] nvarchar(100) NOT NULL, - CONSTRAINT [PK_AppConfigurations] PRIMARY KEY ([Id]) - ); - CREATE UNIQUE INDEX [IX_AppConfigurations_Scope_Code] - ON [ums_configuration].[AppConfigurations]([TenantId], [SystemSuiteId], [ModuleId], [Code]); - CREATE INDEX [IX_AppConfigurations_ScopeId] ON [ums_configuration].[AppConfigurations]([ScopeId]); - CREATE INDEX [IX_AppConfigurations_StatusId] ON [ums_configuration].[AppConfigurations]([StatusId]); -END -GO - -IF OBJECT_ID('[ums_configuration].[FeatureFlags]', 'U') IS NULL -BEGIN - CREATE TABLE [ums_configuration].[FeatureFlags] - ( - [Id] uniqueidentifier NOT NULL, - [FlagCode] nvarchar(100) NOT NULL, - [FlagTypeId] int NOT NULL, - [FlagTargets] nvarchar(2000) NOT NULL, - [StatusId] int NOT NULL, - [LinkedResourceTypeId] int NULL, - [LinkedResourceId] uniqueidentifier NULL, - [RolloutPercentage] int NULL, - [CreatedBy] nvarchar(100) NOT NULL, - [CreatedAtUtc] datetime2 NOT NULL, - [UpdatedBy] nvarchar(100) NULL, - [UpdatedAtUtc] datetime2 NULL, - [AuditTimeSpan] nvarchar(100) NOT NULL, - CONSTRAINT [PK_FeatureFlags] PRIMARY KEY ([Id]) - ); - CREATE UNIQUE INDEX [IX_FeatureFlags_FlagCode] ON [ums_configuration].[FeatureFlags]([FlagCode]); - CREATE INDEX [IX_FeatureFlags_StatusId] ON [ums_configuration].[FeatureFlags]([StatusId]); - CREATE INDEX [IX_FeatureFlags_FlagTypeId] ON [ums_configuration].[FeatureFlags]([FlagTypeId]); -END -GO - -IF OBJECT_ID('[ums_configuration].[FeatureFlagEvaluationLogs]', 'U') IS NULL -BEGIN - CREATE TABLE [ums_configuration].[FeatureFlagEvaluationLogs] - ( - [Id] uniqueidentifier NOT NULL, - [FeatureFlagId] uniqueidentifier NOT NULL, - [EvaluatedBy] uniqueidentifier NOT NULL, - [Result] bit NOT NULL, - [Context] nvarchar(1000) NOT NULL, - [EvaluatedAtUtc] datetime2 NOT NULL, - CONSTRAINT [PK_FeatureFlagEvaluationLogs] PRIMARY KEY ([Id]), - CONSTRAINT [FK_FeatureFlagEvaluationLogs_FeatureFlags] FOREIGN KEY ([FeatureFlagId]) REFERENCES [ums_configuration].[FeatureFlags]([Id]) ON DELETE CASCADE - ); - CREATE INDEX [IX_FeatureFlagEvaluationLogs_FeatureFlagId] ON [ums_configuration].[FeatureFlagEvaluationLogs]([FeatureFlagId]); - CREATE INDEX [IX_FeatureFlagEvaluationLogs_EvaluatedAtUtc] ON [ums_configuration].[FeatureFlagEvaluationLogs]([EvaluatedAtUtc]); -END -GO - -IF OBJECT_ID('[ums_configuration].[IdpConfigurations]', 'U') IS NULL -BEGIN - CREATE TABLE [ums_configuration].[IdpConfigurations] - ( - [Id] uniqueidentifier NOT NULL, - [TenantId] uniqueidentifier NOT NULL, - [SystemSuiteId] uniqueidentifier NOT NULL, - [ProviderTypeId] int NOT NULL, - [DomainHintsJson] nvarchar(4000) NOT NULL, - [ConfigPayload] nvarchar(max) NOT NULL, - [SecretRef] nvarchar(500) NOT NULL, - [StatusId] int NOT NULL, - [ResolutionPriority] int NOT NULL, - [FallbackToId] uniqueidentifier NULL, - [Version] int NOT NULL, - [CreatedBy] nvarchar(100) NOT NULL, - [CreatedAtUtc] datetime2 NOT NULL, - [UpdatedBy] nvarchar(100) NULL, - [UpdatedAtUtc] datetime2 NULL, - [AuditTimeSpan] nvarchar(100) NOT NULL, - CONSTRAINT [PK_IdpConfigurations] PRIMARY KEY ([Id]) - ); - CREATE INDEX [IX_IdpConfigurations_TenantId] ON [ums_configuration].[IdpConfigurations]([TenantId]); - CREATE INDEX [IX_IdpConfigurations_SystemSuiteId] ON [ums_configuration].[IdpConfigurations]([SystemSuiteId]); - CREATE INDEX [IX_IdpConfigurations_ProviderTypeId] ON [ums_configuration].[IdpConfigurations]([ProviderTypeId]); - CREATE INDEX [IX_IdpConfigurations_TenantId_SystemSuiteId_ResolutionPriority] - ON [ums_configuration].[IdpConfigurations]([TenantId], [SystemSuiteId], [ResolutionPriority]); -END -GO diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260524_sqlserver_approvals_ep07_tables.sql b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260524_sqlserver_approvals_ep07_tables.sql deleted file mode 100644 index 76f72597..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260524_sqlserver_approvals_ep07_tables.sql +++ /dev/null @@ -1,136 +0,0 @@ --- ============================================================ --- Migration: EP-07 Approvals compliance tables --- Adds: approvals.DocumentTypes, approvals.UserDocuments, --- approvals.UserDocumentNotifications, --- approvals.AccessEnforcementPolicies --- Idempotent: all CREATE statements wrapped in IF NOT EXISTS guards --- ============================================================ - -IF SCHEMA_ID('approvals') IS NULL -BEGIN - EXEC('CREATE SCHEMA approvals;'); -END -GO - --- ── DocumentTypes ─────────────────────────────────────────── -IF OBJECT_ID('approvals.DocumentTypes', 'U') IS NULL -BEGIN - CREATE TABLE approvals.DocumentTypes ( - Id UNIQUEIDENTIFIER NOT NULL, - TenantId UNIQUEIDENTIFIER NOT NULL, - Code NVARCHAR(50) NOT NULL, - Name NVARCHAR(150) NOT NULL, - Description NVARCHAR(500) NOT NULL, - CriticityId INT NOT NULL, -- 1=Low,2=Medium,3=High - CreatedBy NVARCHAR(100) NOT NULL, - CreatedAtUtc DATETIME2(7) NOT NULL, - UpdatedBy NVARCHAR(100) NULL, - UpdatedAtUtc DATETIME2(7) NULL, - AuditTimeSpan NVARCHAR(100) NOT NULL, - RowVersion ROWVERSION NOT NULL, - CONSTRAINT PK_DocumentTypes PRIMARY KEY (Id) - ); - - CREATE INDEX IX_DocumentTypes_TenantId - ON approvals.DocumentTypes (TenantId); - - CREATE UNIQUE INDEX UX_DocumentTypes_TenantId_Code - ON approvals.DocumentTypes (TenantId, Code); -END -GO - --- ── UserDocuments ─────────────────────────────────────────── -IF OBJECT_ID('approvals.UserDocuments', 'U') IS NULL -BEGIN - CREATE TABLE approvals.UserDocuments ( - Id UNIQUEIDENTIFIER NOT NULL, - UserId UNIQUEIDENTIFIER NOT NULL, - DocumentTypeId UNIQUEIDENTIFIER NOT NULL, - IssueDate DATETIME2(7) NOT NULL, - ExpirationDate DATETIME2(7) NOT NULL, - StatusId INT NOT NULL, -- 1=PendingReview,2=Valid,3=Expired,4=Rejected - CriticityId INT NOT NULL, -- 1=Low,2=Medium,3=High - FileStoragePath NVARCHAR(1000) NOT NULL, - FileChecksum NVARCHAR(128) NOT NULL, - NotificationStep INT NOT NULL DEFAULT 0, - CreatedBy NVARCHAR(100) NOT NULL, - CreatedAtUtc DATETIME2(7) NOT NULL, - UpdatedBy NVARCHAR(100) NULL, - UpdatedAtUtc DATETIME2(7) NULL, - AuditTimeSpan NVARCHAR(100) NOT NULL, - RowVersion ROWVERSION NOT NULL, - CONSTRAINT PK_UserDocuments PRIMARY KEY (Id) - ); - - CREATE INDEX IX_UserDocuments_UserId - ON approvals.UserDocuments (UserId); - - CREATE INDEX IX_UserDocuments_UserId_DocumentTypeId - ON approvals.UserDocuments (UserId, DocumentTypeId); - - CREATE INDEX IX_UserDocuments_ExpirationDate - ON approvals.UserDocuments (ExpirationDate); - - CREATE INDEX IX_UserDocuments_StatusId - ON approvals.UserDocuments (StatusId); -END -GO - --- ── UserDocumentNotifications ─────────────────────────────── -IF OBJECT_ID('approvals.UserDocumentNotifications', 'U') IS NULL -BEGIN - CREATE TABLE approvals.UserDocumentNotifications ( - Id UNIQUEIDENTIFIER NOT NULL, - UserDocumentId UNIQUEIDENTIFIER NOT NULL, - Step INT NOT NULL, - ChannelId INT NOT NULL, - DaysRemaining INT NOT NULL, - SentAt DATETIME2(7) NOT NULL, - CONSTRAINT PK_UserDocumentNotifications PRIMARY KEY (Id), - CONSTRAINT FK_UserDocumentNotifications_UserDocuments - FOREIGN KEY (UserDocumentId) - REFERENCES approvals.UserDocuments (Id) - ON DELETE CASCADE - ); - - CREATE INDEX IX_UserDocumentNotifications_UserDocumentId - ON approvals.UserDocumentNotifications (UserDocumentId); - - CREATE UNIQUE INDEX UX_UserDocumentNotifications_UserDocumentId_Step - ON approvals.UserDocumentNotifications (UserDocumentId, Step); -END -GO - --- ── AccessEnforcementPolicies ─────────────────────────────── -IF OBJECT_ID('approvals.AccessEnforcementPolicies', 'U') IS NULL -BEGIN - CREATE TABLE approvals.AccessEnforcementPolicies ( - Id UNIQUEIDENTIFIER NOT NULL, - TenantId UNIQUEIDENTIFIER NOT NULL, - ProfileId UNIQUEIDENTIFIER NULL, - RoleId UNIQUEIDENTIFIER NULL, - EnforcementActionId INT NOT NULL, -- 1=BlockUser,2=RestrictProfile,3=LogOnly - IsActive BIT NOT NULL DEFAULT 1, - CreatedBy NVARCHAR(100) NOT NULL, - CreatedAtUtc DATETIME2(7) NOT NULL, - UpdatedBy NVARCHAR(100) NULL, - UpdatedAtUtc DATETIME2(7) NULL, - AuditTimeSpan NVARCHAR(100) NOT NULL, - RowVersion ROWVERSION NOT NULL, - CONSTRAINT PK_AccessEnforcementPolicies PRIMARY KEY (Id), - CONSTRAINT CK_AccessEnforcementPolicies_ProfileOrRole - CHECK (ProfileId IS NOT NULL OR RoleId IS NOT NULL) - ); - - CREATE INDEX IX_AccessEnforcementPolicies_TenantId - ON approvals.AccessEnforcementPolicies (TenantId); - - CREATE INDEX IX_AccessEnforcementPolicies_TenantId_ProfileId - ON approvals.AccessEnforcementPolicies (TenantId, ProfileId) - WHERE ProfileId IS NOT NULL; - - CREATE INDEX IX_AccessEnforcementPolicies_TenantId_RoleId - ON approvals.AccessEnforcementPolicies (TenantId, RoleId) - WHERE RoleId IS NOT NULL; -END -GO diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260524_sqlserver_authorization_advanced.sql b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260524_sqlserver_authorization_advanced.sql deleted file mode 100644 index f0cf8a2f..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260524_sqlserver_authorization_advanced.sql +++ /dev/null @@ -1,217 +0,0 @@ -IF SCHEMA_ID(N'ums_authorization') IS NULL - EXEC('CREATE SCHEMA [ums_authorization]'); -GO - -IF OBJECT_ID(N'[ums_authorization].[SystemSuites]', N'U') IS NULL -BEGIN - CREATE TABLE [ums_authorization].[SystemSuites] - ( - [Id] UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, - [TenantId] UNIQUEIDENTIFIER NOT NULL, - [Code] NVARCHAR(100) NOT NULL, - [Name] NVARCHAR(200) NOT NULL, - [Description] NVARCHAR(1000) NOT NULL, - [StatusId] INT NOT NULL, - [CreatedBy] NVARCHAR(100) NOT NULL, - [CreatedAtUtc] DATETIME2 NOT NULL, - [UpdatedBy] NVARCHAR(100) NULL, - [UpdatedAtUtc] DATETIME2 NULL, - [AuditTimeSpan] NVARCHAR(100) NOT NULL, - [RowVersion] ROWVERSION NOT NULL - ); - - CREATE UNIQUE INDEX [UX_SystemSuites_Tenant_Code] - ON [ums_authorization].[SystemSuites]([TenantId], [Code]); -END -GO - -IF OBJECT_ID(N'[ums_authorization].[SystemSuiteModules]', N'U') IS NULL -BEGIN - CREATE TABLE [ums_authorization].[SystemSuiteModules] - ( - [Id] UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, - [SystemSuiteId] UNIQUEIDENTIFIER NOT NULL, - [Code] NVARCHAR(100) NOT NULL, - [Name] NVARCHAR(200) NOT NULL, - [Description] NVARCHAR(1000) NOT NULL, - [StatusId] INT NOT NULL, - [SortOrder] INT NOT NULL, - [CreatedBy] NVARCHAR(100) NOT NULL, - [CreatedAtUtc] DATETIME2 NOT NULL, - [UpdatedBy] NVARCHAR(100) NULL, - [UpdatedAtUtc] DATETIME2 NULL, - [AuditTimeSpan] NVARCHAR(100) NOT NULL, - CONSTRAINT [FK_SystemSuiteModules_SystemSuites] - FOREIGN KEY ([SystemSuiteId]) REFERENCES [ums_authorization].[SystemSuites]([Id]) ON DELETE CASCADE - ); - - CREATE UNIQUE INDEX [UX_SystemSuiteModules_Suite_Code] - ON [ums_authorization].[SystemSuiteModules]([SystemSuiteId], [Code]); -END -GO - -IF OBJECT_ID(N'[ums_authorization].[SystemSuiteMenus]', N'U') IS NULL -BEGIN - CREATE TABLE [ums_authorization].[SystemSuiteMenus] - ( - [Id] UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, - [ModuleId] UNIQUEIDENTIFIER NOT NULL, - [Code] NVARCHAR(100) NOT NULL, - [Label] NVARCHAR(200) NOT NULL, - [Description] NVARCHAR(1000) NOT NULL, - [SortOrder] INT NOT NULL, - [CreatedBy] NVARCHAR(100) NOT NULL, - [CreatedAtUtc] DATETIME2 NOT NULL, - [UpdatedBy] NVARCHAR(100) NULL, - [UpdatedAtUtc] DATETIME2 NULL, - [AuditTimeSpan] NVARCHAR(100) NOT NULL, - CONSTRAINT [FK_SystemSuiteMenus_Modules] - FOREIGN KEY ([ModuleId]) REFERENCES [ums_authorization].[SystemSuiteModules]([Id]) ON DELETE CASCADE - ); - - CREATE UNIQUE INDEX [UX_SystemSuiteMenus_Module_Code] - ON [ums_authorization].[SystemSuiteMenus]([ModuleId], [Code]); -END -GO - -IF OBJECT_ID(N'[ums_authorization].[SystemSuiteSubMenus]', N'U') IS NULL -BEGIN - CREATE TABLE [ums_authorization].[SystemSuiteSubMenus] - ( - [Id] UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, - [MenuId] UNIQUEIDENTIFIER NOT NULL, - [Code] NVARCHAR(100) NOT NULL, - [Label] NVARCHAR(200) NOT NULL, - [Description] NVARCHAR(1000) NOT NULL, - [SortOrder] INT NOT NULL, - [CreatedBy] NVARCHAR(100) NOT NULL, - [CreatedAtUtc] DATETIME2 NOT NULL, - [UpdatedBy] NVARCHAR(100) NULL, - [UpdatedAtUtc] DATETIME2 NULL, - [AuditTimeSpan] NVARCHAR(100) NOT NULL, - CONSTRAINT [FK_SystemSuiteSubMenus_Menus] - FOREIGN KEY ([MenuId]) REFERENCES [ums_authorization].[SystemSuiteMenus]([Id]) ON DELETE CASCADE - ); - - CREATE UNIQUE INDEX [UX_SystemSuiteSubMenus_Menu_Code] - ON [ums_authorization].[SystemSuiteSubMenus]([MenuId], [Code]); -END -GO - -IF OBJECT_ID(N'[ums_authorization].[SystemSuiteOptions]', N'U') IS NULL -BEGIN - CREATE TABLE [ums_authorization].[SystemSuiteOptions] - ( - [Id] UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, - [SubMenuId] UNIQUEIDENTIFIER NOT NULL, - [Code] NVARCHAR(100) NOT NULL, - [Label] NVARCHAR(200) NOT NULL, - [Description] NVARCHAR(1000) NOT NULL, - [ActionCode] NVARCHAR(100) NOT NULL, - [SortOrder] INT NOT NULL, - [CreatedBy] NVARCHAR(100) NOT NULL, - [CreatedAtUtc] DATETIME2 NOT NULL, - [UpdatedBy] NVARCHAR(100) NULL, - [UpdatedAtUtc] DATETIME2 NULL, - [AuditTimeSpan] NVARCHAR(100) NOT NULL, - CONSTRAINT [FK_SystemSuiteOptions_SubMenus] - FOREIGN KEY ([SubMenuId]) REFERENCES [ums_authorization].[SystemSuiteSubMenus]([Id]) ON DELETE CASCADE - ); - - CREATE UNIQUE INDEX [UX_SystemSuiteOptions_SubMenu_Code] - ON [ums_authorization].[SystemSuiteOptions]([SubMenuId], [Code]); -END -GO - -IF OBJECT_ID(N'[ums_authorization].[SystemSuiteAppSettings]', N'U') IS NULL -BEGIN - CREATE TABLE [ums_authorization].[SystemSuiteAppSettings] - ( - [Id] UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, - [SystemSuiteId] UNIQUEIDENTIFIER NOT NULL, - [ConfigKey] NVARCHAR(100) NOT NULL, - [ConfigValue] NVARCHAR(4000) NOT NULL, - [ScopeId] INT NOT NULL, - CONSTRAINT [FK_SystemSuiteAppSettings_SystemSuites] - FOREIGN KEY ([SystemSuiteId]) REFERENCES [ums_authorization].[SystemSuites]([Id]) ON DELETE CASCADE - ); - - CREATE UNIQUE INDEX [UX_SystemSuiteAppSettings_Suite_Key_Scope] - ON [ums_authorization].[SystemSuiteAppSettings]([SystemSuiteId], [ConfigKey], [ScopeId]); -END -GO - -IF OBJECT_ID(N'[ums_authorization].[SystemSuiteActions]', N'U') IS NULL -BEGIN - CREATE TABLE [ums_authorization].[SystemSuiteActions] - ( - [Id] UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, - [TenantId] UNIQUEIDENTIFIER NOT NULL, - [SystemSuiteId] UNIQUEIDENTIFIER NOT NULL, - [ModuleId] UNIQUEIDENTIFIER NULL, - [Code] NVARCHAR(100) NOT NULL, - [Name] NVARCHAR(200) NOT NULL, - [CreatedBy] NVARCHAR(100) NOT NULL, - [CreatedAtUtc] DATETIME2 NOT NULL, - [UpdatedBy] NVARCHAR(100) NULL, - [UpdatedAtUtc] DATETIME2 NULL, - [AuditTimeSpan] NVARCHAR(100) NOT NULL, - CONSTRAINT [FK_SystemSuiteActions_SystemSuites] - FOREIGN KEY ([SystemSuiteId]) REFERENCES [ums_authorization].[SystemSuites]([Id]) ON DELETE CASCADE - ); - - CREATE UNIQUE INDEX [UX_SystemSuiteActions_Suite_Code] - ON [ums_authorization].[SystemSuiteActions]([SystemSuiteId], [Code]); -END -GO - -IF OBJECT_ID(N'[ums_authorization].[PermissionTemplates]', N'U') IS NULL -BEGIN - CREATE TABLE [ums_authorization].[PermissionTemplates] - ( - [Id] UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, - [TenantId] UNIQUEIDENTIFIER NOT NULL, - [RoleId] UNIQUEIDENTIFIER NOT NULL, - [SystemSuiteId] UNIQUEIDENTIFIER NOT NULL, - [Version] NVARCHAR(50) NOT NULL, - [StatusId] INT NOT NULL, - [CreatedBy] NVARCHAR(100) NOT NULL, - [CreatedAtUtc] DATETIME2 NOT NULL, - [UpdatedBy] NVARCHAR(100) NULL, - [UpdatedAtUtc] DATETIME2 NULL, - [AuditTimeSpan] NVARCHAR(100) NOT NULL, - [RowVersion] ROWVERSION NOT NULL, - CONSTRAINT [FK_PermissionTemplates_SystemSuites] - FOREIGN KEY ([SystemSuiteId]) REFERENCES [ums_authorization].[SystemSuites]([Id]) - ); - - CREATE UNIQUE INDEX [UX_PermissionTemplates_Tenant_Role_Suite_Version] - ON [ums_authorization].[PermissionTemplates]([TenantId], [RoleId], [SystemSuiteId], [Version]); -END -GO - -IF OBJECT_ID(N'[ums_authorization].[PermissionTemplateItems]', N'U') IS NULL -BEGIN - CREATE TABLE [ums_authorization].[PermissionTemplateItems] - ( - [Id] UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, - [TemplateId] UNIQUEIDENTIFIER NOT NULL, - [TargetTypeId] INT NOT NULL, - [TargetId] UNIQUEIDENTIFIER NOT NULL, - [ActionId] UNIQUEIDENTIFIER NOT NULL, - [IsAllowed] BIT NOT NULL, - [IsDenied] BIT NOT NULL, - [IsActive] BIT NOT NULL, - [CreatedBy] NVARCHAR(100) NOT NULL, - [CreatedAtUtc] DATETIME2 NOT NULL, - [UpdatedBy] NVARCHAR(100) NULL, - [UpdatedAtUtc] DATETIME2 NULL, - [AuditTimeSpan] NVARCHAR(100) NOT NULL, - CONSTRAINT [FK_PermissionTemplateItems_Templates] - FOREIGN KEY ([TemplateId]) REFERENCES [ums_authorization].[PermissionTemplates]([Id]) ON DELETE CASCADE - ); - - CREATE UNIQUE INDEX [UX_PermissionTemplateItems_Target_Action] - ON [ums_authorization].[PermissionTemplateItems]([TemplateId], [TargetTypeId], [TargetId], [ActionId]); -END -GO diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260527_sqlserver_iga_promotion_impact_rowversion.sql b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260527_sqlserver_iga_promotion_impact_rowversion.sql deleted file mode 100644 index 88b625b0..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260527_sqlserver_iga_promotion_impact_rowversion.sql +++ /dev/null @@ -1,6 +0,0 @@ --- UMS - Add RowVersion to PromotionImpactAnalyses for optimistic concurrency --- Date: 2026-05-27 --- Scope: SQL Server 2022 - -ALTER TABLE [ums_iga].[PromotionImpactAnalyses] -ADD [RowVersion] ROWVERSION NOT NULL; diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260527_sqlserver_standardize_schema_names.sql b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260527_sqlserver_standardize_schema_names.sql deleted file mode 100644 index 12ef5158..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260527_sqlserver_standardize_schema_names.sql +++ /dev/null @@ -1,32 +0,0 @@ --- UMS - Standardize schema names to ums_ convention --- Date: 2026-05-27 --- Scope: SQL Server 2022 - --- Rename approvals schema to ums_approvals -IF EXISTS (SELECT * FROM sys.schemas WHERE name = 'approvals') -BEGIN - ALTER SCHEMA [ums_approvals] TRANSFER [approvals].[ApprovalRequests]; - ALTER SCHEMA [ums_approvals] TRANSFER [approvals].[ApprovalActions]; - ALTER SCHEMA [ums_approvals] TRANSFER [approvals].[ApprovalComments]; - ALTER SCHEMA [ums_approvals] TRANSFER [approvals].[ApprovalWorkflows]; - DROP SCHEMA [approvals]; -END - --- Rename iga schema to ums_iga -IF EXISTS (SELECT * FROM sys.schemas WHERE name = 'iga') -BEGIN - ALTER SCHEMA [ums_iga] TRANSFER [iga].[PromotionRequests]; - ALTER SCHEMA [ums_iga] TRANSFER [iga].[PromotionImpactAnalyses]; - ALTER SCHEMA [ums_iga] TRANSFER [iga].[RolePromotionRecords]; - ALTER SCHEMA [ums_iga] TRANSFER [iga].[CertificationCampaigns]; - ALTER SCHEMA [ums_iga] TRANSFER [iga].[CertificationReviews]; - ALTER SCHEMA [ums_iga] TRANSFER [iga].[AccessReviews]; - DROP SCHEMA [iga]; -END - --- Create new schemas if they don't exist -IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'ums_approvals') - EXEC('CREATE SCHEMA [ums_approvals]'); - -IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'ums_iga') - EXEC('CREATE SCHEMA [ums_iga]'); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260528_sqlserver_authorization_roles.sql b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260528_sqlserver_authorization_roles.sql deleted file mode 100644 index 70773310..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260528_sqlserver_authorization_roles.sql +++ /dev/null @@ -1,40 +0,0 @@ -IF SCHEMA_ID(N'ums_authorization') IS NULL - EXEC('CREATE SCHEMA [ums_authorization]'); -GO - -IF OBJECT_ID(N'[ums_authorization].[Roles]', N'U') IS NULL -BEGIN - CREATE TABLE [ums_authorization].[Roles] - ( - [Id] UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, - [TenantId] UNIQUEIDENTIFIER NOT NULL, - [SystemSuiteId] UNIQUEIDENTIFIER NOT NULL, - [ParentRoleId] UNIQUEIDENTIFIER NULL, - [Code] NVARCHAR(50) NOT NULL, - [Value] NVARCHAR(150) NOT NULL, - [Description] NVARCHAR(500) NOT NULL, - [HierarchyLevel] INT NOT NULL, - [PromotionOrder] INT NOT NULL, - [IsActive] BIT NOT NULL, - [CreatedBy] NVARCHAR(100) NOT NULL, - [CreatedAtUtc] DATETIME2 NOT NULL, - [UpdatedBy] NVARCHAR(100) NULL, - [UpdatedAtUtc] DATETIME2 NULL, - [AuditTimeSpan] NVARCHAR(100) NOT NULL, - [RowVersion] ROWVERSION NOT NULL, - CONSTRAINT [FK_Roles_SystemSuites] - FOREIGN KEY ([SystemSuiteId]) REFERENCES [ums_authorization].[SystemSuites]([Id]), - CONSTRAINT [FK_Roles_ParentRole] - FOREIGN KEY ([ParentRoleId]) REFERENCES [ums_authorization].[Roles]([Id]), - CONSTRAINT [CK_Roles_HierarchyLevel] CHECK ([HierarchyLevel] >= 0), - CONSTRAINT [CK_Roles_PromotionOrder] CHECK ([PromotionOrder] >= 0) - ); - - CREATE UNIQUE INDEX [UX_Roles_SystemSuite_Code] - ON [ums_authorization].[Roles]([SystemSuiteId], [Code]); - CREATE INDEX [IX_Roles_TenantId] - ON [ums_authorization].[Roles]([TenantId]); - CREATE INDEX [IX_Roles_ParentRoleId] - ON [ums_authorization].[Roles]([ParentRoleId]); -END -GO diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/AggregateStoreMode.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/AggregateStoreMode.cs index 3ce60d12..6c78436c 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/AggregateStoreMode.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/AggregateStoreMode.cs @@ -3,6 +3,5 @@ namespace Ums.Infrastructure.Persistence.Options; public enum AggregateStoreMode { InMemory = 0, - SqlServer = 1, PostgreSql = 2, } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceOptions.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceOptions.cs index b0a414a3..68e4935e 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceOptions.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceOptions.cs @@ -4,27 +4,22 @@ public sealed class PersistenceOptions { public const string SectionName = "Persistence"; - public PersistenceProvider Provider { get; init; } = PersistenceProvider.SqlServer; + public PersistenceProvider Provider { get; init; } = PersistenceProvider.PostgreSql; public AggregateStoreMode AggregateStoreMode { get; init; } = AggregateStoreMode.InMemory; - public bool UseSqlServerIdentityStores { get; init; } = false; public bool UseSqliteIdentityStores { get; init; } = false; public bool UsePostgreSqlIdentityStores { get; init; } = false; - public bool UseSqlServerAuthorizationStores { get; init; } = false; public bool UseSqliteAuthorizationStores { get; init; } = false; public bool UsePostgreSqlAuthorizationStores { get; init; } = false; - public bool UseSqlServerConfigurationStores { get; init; } = false; public bool UseSqliteConfigurationStores { get; init; } = false; public bool UsePostgreSqlConfigurationStores { get; init; } = false; - public bool UseSqlServerApprovalsStores { get; init; } = false; public bool UseSqliteApprovalsStores { get; init; } = false; public bool UsePostgreSqlApprovalsStores { get; init; } = false; - public bool UseSqlServerIgaStores { get; init; } = false; public bool UseSqliteIgaStores { get; init; } = false; public bool UsePostgreSqlIgaStores { get; init; } = false; diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceProvider.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceProvider.cs index 82f5bdd4..0216fe9a 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceProvider.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceProvider.cs @@ -3,7 +3,6 @@ namespace Ums.Infrastructure.Persistence.Options; public enum PersistenceProvider { InMemory = 0, - SqlServer = 1, Sqlite = 2, PostgreSql = 3, } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/SqlServerDistributedLockProvider.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/SqlServerDistributedLockProvider.cs deleted file mode 100644 index bcc11496..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/SqlServerDistributedLockProvider.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Ums.Infrastructure.Persistence; - -public sealed class SqlServerDistributedLockProvider : IDistributedLockProvider -{ - public async Task AcquireLockAsync( - DbContext dbContext, - string resourceName, - TimeSpan timeout, - CancellationToken cancellationToken = default) - { - var timeoutMs = (int)timeout.TotalMilliseconds; - - // Note: The caller must ensure that the DbContext has an open connection, - // otherwise EF Core will close the connection immediately after ExecuteSqlRawAsync, - // which drops the Session-level sp_getapplock. - await dbContext.Database.ExecuteSqlRawAsync( - "EXEC sp_getapplock @Resource = {0}, @LockMode = N'Exclusive', @LockOwner = N'Session', @LockTimeout = {1};", - new object[] { resourceName, timeoutMs }, - cancellationToken); - - return new SqlServerLockScope(dbContext, resourceName); - } - - private sealed class SqlServerLockScope : IAsyncDisposable - { - private readonly DbContext _dbContext; - private readonly string _resourceName; - - public SqlServerLockScope(DbContext dbContext, string resourceName) - { - _dbContext = dbContext; - _resourceName = resourceName; - } - - public async ValueTask DisposeAsync() - { - try - { - // Release the lock when the scope is disposed - await _dbContext.Database.ExecuteSqlRawAsync( - "EXEC sp_releaseapplock @Resource = {0}, @LockOwner = N'Session';", - new object[] { _resourceName }); - } - catch - { - // Suppress release errors so we don't mask original exceptions during disposal. - // The lock will naturally release when the connection is closed. - } - } - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/SqlServerSchemaBootstrapper.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/SqlServerSchemaBootstrapper.cs deleted file mode 100644 index 62834225..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/SqlServerSchemaBootstrapper.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Reflection; -using System.Text.RegularExpressions; -using Microsoft.EntityFrameworkCore; - -namespace Ums.Infrastructure.Persistence; - -public static partial class SqlServerSchemaBootstrapper -{ - private static readonly string[] ScriptOrder = - [ - "20260521_sqlserver_platform_outbox.sql", - "20260521_sqlserver_identity_aggregates.sql", - "20260521_sqlserver_authorization_profiles.sql", - "20260522_sqlserver_identity_delegations.sql", - "20260524_sqlserver_authorization_advanced.sql", - "20260523_sqlserver_configuration_aggregates.sql", - "20260523_sqlserver_audit_records.sql", - "20260523_sqlserver_approvals.sql", - "20260523_soft_delete_gdpr.sql", // REC-16 - "20260523_outbox_dispatch_lease.sql", // HARDENING-01 - "20260524_sqlserver_approvals_ep07_tables.sql", // EP-07: DocumentType, UserDocument, AEP - ]; - - /// - /// HARDENING-05: Runs all pending migration scripts under a SQL Server distributed lock - /// (sp_getapplock) so that concurrent pod startups do not run migrations simultaneously. - /// - /// Lock semantics: - /// - Mode = Exclusive → only one session holds the lock at a time. - /// - Timeout = 60 000 ms → pods wait up to 60 s for the lock; they do not crash on startup. - /// - The lock is released automatically when the connection is returned to the pool (end of scope). - /// - All migration SQL scripts are idempotent (IF NOT EXISTS guards) so a second pod that - /// acquires the lock after the first finishes will simply execute no-ops. - /// - public static async Task InitializeAsync( - UmsPlatformDbContext dbContext, - IDistributedLockProvider lockProvider, - CancellationToken cancellationToken = default) - { - await dbContext.Database.EnsureCreatedAsync(cancellationToken); - - // Open connection explicitly so the session spans the lock acquisition and release - await dbContext.Database.OpenConnectionAsync(cancellationToken); - try - { - await using var lockScope = await lockProvider.AcquireLockAsync( - dbContext, - "ums_schema_migrations", - TimeSpan.FromSeconds(60), - cancellationToken); - - var assembly = typeof(SqlServerSchemaBootstrapper).Assembly; - - foreach (var scriptName in ScriptOrder) - { - var resourceName = assembly - .GetManifestResourceNames() - .SingleOrDefault(name => name.EndsWith(scriptName, StringComparison.OrdinalIgnoreCase)) - ?? throw new InvalidOperationException($"Embedded SQL script '{scriptName}' was not found."); - - await using var stream = assembly.GetManifestResourceStream(resourceName) - ?? throw new InvalidOperationException($"Embedded SQL script '{resourceName}' could not be opened."); - - using var reader = new StreamReader(stream); - var sql = await reader.ReadToEndAsync(cancellationToken); - - foreach (var batch in SplitBatches(sql)) - { - if (string.IsNullOrWhiteSpace(batch)) continue; - await dbContext.Database.ExecuteSqlRawAsync(batch, cancellationToken); - } - } - } - finally - { - await dbContext.Database.CloseConnectionAsync(); - } - } - - private static IEnumerable SplitBatches(string sql) - { - return GoBatchRegex() - .Split(sql) - .Select(batch => batch.Trim()) - .Where(batch => !string.IsNullOrWhiteSpace(batch)); - } - - [GeneratedRegex(@"^\s*GO\s*$(\r?\n)?", RegexOptions.Multiline | RegexOptions.IgnoreCase)] - private static partial Regex GoBatchRegex(); -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Ums.Infrastructure.csproj b/src/apps/ums.api/Ums.Infrastructure/Ums.Infrastructure.csproj index 7db221ba..7fc72289 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Ums.Infrastructure.csproj +++ b/src/apps/ums.api/Ums.Infrastructure/Ums.Infrastructure.csproj @@ -36,8 +36,6 @@ - - diff --git a/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs b/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs index bab45893..fb7919cc 100644 --- a/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs +++ b/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs @@ -264,11 +264,7 @@ public static async Task InitializeUmsPlatformAsync(this WebAppl using var scope = app.Services.CreateScope(); var platformDbContext = scope.ServiceProvider.GetRequiredService(); - if (persistence.Provider == PersistenceProvider.SqlServer) - { - await SqlServerSchemaBootstrapper.InitializeAsync(platformDbContext, new SqlServerDistributedLockProvider()); - } - else if (persistence.Provider == PersistenceProvider.Sqlite) + if (persistence.Provider == PersistenceProvider.Sqlite) { await SqliteSchemaBootstrapper.InitializeAsync(platformDbContext); } diff --git a/src/apps/ums.api/Ums.Presentation/appsettings.Development.json b/src/apps/ums.api/Ums.Presentation/appsettings.Development.json index 6b8d3c29..6b321f0b 100644 --- a/src/apps/ums.api/Ums.Presentation/appsettings.Development.json +++ b/src/apps/ums.api/Ums.Presentation/appsettings.Development.json @@ -14,19 +14,14 @@ "Persistence": { "Provider": "PostgreSql", "AggregateStoreMode": "PostgreSql", - "UseSqlServerIdentityStores": false, "UseSqliteIdentityStores": false, "UsePostgreSqlIdentityStores": true, - "UseSqlServerAuthorizationStores": false, "UseSqliteAuthorizationStores": false, "UsePostgreSqlAuthorizationStores": true, - "UseSqlServerConfigurationStores": false, "UseSqliteConfigurationStores": false, "UsePostgreSqlConfigurationStores": true, - "UseSqlServerApprovalsStores": false, "UseSqliteApprovalsStores": false, "UsePostgreSqlApprovalsStores": true, - "UseSqlServerIgaStores": false, "UseSqliteIgaStores": false, "UsePostgreSqlIgaStores": true, "SeedDevData": true, diff --git a/src/apps/ums.api/Ums.Presentation/appsettings.UAT.json b/src/apps/ums.api/Ums.Presentation/appsettings.UAT.json index 4ca9bc5b..2015fc47 100644 --- a/src/apps/ums.api/Ums.Presentation/appsettings.UAT.json +++ b/src/apps/ums.api/Ums.Presentation/appsettings.UAT.json @@ -13,15 +13,10 @@ "Persistence": { "Provider": "PostgreSql", "AggregateStoreMode": "PostgreSql", - "UseSqlServerIdentityStores": false, "UsePostgreSqlIdentityStores": true, - "UseSqlServerAuthorizationStores": false, "UsePostgreSqlAuthorizationStores": true, - "UseSqlServerConfigurationStores": false, "UsePostgreSqlConfigurationStores": true, - "UseSqlServerApprovalsStores": false, "UsePostgreSqlApprovalsStores": true, - "UseSqlServerIgaStores": false, "UsePostgreSqlIgaStores": true, "SeedDevData": false, "EnableOutbox": true, diff --git a/src/apps/ums.api/Ums.Presentation/appsettings.json b/src/apps/ums.api/Ums.Presentation/appsettings.json index f1df59af..765e539a 100644 --- a/src/apps/ums.api/Ums.Presentation/appsettings.json +++ b/src/apps/ums.api/Ums.Presentation/appsettings.json @@ -13,15 +13,10 @@ "Persistence": { "Provider": "PostgreSql", "AggregateStoreMode": "PostgreSql", - "UseSqlServerIdentityStores": false, "UsePostgreSqlIdentityStores": true, - "UseSqlServerAuthorizationStores": false, "UsePostgreSqlAuthorizationStores": true, - "UseSqlServerConfigurationStores": false, "UsePostgreSqlConfigurationStores": true, - "UseSqlServerApprovalsStores": false, "UsePostgreSqlApprovalsStores": true, - "UseSqlServerIgaStores": false, "UsePostgreSqlIgaStores": true, "SeedDevData": false, "EnableOutbox": true,