using System.Data; using Microsoft.EntityFrameworkCore; using Tiku.Application.Learning; using Tiku.Domain.Content; using Tiku.Domain.Learning; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Learning; internal sealed class StudentLearningTargetService( ILearningAccessPersistence access, IQuestionBankPersistence content, IEffectiveLearningAccessService effectiveAccess) : IStudentLearningTargetService { public async Task GetContextAsync( LearningActor actor, CancellationToken cancellationToken = default) { await EnsureTenantActiveAsync(actor.TenantId, cancellationToken); var now = DateTimeOffset.UtcNow; var licenses = await ( from license in access.TenantBusinessLicenses.AsNoTracking() join business in access.BusinessLines.AsNoTracking() on license.BusinessLineId equals business.Id where license.TenantId == actor.TenantId && license.Status == LicenseLifecycleStatus.Active && license.StartsAt <= now && (license.EndsAt == null || license.EndsAt > now) && business.IsActive orderby business.Name, business.Code select new { License = license, Business = business }) .ToArrayAsync(cancellationToken); var items = new List(licenses.Length); foreach (var row in licenses) { var policy = await GetPolicyAsync(row.Business.Id, cancellationToken); var selections = await CurrentSelectionsAsync(actor, row.Business.Id, cancellationToken); items.Add(new LearningBusinessContextItem( row.Business.Id, row.Business.Code, row.Business.Name, row.License.Id, row.License.EndsAt, selections.SingleOrDefault(item => item.Role == TargetSelectionRole.Primary) ?.ExamTargetProfileVersionId, selections.Where(item => item.Role == TargetSelectionRole.Alternate) .Select(item => item.ExamTargetProfileVersionId).Order().ToArray(), policy.MaxActiveTargets, policy.MaxAlternateTargets, policy.TargetChangeCooldownDays)); } return new LearningContextItem(items); } public async Task> GetTargetsAsync( LearningActor actor, Guid businessLineId, CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; var license = await RequireLicenseAsync(actor.TenantId, businessLineId, now, cancellationToken); var selections = await CurrentSelectionsAsync(actor, businessLineId, cancellationToken); var selectedIds = selections.Select(item => item.ExamTargetProfileVersionId).ToHashSet(); var targetAccess = await LoadTargetAccessAsync(actor, businessLineId, now, cancellationToken); var manifestTargets = targetAccess.Targets; var targetRoles = manifestTargets .GroupBy(item => item.ExamTargetProfileVersionId) .ToDictionary(group => group.Key, group => new { Primary = group.Any(item => item.MayBePrimary), Alternate = group.Any(item => item.MayBeAlternate) }); var licensed = await access.TenantLicensedTargets.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.TenantBusinessLicenseId == license.Id && item.StartsAt <= now && (item.EndsAt == null || item.EndsAt > now)) .ToArrayAsync(cancellationToken); var profileIds = licensed.Select(item => item.ExamTargetProfileVersionId).Distinct().ToArray(); var profiles = await ( from version in content.ExamTargetProfileVersions.AsNoTracking() join profile in content.ExamTargetProfiles.AsNoTracking() on version.ExamTargetProfileId equals profile.Id where profileIds.Contains(version.Id) && profile.BusinessLineId == businessLineId && profile.IsActive && version.Status == ContentDefinitionStatus.Published select new { Version = version, Profile = profile }) .ToArrayAsync(cancellationToken); return profiles.Select(row => { targetRoles.TryGetValue(row.Version.Id, out var role); var selection = selections.SingleOrDefault(item => item.ExamTargetProfileVersionId == row.Version.Id); return new LearningTargetItem( row.Version.Id, row.Profile.Code, row.Version.DisplayName, row.Version.ExamYear, licensed.Single(item => item.ExamTargetProfileVersionId == row.Version.Id).IsBaseTarget, role?.Primary == true, role?.Alternate == true, selection?.Role == TargetSelectionRole.Primary, selection?.Role == TargetSelectionRole.Alternate); }).Where(item => selectedIds.Contains(item.ExamTargetProfileVersionId) || item.MayBePrimary || item.MayBeAlternate) .OrderByDescending(item => item.ExamYear) .ThenBy(item => item.DisplayName) .ToArray(); } public async Task ChangeTargetsAsync( LearningActor actor, Guid businessLineId, ChangeLearningTargetsCommand command, CancellationToken cancellationToken = default) { return await ChangeTargetsCoreAsync( actor, businessLineId, command, actor.UserId, "student_self_service", true, cancellationToken); } public async Task OverrideTargetsAsync( LearningActor student, Guid businessLineId, ChangeLearningTargetsCommand command, Guid changedBy, string reason, CancellationToken cancellationToken = default) { if (changedBy == Guid.Empty || string.IsNullOrWhiteSpace(reason)) throw Error("student_target_override_reason_required", "An administrator and a non-empty reason are required for a target override."); return await ChangeTargetsCoreAsync( student, businessLineId, command, changedBy, reason.Trim(), false, cancellationToken); } private async Task ChangeTargetsCoreAsync( LearningActor actor, Guid businessLineId, ChangeLearningTargetsCommand command, Guid changedBy, string reason, bool enforceCooldown, CancellationToken cancellationToken) { var alternateIds = command.AlternateProfileVersionIds.Distinct().ToArray(); if (alternateIds.Length != command.AlternateProfileVersionIds.Count || alternateIds.Contains(command.PrimaryProfileVersionId)) throw Error("student_target_selection_duplicate", "Primary and alternate targets must be unique."); var now = DateTimeOffset.UtcNow; await using var transaction = await access.Database.BeginTransactionAsync( IsolationLevel.ReadCommitted, cancellationToken); var lockKey = $"student-target:{actor.TenantId:N}:{actor.UserId:N}:{businessLineId:N}"; await access.Database.ExecuteSqlInterpolatedAsync( $"SELECT pg_advisory_xact_lock(hashtextextended({lockKey}, 0))", cancellationToken); var license = await RequireLicenseAsync(actor.TenantId, businessLineId, now, cancellationToken); var policy = await GetPolicyAsync(businessLineId, cancellationToken); if (policy.MaxPrimaryTargets < 1 || 1 + alternateIds.Length > Math.Min(16, policy.MaxActiveTargets) || alternateIds.Length > policy.MaxAlternateTargets) throw Error("student_target_policy_limit_exceeded", "The requested primary and alternate targets exceed the business policy."); var requestedIds = alternateIds.Append(command.PrimaryProfileVersionId).ToArray(); var licensedIds = await access.TenantLicensedTargets.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.TenantBusinessLicenseId == license.Id && item.StartsAt <= now && (item.EndsAt == null || item.EndsAt > now) && requestedIds.Contains(item.ExamTargetProfileVersionId)) .Select(item => item.ExamTargetProfileVersionId) .Distinct() .ToArrayAsync(cancellationToken); if (licensedIds.Length != requestedIds.Length) throw Error("student_target_not_licensed", "One or more requested targets are not tenant licensed."); var targetAccess = await LoadTargetAccessAsync(actor, businessLineId, now, cancellationToken); var entitledTargets = targetAccess.Targets; if (1 + alternateIds.Length > targetAccess.MaxActiveTargets || alternateIds.Length > targetAccess.MaxAlternateTargets) throw Error("student_target_product_limit_exceeded", "The requested primary and alternate targets exceed the active product limit."); if (!entitledTargets.Any(item => item.ExamTargetProfileVersionId == command.PrimaryProfileVersionId && item.MayBePrimary) || alternateIds.Any(id => !entitledTargets.Any(item => item.ExamTargetProfileVersionId == id && item.MayBeAlternate))) throw Error("student_target_not_entitled", "One or more requested targets are not product entitled."); var current = await access.StudentTargetSelectionHistory .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.BusinessLineId == businessLineId && item.IsCurrent) .ToArrayAsync(cancellationToken); if (SameSelection(current, command.PrimaryProfileVersionId, alternateIds)) { await transaction.CommitAsync(cancellationToken); var changedAt = current.Max(item => item.EffectiveAt); return Result(businessLineId, command.PrimaryProfileVersionId, alternateIds, changedAt, policy); } var lastChangeAt = current.Length == 0 ? await access.StudentTargetSelectionHistory.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.BusinessLineId == businessLineId) .Select(item => (DateTimeOffset?)item.EffectiveAt) .MaxAsync(cancellationToken) : current.Max(item => item.EffectiveAt); var nextAllowedAt = lastChangeAt?.AddDays(policy.TargetChangeCooldownDays); if (enforceCooldown && nextAllowedAt > now) throw Error("student_target_change_cooldown", $"Targets cannot be changed before {nextAllowedAt:O}."); foreach (var selection in current) { selection.IsCurrent = false; selection.EndedAt = now; selection.UpdatedAt = now; } var primarySource = ResolveSelectionSource( targetAccess, command.PrimaryProfileVersionId, TargetSelectionRole.Primary); access.StudentTargetSelectionHistory.Add(NewSelection( actor, businessLineId, command.PrimaryProfileVersionId, TargetSelectionRole.Primary, primarySource, changedBy, reason, now)); foreach (var alternateId in alternateIds) { var alternateSource = ResolveSelectionSource( targetAccess, alternateId, TargetSelectionRole.Alternate); access.StudentTargetSelectionHistory.Add(NewSelection( actor, businessLineId, alternateId, TargetSelectionRole.Alternate, alternateSource, changedBy, reason, now)); } var version = await access.LearningAccessVersions.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, cancellationToken); version ??= new LearningAccessVersion { TenantId = actor.TenantId, UserId = actor.UserId }; if (access.Entry(version).State == EntityState.Detached) access.LearningAccessVersions.Add(version); version.GrantVersion++; version.UpdatedAt = now; await access.EffectiveAccessProjections .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.BusinessLineId == businessLineId) .ExecuteDeleteAsync(cancellationToken); await access.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); await effectiveAccess.InvalidateAsync(actor.TenantId, actor.UserId, businessLineId, cancellationToken); return Result(businessLineId, command.PrimaryProfileVersionId, alternateIds, now, policy); } private async Task LoadTargetAccessAsync( LearningActor actor, Guid businessLineId, DateTimeOffset now, CancellationToken cancellationToken) { var entitlementManifests = await ( from entitlement in access.StudentEntitlementsV2.AsNoTracking() join product in access.LearningProducts.AsNoTracking() on new { entitlement.TenantId, Id = entitlement.LearningProductId } equals new { product.TenantId, product.Id } join manifest in access.ProductAccessManifestVersions.AsNoTracking() on new { entitlement.TenantId, Id = entitlement.ProductAccessManifestVersionId } equals new { manifest.TenantId, manifest.Id } where entitlement.TenantId == actor.TenantId && entitlement.UserId == actor.UserId && entitlement.Status == StudentEntitlementStatus.Active && entitlement.StartsAt <= now && (entitlement.EndsAt == null || entitlement.EndsAt > now) && product.BusinessLineId == businessLineId && product.IsActive && manifest.Status == AccessManifestStatus.Published select new ManifestGrant( manifest.Id, manifest.MaxActiveTargets, manifest.MaxAlternateTargets, "student_entitlement", entitlement.Id)) .ToArrayAsync(cancellationToken); var classManifests = await ( from member in access.TenantClassMembers.AsNoTracking() join grant in access.ClassAssignmentGrants.AsNoTracking() on new { member.TenantId, member.ClassId } equals new { grant.TenantId, grant.ClassId } join manifest in access.ProductAccessManifestVersions.AsNoTracking() on new { grant.TenantId, Id = grant.ProductAccessManifestVersionId } equals new { manifest.TenantId, manifest.Id } join definition in access.ProductAccessManifests.AsNoTracking() on new { manifest.TenantId, Id = manifest.ProductAccessManifestId } equals new { definition.TenantId, definition.Id } join product in access.LearningProducts.AsNoTracking() on new { definition.TenantId, Id = definition.LearningProductId } equals new { product.TenantId, product.Id } where member.TenantId == actor.TenantId && member.UserId == actor.UserId && member.MemberType == TenantClassMemberType.Student && member.Status == TenantClassMemberStatus.Active && grant.Status == ClassAssignmentGrantStatus.Active && grant.StartsAt <= now && (grant.EndsAt == null || grant.EndsAt > now) && manifest.Status == AccessManifestStatus.Published && product.BusinessLineId == businessLineId && product.IsActive select new ManifestGrant( manifest.Id, manifest.MaxActiveTargets, manifest.MaxAlternateTargets, "class_assignment_grant", grant.Id)) .ToArrayAsync(cancellationToken); var manifests = entitlementManifests.Concat(classManifests) .DistinctBy(item => new { item.ManifestVersionId, item.SourceType, item.SourceId }) .ToArray(); if (manifests.Length == 0) return new TargetAccess([], [], 0, 0); var manifestIds = manifests.Select(item => item.ManifestVersionId).ToArray(); var targets = await access.ProductManifestTargets.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && manifestIds.Contains(item.ProductAccessManifestVersionId)) .ToArrayAsync(cancellationToken); return new TargetAccess( targets, manifests, manifests.Max(item => item.MaxActiveTargets), manifests.Max(item => item.MaxAlternateTargets)); } private async Task RequireLicenseAsync( Guid tenantId, Guid businessLineId, DateTimeOffset now, CancellationToken cancellationToken) { await EnsureTenantActiveAsync(tenantId, cancellationToken); return await access.TenantBusinessLicenses.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.BusinessLineId == businessLineId && item.Status == LicenseLifecycleStatus.Active && item.StartsAt <= now && (item.EndsAt == null || item.EndsAt > now), cancellationToken) ?? throw Error("learning_license_required", "The tenant has no active license for the requested business."); } private async Task EnsureTenantActiveAsync(Guid tenantId, CancellationToken cancellationToken) { if (!await access.Tenants.AsNoTracking().AnyAsync( item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken)) throw Error("learning_tenant_inactive", "The tenant is not active."); } private async Task GetPolicyAsync( Guid businessLineId, CancellationToken cancellationToken) { return await ( from policy in content.BusinessTargetPolicies.AsNoTracking() join version in content.BusinessTargetPolicyVersions.AsNoTracking() on new { PolicyId = policy.Id, VersionId = policy.CurrentVersionId } equals new { PolicyId = version.BusinessTargetPolicyId, VersionId = (Guid?)version.Id } where policy.BusinessLineId == businessLineId && policy.IsActive && version.Status == ContentDefinitionStatus.Published select version) .SingleOrDefaultAsync(cancellationToken) ?? throw Error("business_target_policy_required", "The business has no published target policy."); } private Task CurrentSelectionsAsync( LearningActor actor, Guid businessLineId, CancellationToken cancellationToken) { return access.StudentTargetSelectionHistory.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.BusinessLineId == businessLineId && item.IsCurrent) .ToArrayAsync(cancellationToken); } private static bool SameSelection( IReadOnlyCollection current, Guid primaryId, IReadOnlyCollection alternateIds) { return current.Count(item => item.Role == TargetSelectionRole.Primary) == 1 && current.Single(item => item.Role == TargetSelectionRole.Primary).ExamTargetProfileVersionId == primaryId && current.Where(item => item.Role == TargetSelectionRole.Alternate) .Select(item => item.ExamTargetProfileVersionId).ToHashSet().SetEquals(alternateIds); } private static StudentTargetSelectionHistory NewSelection( LearningActor actor, Guid businessLineId, Guid profileVersionId, TargetSelectionRole role, SelectionSource source, Guid changedBy, string reason, DateTimeOffset now) { return new StudentTargetSelectionHistory { TenantId = actor.TenantId, UserId = actor.UserId, BusinessLineId = businessLineId, ExamTargetProfileVersionId = profileVersionId, Role = role, EntitlementId = source.SourceType == "student_entitlement" ? source.SourceId : null, SourceType = source.SourceType, SourceId = source.SourceId, EffectiveAt = now, IsCurrent = true, ChangedBy = changedBy, Reason = reason }; } private static LearningTargetSelectionItem Result( Guid businessLineId, Guid primaryId, IReadOnlyCollection alternateIds, DateTimeOffset changedAt, BusinessTargetPolicyVersion policy) { return new LearningTargetSelectionItem( businessLineId, primaryId, alternateIds.Order().ToArray(), changedAt, policy.TargetChangeCooldownDays == 0 ? changedAt : changedAt.AddDays(policy.TargetChangeCooldownDays)); } private static LearningAccessException Error(string code, string message) => new(code, message); private static SelectionSource ResolveSelectionSource( TargetAccess access, Guid profileVersionId, TargetSelectionRole role) { var manifestIds = access.Targets .Where(item => item.ExamTargetProfileVersionId == profileVersionId && (role == TargetSelectionRole.Primary ? item.MayBePrimary : item.MayBeAlternate)) .Select(item => item.ProductAccessManifestVersionId) .ToHashSet(); var grant = access.Grants .Where(item => manifestIds.Contains(item.ManifestVersionId)) .OrderBy(item => item.SourceType == "student_entitlement" ? 0 : 1) .ThenBy(item => item.SourceId) .FirstOrDefault() ?? throw Error( "student_target_source_missing", "The selected target has no auditable active entitlement source."); return new SelectionSource(grant.SourceType, grant.SourceId); } private sealed record ManifestGrant( Guid ManifestVersionId, int MaxActiveTargets, int MaxAlternateTargets, string SourceType, Guid SourceId); private sealed record SelectionSource(string SourceType, Guid SourceId); private sealed record TargetAccess( ProductManifestTarget[] Targets, ManifestGrant[] Grants, int MaxActiveTargets, int MaxAlternateTargets); }