using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Content; using Tiku.Application.Learning; using Tiku.Application.Security; using Tiku.Domain.Content; using Tiku.Domain.Learning; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Caching; using Tiku.Infrastructure.Persistence; using ZiggyCreatures.Caching.Fusion; namespace Tiku.Infrastructure.Learning; internal sealed class EffectiveLearningAccessService( ILearningAccessPersistence access, ITenantExecutionScope tenantExecutionScope, [FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)] IFusionCache cache) : IEffectiveLearningAccessService { private static readonly TimeSpan SnapshotLifetime = TimeSpan.FromSeconds(30); public async Task GetSnapshotAsync( LearningActor actor, Guid businessLineId, CancellationToken cancellationToken = default) { return await cache.GetOrSetAsync( CacheKey(actor.TenantId, actor.UserId, businessLineId), (_, token) => CompileAsync(actor, businessLineId, token), options => options .SetDuration(SnapshotLifetime) .SetFailSafe(false), token: cancellationToken); } public Task InvalidateAsync( Guid tenantId, Guid userId, Guid businessLineId, CancellationToken cancellationToken = default) { return cache.RemoveAsync(CacheKey(tenantId, userId, businessLineId), token: cancellationToken).AsTask(); } private async Task CompileAsync( LearningActor actor, Guid businessLineId, CancellationToken cancellationToken) { var now = DateTimeOffset.UtcNow; if (!await access.Tenants.AsNoTracking().AnyAsync( item => item.Id == actor.TenantId && item.Status == TenantStatus.Active, cancellationToken)) throw Error("learning_tenant_inactive", "The tenant is not active."); var license = await access.TenantBusinessLicenses.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == actor.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."); var selections = await access.StudentTargetSelectionHistory.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.BusinessLineId == businessLineId && item.IsCurrent && item.EffectiveAt <= now && (item.EndedAt == null || item.EndedAt > now)) .ToArrayAsync(cancellationToken); var primary = selections.SingleOrDefault(item => item.Role == TargetSelectionRole.Primary) ?? throw Error("student_primary_target_required", "A primary exam target must be selected before starting practice."); if (selections.Length > 16) throw Error("student_target_limit_exceeded", "No more than 16 active targets are allowed per business."); var selectedProfileIds = selections.Select(item => item.ExamTargetProfileVersionId).Distinct().ToArray(); if (selectedProfileIds.Length != selections.Length) throw Error("student_target_selection_duplicate", "The active target selection contains duplicates."); var licensedProfileIds = await access.TenantLicensedTargets.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.TenantBusinessLicenseId == license.Id && item.StartsAt <= now && (item.EndsAt == null || item.EndsAt > now) && selectedProfileIds.Contains(item.ExamTargetProfileVersionId)) .Select(item => item.ExamTargetProfileVersionId) .Distinct() .ToArrayAsync(cancellationToken); if (licensedProfileIds.Length != selectedProfileIds.Length) throw Error("student_target_not_licensed", "One or more selected exam targets are not tenant licensed."); var entitlementRows = 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, entitlement.EndsAt, null)) .ToArrayAsync(cancellationToken); var classGrantRows = 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, grant.EndsAt, grant.Id)) .ToArrayAsync(cancellationToken); var grants = entitlementRows.Concat(classGrantRows).DistinctBy(item => item.ManifestVersionId).ToArray(); if (grants.Length == 0) throw Error("student_entitlement_required", "No active product or class grant covers this business."); var manifestIds = grants.Select(item => item.ManifestVersionId).ToArray(); await ValidateManifestTargetsAsync(actor.TenantId, selections, grants, cancellationToken); var manifestReleases = await access.ProductManifestReleases.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && manifestIds.Contains(item.ProductAccessManifestVersionId)) .ToArrayAsync(cancellationToken); if (manifestReleases.Length == 0) throw Error("learning_manifest_empty", "The active access manifests contain no content release."); var releaseIds = manifestReleases.Select(item => item.ContentReleaseId).Distinct().ToArray(); var releases = new List(); foreach (var ownerTenantId in manifestReleases.Select(item => item.ContentOwnerTenantId).Distinct()) releases.AddRange(await ReadForOwnerAsync( ownerTenantId, "Validate explicitly licensed V2 content releases", async (persistence, token) => (await persistence.ContentReleases.AsNoTracking() .Where(item => item.TenantId == ownerTenantId && releaseIds.Contains(item.Id) && item.BusinessLineId == businessLineId && item.Status == ContentReleaseStatus.Published) .Select(item => new { item.TenantId, item.Id, item.ReleaseNo }) .ToArrayAsync(token)) .Select(item => new ReleaseFact(item.TenantId, item.Id, item.ReleaseNo)).ToArray(), cancellationToken)); var validReleaseIds = manifestReleases .Where(link => releases.Any(release => release.TenantId == link.ContentOwnerTenantId && release.Id == link.ContentReleaseId)) .Select(link => link.ContentReleaseId) .Distinct() .ToArray(); if (validReleaseIds.Length != releaseIds.Length) throw Error("learning_manifest_release_invalid", "An access manifest references a missing, unpublished, or cross-business release."); var segmentIds = new List(); foreach (var ownerTenantId in manifestReleases.Select(item => item.ContentOwnerTenantId).Distinct()) segmentIds.AddRange(await ReadForOwnerAsync( ownerTenantId, "Compile V2 audience segments for explicitly licensed releases", (persistence, token) => persistence.AudienceSegmentMembers.AsNoTracking() .Where(item => item.TenantId == ownerTenantId && validReleaseIds.Contains(item.ContentReleaseId) && selectedProfileIds.Contains(item.ExamTargetProfileVersionId)) .Select(item => item.AudienceSegmentId) .Distinct() .ToArrayAsync(token), cancellationToken)); if (segmentIds.Count == 0) throw Error("learning_audience_empty", "No published content matches the selected exam targets."); var version = await access.LearningAccessVersions.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, cancellationToken); var validUntil = grants.Where(item => item.EndsAt.HasValue).Select(item => item.EndsAt!.Value) .Append(license.EndsAt ?? now.AddHours(4)) .Min(); var alternateProfileIds = selections .Where(item => item.Role == TargetSelectionRole.Alternate) .Select(item => item.ExamTargetProfileVersionId).Order().ToArray(); var orderedReleaseIds = validReleaseIds.Order().ToArray(); var orderedSegmentIds = segmentIds.Distinct().Order().ToArray(); var orderedManifestIds = manifestIds.Order().ToArray(); var grantVersion = version?.GrantVersion ?? 1; var contentVersion = Math.Max(version?.ContentVersion ?? 1, license.Version); var strongRevocationVersion = version?.StrongRevocationVersion ?? 1; var projectionId = Guid.NewGuid(); await access.Database.ExecuteSqlInterpolatedAsync($""" INSERT INTO effective_access_projections (id, tenant_id, user_id, business_line_id, primary_profile_version_id, alternate_profile_version_ids, content_release_ids, audience_segment_ids, manifest_version_ids, grant_version, content_version, strong_revocation_version, valid_until, compiled_at) VALUES ({projectionId}, {actor.TenantId}, {actor.UserId}, {businessLineId}, {primary.ExamTargetProfileVersionId}, {alternateProfileIds}, {orderedReleaseIds}, {orderedSegmentIds}, {orderedManifestIds}, {grantVersion}, {contentVersion}, {strongRevocationVersion}, {validUntil}, {now}) ON CONFLICT (tenant_id, user_id, business_line_id) DO UPDATE SET primary_profile_version_id = EXCLUDED.primary_profile_version_id, alternate_profile_version_ids = EXCLUDED.alternate_profile_version_ids, content_release_ids = EXCLUDED.content_release_ids, audience_segment_ids = EXCLUDED.audience_segment_ids, manifest_version_ids = EXCLUDED.manifest_version_ids, grant_version = EXCLUDED.grant_version, content_version = EXCLUDED.content_version, strong_revocation_version = EXCLUDED.strong_revocation_version, valid_until = EXCLUDED.valid_until, compiled_at = EXCLUDED.compiled_at """, cancellationToken); return new EffectiveLearningAccessSnapshot( actor.TenantId, actor.UserId, businessLineId, primary.ExamTargetProfileVersionId, alternateProfileIds.ToHashSet(), orderedReleaseIds.ToHashSet(), orderedSegmentIds.ToHashSet(), orderedManifestIds.ToHashSet(), grantVersion, contentVersion, strongRevocationVersion, validUntil, now); } private async Task ValidateManifestTargetsAsync( Guid tenantId, IReadOnlyCollection selections, IReadOnlyCollection grants, CancellationToken cancellationToken) { var manifestIds = grants.Select(item => item.ManifestVersionId).ToArray(); var targetRows = await access.ProductManifestTargets.AsNoTracking() .Where(item => item.TenantId == tenantId && manifestIds.Contains(item.ProductAccessManifestVersionId)) .ToArrayAsync(cancellationToken); foreach (var selection in selections) { var permitted = targetRows.Any(target => target.ExamTargetProfileVersionId == selection.ExamTargetProfileVersionId && (selection.Role == TargetSelectionRole.Primary ? target.MayBePrimary : target.MayBeAlternate)); if (!permitted) throw Error("student_target_not_entitled", "One or more selected exam targets are not covered by an active manifest."); } var activeLimit = grants.Max(item => item.MaxActiveTargets); var alternateLimit = grants.Max(item => item.MaxAlternateTargets); if (selections.Count > activeLimit || selections.Count(item => item.Role == TargetSelectionRole.Alternate) > alternateLimit) throw Error("student_target_product_limit_exceeded", "The selected primary and alternate targets exceed the product limit."); } private static LearningAccessException Error(string code, string message) => new(code, message); private Task ReadForOwnerAsync( Guid ownerTenantId, string reason, Func> read, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( ownerTenantId, SystemScopeCallerType.PublicQuestionBank, nameof(EffectiveLearningAccessService), reason, Guid.NewGuid().ToString("N")), (provider, token) => read(provider.GetRequiredService(), token), cancellationToken); } internal static string CacheKey(Guid tenantId, Guid userId, Guid businessLineId) => $"learning-access:v2:{tenantId:N}:{userId:N}:{businessLineId:N}"; private sealed record ManifestGrant( Guid ManifestVersionId, int MaxActiveTargets, int MaxAlternateTargets, DateTimeOffset? EndsAt, Guid? ClassAssignmentGrantId); private sealed record ReleaseFact(Guid TenantId, Guid Id, int ReleaseNo); }