using Microsoft.EntityFrameworkCore; using Tiku.Application.Learning; using Tiku.Domain.Learning; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Learning; internal sealed class LearningAccessAdministrationService( ILearningAccessPersistence persistence, ILearningAccessService accessService) : ILearningAccessAdministrationService { public async Task GetTargetRegionAsync( LearningActor actor, CancellationToken cancellationToken = default) { var business = await ( from license in persistence.TenantLearningLicenses.AsNoTracking() join item in persistence.BusinessLines.AsNoTracking() on license.BusinessLineId equals item.Id where license.TenantId == actor.TenantId && license.IsPrimary select new { BusinessLineId = item.Id, item.TargetRegionCooldownDays }) .FirstOrDefaultAsync(cancellationToken); if (business is null) return null; return await ( from history in persistence.StudentTargetRegionHistory.AsNoTracking() join region in persistence.MarketRegions.AsNoTracking() on history.MarketRegionId equals region.Id where history.TenantId == actor.TenantId && history.UserId == actor.UserId && history.BusinessLineId == business.BusinessLineId && history.IsCurrent select new StudentTargetRegionItem( region.Id, region.Code, region.Name, history.EffectiveAt, history.EffectiveAt.AddDays(business.TargetRegionCooldownDays))) .SingleOrDefaultAsync(cancellationToken); } public async Task ChangeTargetRegionAsync( LearningActor actor, ChangeStudentTargetRegionCommand command, CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; var license = await ( from item in persistence.TenantLearningLicenses join business in persistence.BusinessLines on item.BusinessLineId equals business.Id where item.TenantId == actor.TenantId && item.IsPrimary && item.Status == LearningLicenseStatus.Active select new { License = item, business.RegionAccessStrategy, business.TargetRegionCooldownDays }) .SingleOrDefaultAsync(cancellationToken); if (license is null || license.RegionAccessStrategy != LearningRegionAccessStrategy.NationalWithStudentTargetRegion) throw new LearningAccessException( "student_target_region_not_supported", "The tenant business does not use personal target regions."); var region = await persistence.MarketRegions.AsNoTracking() .SingleOrDefaultAsync(item => item.Id == command.MarketRegionId && item.IsActive, cancellationToken) ?? throw new LearningAccessException("market_region_not_found", "The target region was not found."); var regionLicensed = license.License.AllowsAnyTargetRegion || await persistence.TenantLearningLicenseRegions.AsNoTracking().AnyAsync( item => item.TenantId == actor.TenantId && item.LicenseId == license.License.Id && item.MarketRegionId == region.Id, cancellationToken); if (!regionLicensed) throw new LearningAccessException( "student_target_region_not_licensed", "The selected target region is not covered by the tenant license."); await using var transaction = await persistence.Database.BeginTransactionAsync(cancellationToken); var current = await persistence.StudentTargetRegionHistory.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.BusinessLineId == license.License.BusinessLineId && item.IsCurrent, cancellationToken); if (current?.MarketRegionId == region.Id) { await transaction.RollbackAsync(cancellationToken); return new StudentTargetRegionItem( region.Id, region.Code, region.Name, current.EffectiveAt, current.EffectiveAt.AddDays(license.TargetRegionCooldownDays)); } if (current is not null && current.EffectiveAt.AddDays(license.TargetRegionCooldownDays) > now) throw new LearningAccessException( "student_target_region_cooldown", $"The target region cannot be changed before {current.EffectiveAt.AddDays(license.TargetRegionCooldownDays):O}."); if (current is not null) { current.IsCurrent = false; current.EndedAt = now; } persistence.StudentTargetRegionHistory.Add(new StudentTargetRegionHistory { TenantId = actor.TenantId, UserId = actor.UserId, BusinessLineId = license.License.BusinessLineId, MarketRegionId = region.Id, EffectiveAt = now, IsCurrent = true, ChangedBy = actor.UserId, Reason = "student_self_service" }); await BumpVersionAsync(actor.TenantId, actor.UserId, false, cancellationToken); await persistence.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); await accessService.InvalidateAsync(actor.TenantId, actor.UserId, cancellationToken); return new StudentTargetRegionItem( region.Id, region.Code, region.Name, now, now.AddDays(license.TargetRegionCooldownDays)); } public async Task OverrideTargetRegionAsync( Guid tenantId, OverrideStudentTargetRegionCommand command, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(command.Reason)) throw new LearningAccessException( "student_target_region_override_reason_required", "An override reason is required."); var studentExists = await persistence.TenantMemberships.AsNoTracking().AnyAsync( item => item.TenantId == tenantId && item.UserId == command.UserId && item.Role == TenantRole.Student && item.Status == MembershipStatus.Active, cancellationToken); if (!studentExists) throw new LearningAccessException("student_not_found", "The active tenant student was not found."); var license = await ( from item in persistence.TenantLearningLicenses join business in persistence.BusinessLines on item.BusinessLineId equals business.Id where item.TenantId == tenantId && item.IsPrimary && item.Status == LearningLicenseStatus.Active select new { License = item, business.RegionAccessStrategy, business.TargetRegionCooldownDays }) .SingleOrDefaultAsync(cancellationToken); if (license is null || license.RegionAccessStrategy != LearningRegionAccessStrategy.NationalWithStudentTargetRegion) throw new LearningAccessException( "student_target_region_not_supported", "The tenant business does not use personal target regions."); var region = await persistence.MarketRegions.AsNoTracking() .SingleOrDefaultAsync(item => item.Id == command.MarketRegionId && item.IsActive, cancellationToken) ?? throw new LearningAccessException("market_region_not_found", "The target region was not found."); var regionLicensed = license.License.AllowsAnyTargetRegion || await persistence.TenantLearningLicenseRegions.AsNoTracking().AnyAsync( item => item.TenantId == tenantId && item.LicenseId == license.License.Id && item.MarketRegionId == region.Id, cancellationToken); if (!regionLicensed) throw new LearningAccessException( "student_target_region_not_licensed", "The selected target region is not covered by the tenant license."); var now = DateTimeOffset.UtcNow; await using var transaction = await persistence.Database.BeginTransactionAsync(cancellationToken); var current = await persistence.StudentTargetRegionHistory.SingleOrDefaultAsync( item => item.TenantId == tenantId && item.UserId == command.UserId && item.BusinessLineId == license.License.BusinessLineId && item.IsCurrent, cancellationToken); if (current?.MarketRegionId != region.Id) { if (current is not null) { current.IsCurrent = false; current.EndedAt = now; } persistence.StudentTargetRegionHistory.Add(new StudentTargetRegionHistory { TenantId = tenantId, UserId = command.UserId, BusinessLineId = license.License.BusinessLineId, MarketRegionId = region.Id, EffectiveAt = now, IsCurrent = true, ChangedBy = command.ChangedBy, Reason = command.Reason.Trim() }); await BumpVersionAsync(tenantId, command.UserId, false, cancellationToken); await persistence.SaveChangesAsync(cancellationToken); } await transaction.CommitAsync(cancellationToken); await accessService.InvalidateAsync(tenantId, command.UserId, cancellationToken); var effectiveAt = current?.MarketRegionId == region.Id ? current.EffectiveAt : now; return new StudentTargetRegionItem( region.Id, region.Code, region.Name, effectiveAt, effectiveAt.AddDays(license.TargetRegionCooldownDays)); } public async Task> GetClassAssignmentsAsync( Guid tenantId, Guid classId, CancellationToken cancellationToken = default) { return await persistence.ClassContentAssignments.AsNoTracking() .Where(item => item.TenantId == tenantId && item.ClassId == classId) .OrderByDescending(item => item.CreatedAt) .Select(item => ToItem(item)) .ToArrayAsync(cancellationToken); } public async Task UpsertClassAssignmentAsync( Guid tenantId, Guid actorUserId, UpsertClassContentAssignmentCommand command, CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; if (command.EndsAt.HasValue && command.EndsAt <= (command.StartsAt ?? now)) throw new LearningAccessException("class_assignment_period_invalid", "Assignment end must follow its start."); var tenantClass = await persistence.TenantClasses.AsNoTracking().AnyAsync( item => item.TenantId == tenantId && item.Id == command.ClassId && item.Status == TenantRecordStatus.Active, cancellationToken); if (!tenantClass) throw new LearningAccessException("class_not_found", "The class was not found."); var slice = await persistence.ContentSlices.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == command.ContentSliceId && item.ResourceType == command.ResourceType && item.ResourceId == command.ResourceId && item.Status == ContentSliceStatus.Active, cancellationToken) ?? throw new LearningAccessException( "content_slice_not_found", "The active content slice was not found."); var license = await persistence.TenantLearningLicenses.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.IsPrimary && item.Status == LearningLicenseStatus.Active && item.BusinessLineId == slice.BusinessLineId, cancellationToken) ?? throw new LearningAccessException( "learning_license_scope_mismatch", "The content is outside the tenant learning license."); if (slice.RegionScope == LearningRegionScopeKind.National && !license.IncludesNational) throw new LearningAccessException( "learning_license_scope_mismatch", "The tenant license does not include national content."); if (slice.MarketRegionId.HasValue && !license.AllowsAnyTargetRegion && !await persistence.TenantLearningLicenseRegions.AsNoTracking().AnyAsync( item => item.TenantId == tenantId && item.LicenseId == license.Id && item.MarketRegionId == slice.MarketRegionId, cancellationToken)) throw new LearningAccessException( "learning_license_scope_mismatch", "The tenant license does not include the content region."); var assignment = command.Id.HasValue ? await persistence.ClassContentAssignments.SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == command.Id.Value, cancellationToken) : null; assignment ??= new ClassContentAssignment { TenantId = tenantId, ClassId = command.ClassId, ContentSliceOwnerTenantId = tenantId, ContentSliceId = slice.Id, CreatedBy = actorUserId }; var isNew = persistence.Entry(assignment).State == EntityState.Detached; assignment.ClassId = command.ClassId; assignment.ContentSliceOwnerTenantId = tenantId; assignment.ContentSliceId = slice.Id; assignment.ResourceType = command.ResourceType; assignment.ResourceId = command.ResourceId; assignment.StartsAt = command.StartsAt ?? now; assignment.EndsAt = command.EndsAt; assignment.Status = ClassContentAssignmentStatus.Active; assignment.RevokedBy = null; assignment.RevokedReason = null; if (isNew) persistence.ClassContentAssignments.Add(assignment); await persistence.SaveChangesAsync(cancellationToken); await InvalidateClassMembersAsync(tenantId, command.ClassId, false, cancellationToken); return ToItem(assignment); } public async Task RevokeClassAssignmentAsync( Guid tenantId, Guid actorUserId, Guid assignmentId, string? reason, CancellationToken cancellationToken = default) { var assignment = await persistence.ClassContentAssignments.SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == assignmentId, cancellationToken) ?? throw new LearningAccessException("class_assignment_not_found", "Assignment was not found."); assignment.Status = ClassContentAssignmentStatus.Revoked; assignment.RevokedBy = actorUserId; assignment.RevokedReason = string.IsNullOrWhiteSpace(reason) ? "revoked_by_teacher" : reason.Trim(); assignment.EndsAt = DateTimeOffset.UtcNow; await persistence.SaveChangesAsync(cancellationToken); await InvalidateClassMembersAsync(tenantId, assignment.ClassId, true, cancellationToken); return ToItem(assignment); } private async Task InvalidateClassMembersAsync( Guid tenantId, Guid classId, bool strongRevocation, CancellationToken cancellationToken) { var userIds = await persistence.TenantClassMembers.AsNoTracking() .Where(item => item.TenantId == tenantId && item.ClassId == classId && item.MemberType == TenantClassMemberType.Student && item.Status == TenantClassMemberStatus.Active) .Select(item => item.UserId) .Distinct() .ToArrayAsync(cancellationToken); var versions = await persistence.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)) { persistence.LearningAccessVersions.Add(new LearningAccessVersion { TenantId = tenantId, UserId = userId, StrongRevocationVersion = strongRevocation ? 2 : 1 }); continue; } version.GrantVersion++; if (strongRevocation) version.StrongRevocationVersion++; version.UpdatedAt = DateTimeOffset.UtcNow; } await persistence.SaveChangesAsync(cancellationToken); await Task.WhenAll(userIds.Select(userId => accessService.InvalidateAsync(tenantId, userId, cancellationToken))); } private async Task BumpVersionAsync( Guid tenantId, Guid userId, bool strongRevocation, CancellationToken cancellationToken) { var version = await persistence.LearningAccessVersions.SingleOrDefaultAsync( item => item.TenantId == tenantId && item.UserId == userId, cancellationToken); if (version is null) { version = new LearningAccessVersion { TenantId = tenantId, UserId = userId, StrongRevocationVersion = strongRevocation ? 2 : 1 }; persistence.LearningAccessVersions.Add(version); } else { version.GrantVersion++; if (strongRevocation) version.StrongRevocationVersion++; version.UpdatedAt = DateTimeOffset.UtcNow; } } private static ClassContentAssignmentItem ToItem(ClassContentAssignment item) => new( item.Id, item.ClassId, item.ContentSliceId, item.ResourceType, item.ResourceId, item.StartsAt, item.EndsAt, item.Status); }