267 lines
13 KiB
C#
267 lines
13 KiB
C#
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<LearningAccessSnapshot> GetSnapshotAsync(
|
|
LearningActor actor,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await cache.GetOrSetAsync<LearningAccessSnapshot>(
|
|
CacheKey(actor.TenantId, actor.UserId),
|
|
(_, token) => CompileAsync(actor, token),
|
|
options =>
|
|
{
|
|
options.Duration = SnapshotLifetime;
|
|
options.MemoryCacheDuration = TimeSpan.FromSeconds(5);
|
|
},
|
|
token: cancellationToken);
|
|
}
|
|
|
|
public async Task<LearningResourceAccessDecision> 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<LearningAccessSnapshot> 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}";
|
|
}
|