using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Learning; using Tiku.Application.Security; using Tiku.Domain.Learning; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Learning; internal sealed class PlatformLearningAccessAdministrationService( ITenantExecutionScope tenantExecutionScope) : IPlatformLearningAccessAdministrationService { public Task> GetBusinessLinesAsync( CancellationToken cancellationToken = default) => ExecuteGlobalAsync>( "List learning business lines", async (db, _, token) => await db.BusinessLines.AsNoTracking().OrderBy(item => item.Code) .Select(item => new LearningDictionaryItem( item.Id, item.Code, item.Name, item.RegionAccessStrategy.ToString(), item.IsActive, item.RequiresBaseRegion, item.TargetRegionCooldownDays)) .ToArrayAsync(token), cancellationToken); public Task UpsertBusinessLineAsync( UpsertBusinessLineCommand command, CancellationToken cancellationToken = default) => ExecuteGlobalAsync("Upsert learning business line", async (db, _, token) => { ValidateCodeAndName(command.Code, command.Name); var entity = command.Id.HasValue ? await db.BusinessLines.SingleOrDefaultAsync(item => item.Id == command.Id.Value, token) : null; entity ??= new BusinessLine(); var isNew = db.Entry(entity).State == EntityState.Detached; entity.Code = command.Code.Trim().ToLowerInvariant(); entity.Name = command.Name.Trim(); if (command.TargetRegionCooldownDays is < 0 or > 365) throw Error("target_region_cooldown_invalid", "Target-region cooldown must be between 0 and 365 days."); entity.RegionAccessStrategy = command.RegionAccessStrategy; entity.RequiresBaseRegion = command.RequiresBaseRegion; entity.TargetRegionCooldownDays = command.TargetRegionCooldownDays; entity.IsActive = command.IsActive; if (isNew) db.BusinessLines.Add(entity); await db.SaveChangesAsync(token); return new LearningDictionaryItem( entity.Id, entity.Code, entity.Name, entity.RegionAccessStrategy.ToString(), entity.IsActive, entity.RequiresBaseRegion, entity.TargetRegionCooldownDays); }, cancellationToken); public Task> GetMarketRegionsAsync( CancellationToken cancellationToken = default) => ExecuteGlobalAsync>( "List learning market regions", async (db, _, token) => await db.MarketRegions.AsNoTracking().OrderBy(item => item.Code) .Select(item => new LearningDictionaryItem( item.Id, item.Code, item.Name, item.ParentCode, item.IsActive)) .ToArrayAsync(token), cancellationToken); public Task UpsertMarketRegionAsync( UpsertMarketRegionCommand command, CancellationToken cancellationToken = default) => ExecuteGlobalAsync("Upsert learning market region", async (db, _, token) => { ValidateCodeAndName(command.Code, command.Name); var entity = command.Id.HasValue ? await db.MarketRegions.SingleOrDefaultAsync(item => item.Id == command.Id.Value, token) : null; entity ??= new MarketRegion(); var isNew = db.Entry(entity).State == EntityState.Detached; entity.Code = command.Code.Trim().ToLowerInvariant(); entity.Name = command.Name.Trim(); entity.ParentCode = string.IsNullOrWhiteSpace(command.ParentCode) ? null : command.ParentCode.Trim().ToLowerInvariant(); entity.IsActive = command.IsActive; if (isNew) db.MarketRegions.Add(entity); await db.SaveChangesAsync(token); return new LearningDictionaryItem( entity.Id, entity.Code, entity.Name, entity.ParentCode, entity.IsActive); }, cancellationToken); public Task GetTenantLicenseAsync( Guid tenantId, CancellationToken cancellationToken = default) => ExecuteTenantAsync(tenantId, "Read tenant learning license", async (db, _, token) => { var license = await db.TenantLearningLicenses.AsNoTracking() .Where(item => item.TenantId == tenantId && item.IsPrimary) .OrderByDescending(item => item.UpdatedAt) .FirstOrDefaultAsync(token); return license is null ? null : await ToLicenseItemAsync(db, license, token); }, cancellationToken); public Task UpsertTenantLicenseAsync( UpsertTenantLearningLicenseCommand command, CancellationToken cancellationToken = default) => ExecuteTenantAsync(command.TenantId, "Upsert tenant learning license", async (db, access, token) => { await RequireTenantAndBusinessAsync(db, command.TenantId, command.BusinessLineId, token); if (command.EndsAt.HasValue && command.EndsAt <= command.StartsAt) throw Error("learning_license_period_invalid", "License end must follow its start."); var business = await db.BusinessLines.Where(item => item.Id == command.BusinessLineId) .Select(item => new { item.RegionAccessStrategy, item.RequiresBaseRegion }).SingleAsync(token); var regionIds = command.MarketRegionIds.Distinct().ToArray(); if (regionIds.Length != await db.MarketRegions.CountAsync( item => regionIds.Contains(item.Id) && item.IsActive, token)) throw Error("market_region_not_found", "One or more active market regions were not found."); if (business.RequiresBaseRegion && (!command.BaseMarketRegionId.HasValue || !regionIds.Contains(command.BaseMarketRegionId.Value))) throw Error("learning_license_base_region_required", "This business line requires one base region included in its region set."); if (business.RegionAccessStrategy is ( LearningRegionAccessStrategy.NationalOnly or LearningRegionAccessStrategy.NationalWithStudentTargetRegion or LearningRegionAccessStrategy.NationalWithLicensedRegions) && !command.IncludesNational) throw Error("learning_license_national_required", "This business-line strategy requires national content."); var entity = command.Id.HasValue ? await db.TenantLearningLicenses.SingleOrDefaultAsync( item => item.TenantId == command.TenantId && item.Id == command.Id.Value, token) : null; entity ??= new TenantLearningLicense { TenantId = command.TenantId }; var isNew = db.Entry(entity).State == EntityState.Detached; var previousStatus = entity.Status; entity.BusinessLineId = command.BusinessLineId; entity.IsPrimary = command.IsPrimary; entity.IncludesNational = command.IncludesNational; entity.AllowsAnyTargetRegion = command.AllowsAnyTargetRegion; entity.Status = command.Status; entity.StartsAt = command.StartsAt; entity.EndsAt = command.EndsAt; if (!isNew) entity.Version++; if (isNew) db.TenantLearningLicenses.Add(entity); else await db.TenantLearningLicenseRegions .Where(item => item.TenantId == command.TenantId && item.LicenseId == entity.Id) .ExecuteDeleteAsync(token); db.TenantLearningLicenseRegions.AddRange(regionIds.Select(regionId => new TenantLearningLicenseRegion { TenantId = command.TenantId, LicenseId = entity.Id, MarketRegionId = regionId, IsBaseRegion = regionId == command.BaseMarketRegionId })); var strong = previousStatus == LearningLicenseStatus.Active && command.Status != LearningLicenseStatus.Active; var userIds = await ActiveStudentIdsAsync(db, command.TenantId, token); await BumpVersionsAsync(db, command.TenantId, userIds, content: true, strong, token); await db.SaveChangesAsync(token); await InvalidateAsync(access, command.TenantId, userIds, token); return await ToLicenseItemAsync(db, entity, token); }, cancellationToken); public Task> GetContentSlicesAsync( Guid tenantId, CancellationToken cancellationToken = default) => ExecuteTenantAsync>( tenantId, "List tenant content slices", async (db, _, token) => await db.ContentSlices.AsNoTracking().Where(item => item.TenantId == tenantId) .OrderBy(item => item.ResourceType).ThenBy(item => item.ResourceId) .Select(item => ToSliceItem(item)).ToArrayAsync(token), cancellationToken); public Task UpsertContentSliceAsync( UpsertContentSliceCommand command, CancellationToken cancellationToken = default) => ExecuteTenantAsync(command.TenantId, "Upsert tenant content slice", async (db, access, token) => { await RequireTenantAndBusinessAsync(db, command.TenantId, command.BusinessLineId, token); if ((command.RegionScope == LearningRegionScopeKind.National) != !command.MarketRegionId.HasValue) throw Error("content_slice_region_invalid", "National slices must omit a region and regional slices must include one."); if (command.MarketRegionId.HasValue && !await db.MarketRegions.AnyAsync( item => item.Id == command.MarketRegionId && item.IsActive, token)) throw Error("market_region_not_found", "The active market region was not found."); var entity = command.Id.HasValue ? await db.ContentSlices.SingleOrDefaultAsync( item => item.TenantId == command.TenantId && item.Id == command.Id.Value, token) : null; entity ??= new ContentSlice { TenantId = command.TenantId }; var isNew = db.Entry(entity).State == EntityState.Detached; entity.BusinessLineId = command.BusinessLineId; entity.MarketRegionId = command.MarketRegionId; entity.RegionScope = command.RegionScope; entity.ResourceType = command.ResourceType; entity.ResourceId = command.ResourceId; entity.Status = command.Status; if (!isNew) entity.ContentVersion++; if (isNew) db.ContentSlices.Add(entity); await db.SaveChangesAsync(token); var userIds = await AffectedStudentIdsAsync(db, command.TenantId, entity.Id, token); await BumpVersionsAsync(db, command.TenantId, userIds, content: true, strong: false, token); await db.SaveChangesAsync(token); await InvalidateAsync(access, command.TenantId, userIds, token); return ToSliceItem(entity); }, cancellationToken); public Task> GetProductsAsync( Guid tenantId, CancellationToken cancellationToken = default) => ExecuteTenantAsync>( tenantId, "List tenant learning products", async (db, _, token) => { var products = await db.LearningProducts.AsNoTracking() .Where(item => item.TenantId == tenantId).OrderBy(item => item.Code).ToArrayAsync(token); var scopes = await db.LearningProductScopes.AsNoTracking() .Where(item => item.TenantId == tenantId) .GroupBy(item => item.ProductId) .ToDictionaryAsync(group => group.Key, group => group.Select(item => item.ContentSliceId).ToArray(), token); return products.Select(item => ToProductItem(item, scopes.GetValueOrDefault(item.Id, []))).ToArray(); }, cancellationToken); public Task UpsertProductAsync( UpsertLearningProductCommand command, CancellationToken cancellationToken = default) => ExecuteTenantAsync(command.TenantId, "Upsert tenant learning product", async (db, access, token) => { ValidateCodeAndName(command.Code, command.Name); await RequireTenantAndBusinessAsync(db, command.TenantId, command.BusinessLineId, token); var sliceIds = command.ContentSliceIds.Distinct().ToArray(); if (sliceIds.Length != await db.ContentSlices.CountAsync(item => item.TenantId == command.TenantId && sliceIds.Contains(item.Id) && item.BusinessLineId == command.BusinessLineId, token)) throw Error("content_slice_not_found", "One or more content slices are invalid for this product."); var entity = command.Id.HasValue ? await db.LearningProducts.SingleOrDefaultAsync( item => item.TenantId == command.TenantId && item.Id == command.Id.Value, token) : null; entity ??= new LearningProduct { TenantId = command.TenantId }; var isNew = db.Entry(entity).State == EntityState.Detached; entity.BusinessLineId = command.BusinessLineId; entity.Code = command.Code.Trim().ToLowerInvariant(); entity.Name = command.Name.Trim(); entity.IsActive = command.IsActive; if (isNew) db.LearningProducts.Add(entity); else await db.LearningProductScopes.Where(item => item.TenantId == command.TenantId && item.ProductId == entity.Id).ExecuteDeleteAsync(token); db.LearningProductScopes.AddRange(sliceIds.Select(sliceId => new LearningProductScope { TenantId = command.TenantId, ProductId = entity.Id, ContentSliceOwnerTenantId = command.TenantId, ContentSliceId = sliceId })); await db.SaveChangesAsync(token); var userIds = await db.Entitlements.AsNoTracking().Where(item => item.TenantId == command.TenantId && item.LearningProductId == entity.Id) .Select(item => item.UserId).Distinct().ToArrayAsync(token); await BumpVersionsAsync(db, command.TenantId, userIds, content: false, strong: false, token); await db.SaveChangesAsync(token); await InvalidateAsync(access, command.TenantId, userIds, token); return ToProductItem(entity, sliceIds); }, cancellationToken); private Task ExecuteGlobalAsync( string reason, Func> operation, CancellationToken cancellationToken) => ExecuteAsync(null, true, reason, operation, cancellationToken); private Task ExecuteTenantAsync( Guid tenantId, string reason, Func> operation, CancellationToken cancellationToken) => ExecuteAsync(tenantId, false, reason, operation, cancellationToken); private Task ExecuteAsync( Guid? tenantId, bool global, string reason, Func> operation, CancellationToken cancellationToken) => tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( tenantId, SystemScopeCallerType.Platform, nameof(PlatformLearningAccessAdministrationService), reason, Guid.NewGuid().ToString("N"), global), async (provider, token) => await operation( provider.GetRequiredService(), provider.GetRequiredService(), token), cancellationToken); private static async Task RequireTenantAndBusinessAsync( ILearningAccessPersistence db, Guid tenantId, Guid businessLineId, CancellationToken cancellationToken) { if (!await db.Tenants.AnyAsync(item => item.Id == tenantId && item.Mode == TenantMode.Saas, cancellationToken)) throw Error("tenant_not_found", "The SaaS tenant was not found."); if (!await db.BusinessLines.AnyAsync(item => item.Id == businessLineId && item.IsActive, cancellationToken)) throw Error("business_line_not_found", "The active business line was not found."); } private static async Task ActiveStudentIdsAsync( ILearningAccessPersistence db, Guid tenantId, CancellationToken cancellationToken) => await db.TenantMemberships.AsNoTracking().Where(item => item.TenantId == tenantId && item.Role == TenantRole.Student && item.Status == MembershipStatus.Active) .Select(item => item.UserId).Distinct().ToArrayAsync(cancellationToken); private static async Task AffectedStudentIdsAsync( ILearningAccessPersistence db, Guid tenantId, Guid sliceId, CancellationToken cancellationToken) { var entitled = await ( from scope in db.LearningProductScopes.AsNoTracking() join entitlement in db.Entitlements.AsNoTracking() on new { scope.TenantId, ProductId = (Guid?)scope.ProductId } equals new { entitlement.TenantId, ProductId = entitlement.LearningProductId } where scope.TenantId == tenantId && scope.ContentSliceId == sliceId select entitlement.UserId) .Distinct().ToArrayAsync(cancellationToken); var assigned = await ( from assignment in db.ClassContentAssignments.AsNoTracking() join member in db.TenantClassMembers.AsNoTracking() on new { assignment.TenantId, assignment.ClassId } equals new { member.TenantId, member.ClassId } where assignment.TenantId == tenantId && assignment.ContentSliceId == sliceId && member.MemberType == TenantClassMemberType.Student && member.Status == TenantClassMemberStatus.Active select member.UserId) .Distinct().ToArrayAsync(cancellationToken); return entitled.Concat(assigned).Distinct().ToArray(); } private static async Task BumpVersionsAsync( ILearningAccessPersistence db, Guid tenantId, IReadOnlyCollection userIds, bool content, bool strong, CancellationToken cancellationToken) { if (userIds.Count == 0) return; var versions = await db.LearningAccessVersions .Where(item => item.TenantId == tenantId && userIds.Contains(item.UserId)) .ToDictionaryAsync(item => item.UserId, cancellationToken); foreach (var userId in userIds) { if (!versions.TryGetValue(userId, out var version)) { version = new LearningAccessVersion { TenantId = tenantId, UserId = userId }; db.LearningAccessVersions.Add(version); } version.GrantVersion++; if (content) version.ContentVersion++; if (strong) version.StrongRevocationVersion++; version.UpdatedAt = DateTimeOffset.UtcNow; } } private static async Task InvalidateAsync( ILearningAccessService access, Guid tenantId, IEnumerable userIds, CancellationToken cancellationToken) => await Task.WhenAll(userIds.Distinct().Select(userId => access.InvalidateAsync(tenantId, userId, cancellationToken))); private static async Task ToLicenseItemAsync( ILearningAccessPersistence db, TenantLearningLicense entity, CancellationToken cancellationToken) { var regionIds = await db.TenantLearningLicenseRegions.AsNoTracking() .Where(item => item.TenantId == entity.TenantId && item.LicenseId == entity.Id) .Select(item => item.MarketRegionId).ToArrayAsync(cancellationToken); return new TenantLearningLicenseItem( entity.Id, entity.TenantId, entity.BusinessLineId, entity.IsPrimary, entity.IncludesNational, entity.AllowsAnyTargetRegion, entity.Status, entity.StartsAt, entity.EndsAt, entity.Version, regionIds); } private static ContentSliceItem ToSliceItem(ContentSlice entity) => new( entity.Id, entity.TenantId, entity.BusinessLineId, entity.MarketRegionId, entity.RegionScope, entity.ResourceType, entity.ResourceId, entity.ContentVersion, entity.Status); private static LearningProductItem ToProductItem( LearningProduct entity, IReadOnlyCollection sliceIds) => new( entity.Id, entity.TenantId, entity.BusinessLineId, entity.Code, entity.Name, entity.IsActive, sliceIds); private static void ValidateCodeAndName(string code, string name) { if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name)) throw Error("learning_access_name_required", "Code and name are required."); } private static LearningAccessException Error(string code, string message) => new(code, message); }