using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Learning; using Tiku.Domain.Commerce; 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 LearningAccessService( ILearningAccessPersistence persistence, [FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)] IFusionCache cache) : ILearningAccessService { private static readonly TimeSpan SnapshotLifetime = TimeSpan.FromSeconds(30); public async Task GetSnapshotAsync( LearningActor actor, CancellationToken cancellationToken = default) { return await cache.GetOrSetAsync( CacheKey(actor.TenantId, actor.UserId), (_, token) => CompileAsync(actor, token), options => { options.Duration = SnapshotLifetime; options.MemoryCacheDuration = TimeSpan.FromSeconds(5); }, token: cancellationToken); } public async Task EnsureResourceAccessAsync( LearningActor actor, LearningContentResourceType resourceType, Guid resourceId, CancellationToken cancellationToken = default) { var snapshot = await GetSnapshotAsync(actor, cancellationToken); var now = DateTimeOffset.UtcNow; var slice = await persistence.ContentSlices.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.ResourceType == resourceType && item.ResourceId == resourceId && item.Status == ContentSliceStatus.Active) .OrderByDescending(item => item.ContentVersion) .FirstOrDefaultAsync(cancellationToken); if (slice is null) throw new LearningAccessException( "learning_content_unclassified", "The learning resource has no active content classification."); if (!snapshot.ContentSliceIds.Contains(slice.Id)) throw new LearningAccessException( "learning_content_not_entitled", "The current student is not entitled to this learning resource."); var entitlementId = await ( from entitlement in persistence.Entitlements.AsNoTracking() join scope in persistence.LearningProductScopes.AsNoTracking() on new { entitlement.TenantId, ProductId = entitlement.LearningProductId } equals new { scope.TenantId, ProductId = (Guid?)scope.ProductId } where entitlement.TenantId == actor.TenantId && entitlement.UserId == actor.UserId && entitlement.Status == EntitlementStatus.Active && entitlement.StartsAt <= now && (entitlement.ExpiresAt == null || entitlement.ExpiresAt > now) && scope.ContentSliceId == slice.Id select (Guid?)entitlement.Id) .FirstOrDefaultAsync(cancellationToken); if (entitlementId.HasValue) return new LearningResourceAccessDecision( slice.Id, PracticeAccessMode.Package, entitlementId, null, snapshot); var assignmentId = await ( from member in persistence.TenantClassMembers.AsNoTracking() join assignment in persistence.ClassContentAssignments.AsNoTracking() on new { member.TenantId, member.ClassId } equals new { assignment.TenantId, assignment.ClassId } where member.TenantId == actor.TenantId && member.UserId == actor.UserId && member.MemberType == TenantClassMemberType.Student && member.Status == TenantClassMemberStatus.Active && assignment.ContentSliceId == slice.Id && assignment.Status == ClassContentAssignmentStatus.Active && assignment.StartsAt <= now && (assignment.EndsAt == null || assignment.EndsAt > now) select (Guid?)assignment.Id) .FirstOrDefaultAsync(cancellationToken); if (assignmentId.HasValue) return new LearningResourceAccessDecision( slice.Id, PracticeAccessMode.ClassAssignment, null, assignmentId, snapshot); throw new LearningAccessException( "learning_content_grant_expired", "The learning grant expired while access was being evaluated."); } public async Task EnsureStrongRevocationVersionAsync( LearningActor actor, long expectedVersion, CancellationToken cancellationToken = default) { var snapshot = await GetSnapshotAsync(actor, cancellationToken); if (snapshot.StrongRevocationVersion != expectedVersion) throw new LearningAccessException( "practice_access_revoked", "The practice session was revoked and can no longer accept answers."); } public async Task InvalidateAsync( Guid tenantId, Guid userId, CancellationToken cancellationToken = default) { await cache.RemoveAsync(CacheKey(tenantId, userId), token: cancellationToken); } private async Task CompileAsync( LearningActor actor, CancellationToken cancellationToken) { var now = DateTimeOffset.UtcNow; var tenantIsActive = await persistence.Tenants.AsNoTracking().AnyAsync( tenant => tenant.Id == actor.TenantId && tenant.Status == TenantStatus.Active, cancellationToken); if (!tenantIsActive) throw new LearningAccessException("learning_tenant_inactive", "The tenant is not active."); var license = await ( from item in persistence.TenantLearningLicenses.AsNoTracking() join business in persistence.BusinessLines.AsNoTracking() on item.BusinessLineId equals business.Id where item.TenantId == actor.TenantId && item.IsPrimary && item.Status == LearningLicenseStatus.Active && item.StartsAt <= now && (item.EndsAt == null || item.EndsAt > now) && business.IsActive select new { License = item, business.RegionAccessStrategy }) .SingleOrDefaultAsync(cancellationToken); if (license is null) throw new LearningAccessException( "learning_license_required", "The tenant has no active primary learning license."); var licensedRegions = await persistence.TenantLearningLicenseRegions.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.LicenseId == license.License.Id) .Select(item => item.MarketRegionId) .ToArrayAsync(cancellationToken); var usesStudentTarget = license.RegionAccessStrategy == LearningRegionAccessStrategy.NationalWithStudentTargetRegion; var targetRegionId = usesStudentTarget ? await persistence.StudentTargetRegionHistory.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.BusinessLineId == license.License.BusinessLineId && item.IsCurrent) .Select(item => (Guid?)item.MarketRegionId) .SingleOrDefaultAsync(cancellationToken) : null; if (usesStudentTarget && !targetRegionId.HasValue) throw new LearningAccessException( "student_target_region_required", "A target region must be selected before starting practice."); if (targetRegionId.HasValue && !license.License.AllowsAnyTargetRegion && !licensedRegions.Contains(targetRegionId.Value)) throw new LearningAccessException( "student_target_region_not_licensed", "The selected target region is not covered by the tenant license."); var effectiveRegionIds = usesStudentTarget ? targetRegionId.HasValue ? new[] { targetRegionId.Value } : [] : license.RegionAccessStrategy == LearningRegionAccessStrategy.NationalOnly ? [] : licensedRegions; var entitlementRows = await ( from entitlement in persistence.Entitlements.AsNoTracking() join scope in persistence.LearningProductScopes.AsNoTracking() on new { entitlement.TenantId, ProductId = entitlement.LearningProductId } equals new { scope.TenantId, ProductId = (Guid?)scope.ProductId } join slice in persistence.ContentSlices.AsNoTracking() on new { TenantId = scope.ContentSliceOwnerTenantId, Id = scope.ContentSliceId } equals new { slice.TenantId, slice.Id } where entitlement.TenantId == actor.TenantId && entitlement.UserId == actor.UserId && entitlement.Status == EntitlementStatus.Active && entitlement.StartsAt <= now && (entitlement.ExpiresAt == null || entitlement.ExpiresAt > now) && slice.Status == ContentSliceStatus.Active && slice.BusinessLineId == license.License.BusinessLineId && ((slice.RegionScope == LearningRegionScopeKind.National && license.License.IncludesNational) || (slice.MarketRegionId.HasValue && effectiveRegionIds.Contains(slice.MarketRegionId.Value))) select new { entitlement.ExpiresAt, slice.Id }) .ToArrayAsync(cancellationToken); var assignmentRows = await ( from member in persistence.TenantClassMembers.AsNoTracking() join assignment in persistence.ClassContentAssignments.AsNoTracking() on new { member.TenantId, member.ClassId } equals new { assignment.TenantId, assignment.ClassId } join slice in persistence.ContentSlices.AsNoTracking() on new { TenantId = assignment.ContentSliceOwnerTenantId, Id = assignment.ContentSliceId } equals new { slice.TenantId, slice.Id } where member.TenantId == actor.TenantId && member.UserId == actor.UserId && member.MemberType == TenantClassMemberType.Student && member.Status == TenantClassMemberStatus.Active && assignment.Status == ClassContentAssignmentStatus.Active && assignment.StartsAt <= now && (assignment.EndsAt == null || assignment.EndsAt > now) && slice.Status == ContentSliceStatus.Active && slice.BusinessLineId == license.License.BusinessLineId && ((slice.RegionScope == LearningRegionScopeKind.National && license.License.IncludesNational) || (slice.MarketRegionId.HasValue && effectiveRegionIds.Contains(slice.MarketRegionId.Value))) select new { assignment.Id, assignment.EndsAt, SliceId = slice.Id }) .ToArrayAsync(cancellationToken); var version = await persistence.LearningAccessVersions.AsNoTracking() .SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, cancellationToken); var expiryCandidates = entitlementRows.Where(item => item.ExpiresAt.HasValue) .Select(item => item.ExpiresAt!.Value) .Concat(assignmentRows.Where(item => item.EndsAt.HasValue).Select(item => item.EndsAt!.Value)) .Append(license.License.EndsAt ?? now.AddHours(4)) .Append(now.AddMinutes(2)); return new LearningAccessSnapshot( actor.TenantId, actor.UserId, license.License.BusinessLineId, license.RegionAccessStrategy, effectiveRegionIds.ToHashSet(), targetRegionId, entitlementRows.Select(item => item.Id) .Concat(assignmentRows.Select(item => item.SliceId)) .ToHashSet(), assignmentRows.Select(item => item.Id).ToHashSet(), version?.GrantVersion ?? 1, Math.Max(version?.ContentVersion ?? 1, license.License.Version), version?.StrongRevocationVersion ?? 1, expiryCandidates.Min(), now); } internal static string CacheKey(Guid tenantId, Guid userId) => $"learning-access:v1:{tenantId:N}:{userId:N}"; }