312 lines
17 KiB
C#
312 lines
17 KiB
C#
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<EffectiveLearningAccessSnapshot> GetSnapshotAsync(
|
|
LearningActor actor,
|
|
Guid businessLineId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await cache.GetOrSetAsync<EffectiveLearningAccessSnapshot>(
|
|
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<EffectiveLearningAccessSnapshot> 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<ReleaseFact>();
|
|
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<Guid>();
|
|
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<StudentTargetSelectionHistory> selections,
|
|
IReadOnlyCollection<ManifestGrant> 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<T[]> ReadForOwnerAsync<T>(
|
|
Guid ownerTenantId,
|
|
string reason,
|
|
Func<IQuestionBankPersistence, CancellationToken, Task<T[]>> read,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return tenantExecutionScope.ExecuteAsync(
|
|
new SystemScopeRequest(
|
|
ownerTenantId,
|
|
SystemScopeCallerType.PublicQuestionBank,
|
|
nameof(EffectiveLearningAccessService),
|
|
reason,
|
|
Guid.NewGuid().ToString("N")),
|
|
(provider, token) => read(provider.GetRequiredService<IQuestionBankPersistence>(), 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);
|
|
}
|