412 lines
22 KiB
C#
412 lines
22 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Tiku.Application.Learning;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Learning;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.Learning;
|
|
|
|
internal sealed class PlatformLearningAccessAdministrationService(
|
|
ITenantExecutionScope tenantExecutionScope) : IPlatformLearningAccessAdministrationService
|
|
{
|
|
public Task<IReadOnlyCollection<LearningDictionaryItem>> GetBusinessLinesAsync(
|
|
CancellationToken cancellationToken = default) =>
|
|
ExecuteGlobalAsync<IReadOnlyCollection<LearningDictionaryItem>>(
|
|
"List learning business lines", async (db, _, token) =>
|
|
await db.BusinessLines.AsNoTracking().OrderBy(item => item.Code)
|
|
.Select(item => new LearningDictionaryItem(
|
|
item.Id, item.Code, item.Name, item.RegionAccessStrategy.ToString(), item.IsActive,
|
|
item.RequiresBaseRegion, item.TargetRegionCooldownDays))
|
|
.ToArrayAsync(token), cancellationToken);
|
|
|
|
public Task<LearningDictionaryItem> UpsertBusinessLineAsync(
|
|
UpsertBusinessLineCommand command,
|
|
CancellationToken cancellationToken = default) =>
|
|
ExecuteGlobalAsync("Upsert learning business line", async (db, _, token) =>
|
|
{
|
|
ValidateCodeAndName(command.Code, command.Name);
|
|
var entity = command.Id.HasValue
|
|
? await db.BusinessLines.SingleOrDefaultAsync(item => item.Id == command.Id.Value, token)
|
|
: null;
|
|
entity ??= new BusinessLine();
|
|
var isNew = db.Entry(entity).State == EntityState.Detached;
|
|
entity.Code = command.Code.Trim().ToLowerInvariant();
|
|
entity.Name = command.Name.Trim();
|
|
if (command.TargetRegionCooldownDays is < 0 or > 365)
|
|
throw Error("target_region_cooldown_invalid", "Target-region cooldown must be between 0 and 365 days.");
|
|
entity.RegionAccessStrategy = command.RegionAccessStrategy;
|
|
entity.RequiresBaseRegion = command.RequiresBaseRegion;
|
|
entity.TargetRegionCooldownDays = command.TargetRegionCooldownDays;
|
|
entity.IsActive = command.IsActive;
|
|
if (isNew) db.BusinessLines.Add(entity);
|
|
await db.SaveChangesAsync(token);
|
|
return new LearningDictionaryItem(
|
|
entity.Id, entity.Code, entity.Name, entity.RegionAccessStrategy.ToString(), entity.IsActive,
|
|
entity.RequiresBaseRegion, entity.TargetRegionCooldownDays);
|
|
}, cancellationToken);
|
|
|
|
public Task<IReadOnlyCollection<LearningDictionaryItem>> GetMarketRegionsAsync(
|
|
CancellationToken cancellationToken = default) =>
|
|
ExecuteGlobalAsync<IReadOnlyCollection<LearningDictionaryItem>>(
|
|
"List learning market regions", async (db, _, token) =>
|
|
await db.MarketRegions.AsNoTracking().OrderBy(item => item.Code)
|
|
.Select(item => new LearningDictionaryItem(
|
|
item.Id, item.Code, item.Name, item.ParentCode, item.IsActive))
|
|
.ToArrayAsync(token), cancellationToken);
|
|
|
|
public Task<LearningDictionaryItem> UpsertMarketRegionAsync(
|
|
UpsertMarketRegionCommand command,
|
|
CancellationToken cancellationToken = default) =>
|
|
ExecuteGlobalAsync("Upsert learning market region", async (db, _, token) =>
|
|
{
|
|
ValidateCodeAndName(command.Code, command.Name);
|
|
var entity = command.Id.HasValue
|
|
? await db.MarketRegions.SingleOrDefaultAsync(item => item.Id == command.Id.Value, token)
|
|
: null;
|
|
entity ??= new MarketRegion();
|
|
var isNew = db.Entry(entity).State == EntityState.Detached;
|
|
entity.Code = command.Code.Trim().ToLowerInvariant();
|
|
entity.Name = command.Name.Trim();
|
|
entity.ParentCode = string.IsNullOrWhiteSpace(command.ParentCode)
|
|
? null
|
|
: command.ParentCode.Trim().ToLowerInvariant();
|
|
entity.IsActive = command.IsActive;
|
|
if (isNew) db.MarketRegions.Add(entity);
|
|
await db.SaveChangesAsync(token);
|
|
return new LearningDictionaryItem(
|
|
entity.Id, entity.Code, entity.Name, entity.ParentCode, entity.IsActive);
|
|
}, cancellationToken);
|
|
|
|
public Task<TenantLearningLicenseItem?> GetTenantLicenseAsync(
|
|
Guid tenantId,
|
|
CancellationToken cancellationToken = default) =>
|
|
ExecuteTenantAsync(tenantId, "Read tenant learning license", async (db, _, token) =>
|
|
{
|
|
var license = await db.TenantLearningLicenses.AsNoTracking()
|
|
.Where(item => item.TenantId == tenantId && item.IsPrimary)
|
|
.OrderByDescending(item => item.UpdatedAt)
|
|
.FirstOrDefaultAsync(token);
|
|
return license is null ? null : await ToLicenseItemAsync(db, license, token);
|
|
}, cancellationToken);
|
|
|
|
public Task<TenantLearningLicenseItem> UpsertTenantLicenseAsync(
|
|
UpsertTenantLearningLicenseCommand command,
|
|
CancellationToken cancellationToken = default) =>
|
|
ExecuteTenantAsync(command.TenantId, "Upsert tenant learning license", async (db, access, token) =>
|
|
{
|
|
await RequireTenantAndBusinessAsync(db, command.TenantId, command.BusinessLineId, token);
|
|
if (command.EndsAt.HasValue && command.EndsAt <= command.StartsAt)
|
|
throw Error("learning_license_period_invalid", "License end must follow its start.");
|
|
var business = await db.BusinessLines.Where(item => item.Id == command.BusinessLineId)
|
|
.Select(item => new
|
|
{
|
|
item.RegionAccessStrategy,
|
|
item.RequiresBaseRegion
|
|
}).SingleAsync(token);
|
|
var regionIds = command.MarketRegionIds.Distinct().ToArray();
|
|
if (regionIds.Length != await db.MarketRegions.CountAsync(
|
|
item => regionIds.Contains(item.Id) && item.IsActive, token))
|
|
throw Error("market_region_not_found", "One or more active market regions were not found.");
|
|
if (business.RequiresBaseRegion &&
|
|
(!command.BaseMarketRegionId.HasValue || !regionIds.Contains(command.BaseMarketRegionId.Value)))
|
|
throw Error("learning_license_base_region_required",
|
|
"This business line requires one base region included in its region set.");
|
|
if (business.RegionAccessStrategy is (
|
|
LearningRegionAccessStrategy.NationalOnly or
|
|
LearningRegionAccessStrategy.NationalWithStudentTargetRegion or
|
|
LearningRegionAccessStrategy.NationalWithLicensedRegions) &&
|
|
!command.IncludesNational)
|
|
throw Error("learning_license_national_required",
|
|
"This business-line strategy requires national content.");
|
|
|
|
var entity = command.Id.HasValue
|
|
? await db.TenantLearningLicenses.SingleOrDefaultAsync(
|
|
item => item.TenantId == command.TenantId && item.Id == command.Id.Value, token)
|
|
: null;
|
|
entity ??= new TenantLearningLicense { TenantId = command.TenantId };
|
|
var isNew = db.Entry(entity).State == EntityState.Detached;
|
|
var previousStatus = entity.Status;
|
|
entity.BusinessLineId = command.BusinessLineId;
|
|
entity.IsPrimary = command.IsPrimary;
|
|
entity.IncludesNational = command.IncludesNational;
|
|
entity.AllowsAnyTargetRegion = command.AllowsAnyTargetRegion;
|
|
entity.Status = command.Status;
|
|
entity.StartsAt = command.StartsAt;
|
|
entity.EndsAt = command.EndsAt;
|
|
if (!isNew) entity.Version++;
|
|
if (isNew) db.TenantLearningLicenses.Add(entity);
|
|
else
|
|
await db.TenantLearningLicenseRegions
|
|
.Where(item => item.TenantId == command.TenantId && item.LicenseId == entity.Id)
|
|
.ExecuteDeleteAsync(token);
|
|
db.TenantLearningLicenseRegions.AddRange(regionIds.Select(regionId =>
|
|
new TenantLearningLicenseRegion
|
|
{
|
|
TenantId = command.TenantId,
|
|
LicenseId = entity.Id,
|
|
MarketRegionId = regionId,
|
|
IsBaseRegion = regionId == command.BaseMarketRegionId
|
|
}));
|
|
var strong = previousStatus == LearningLicenseStatus.Active &&
|
|
command.Status != LearningLicenseStatus.Active;
|
|
var userIds = await ActiveStudentIdsAsync(db, command.TenantId, token);
|
|
await BumpVersionsAsync(db, command.TenantId, userIds, content: true, strong, token);
|
|
await db.SaveChangesAsync(token);
|
|
await InvalidateAsync(access, command.TenantId, userIds, token);
|
|
return await ToLicenseItemAsync(db, entity, token);
|
|
}, cancellationToken);
|
|
|
|
public Task<IReadOnlyCollection<ContentSliceItem>> GetContentSlicesAsync(
|
|
Guid tenantId,
|
|
CancellationToken cancellationToken = default) =>
|
|
ExecuteTenantAsync<IReadOnlyCollection<ContentSliceItem>>(
|
|
tenantId, "List tenant content slices", async (db, _, token) =>
|
|
await db.ContentSlices.AsNoTracking().Where(item => item.TenantId == tenantId)
|
|
.OrderBy(item => item.ResourceType).ThenBy(item => item.ResourceId)
|
|
.Select(item => ToSliceItem(item)).ToArrayAsync(token), cancellationToken);
|
|
|
|
public Task<ContentSliceItem> UpsertContentSliceAsync(
|
|
UpsertContentSliceCommand command,
|
|
CancellationToken cancellationToken = default) =>
|
|
ExecuteTenantAsync(command.TenantId, "Upsert tenant content slice", async (db, access, token) =>
|
|
{
|
|
await RequireTenantAndBusinessAsync(db, command.TenantId, command.BusinessLineId, token);
|
|
if ((command.RegionScope == LearningRegionScopeKind.National) != !command.MarketRegionId.HasValue)
|
|
throw Error("content_slice_region_invalid",
|
|
"National slices must omit a region and regional slices must include one.");
|
|
if (command.MarketRegionId.HasValue && !await db.MarketRegions.AnyAsync(
|
|
item => item.Id == command.MarketRegionId && item.IsActive, token))
|
|
throw Error("market_region_not_found", "The active market region was not found.");
|
|
var entity = command.Id.HasValue
|
|
? await db.ContentSlices.SingleOrDefaultAsync(
|
|
item => item.TenantId == command.TenantId && item.Id == command.Id.Value, token)
|
|
: null;
|
|
entity ??= new ContentSlice { TenantId = command.TenantId };
|
|
var isNew = db.Entry(entity).State == EntityState.Detached;
|
|
entity.BusinessLineId = command.BusinessLineId;
|
|
entity.MarketRegionId = command.MarketRegionId;
|
|
entity.RegionScope = command.RegionScope;
|
|
entity.ResourceType = command.ResourceType;
|
|
entity.ResourceId = command.ResourceId;
|
|
entity.Status = command.Status;
|
|
if (!isNew) entity.ContentVersion++;
|
|
if (isNew) db.ContentSlices.Add(entity);
|
|
await db.SaveChangesAsync(token);
|
|
var userIds = await AffectedStudentIdsAsync(db, command.TenantId, entity.Id, token);
|
|
await BumpVersionsAsync(db, command.TenantId, userIds, content: true, strong: false, token);
|
|
await db.SaveChangesAsync(token);
|
|
await InvalidateAsync(access, command.TenantId, userIds, token);
|
|
return ToSliceItem(entity);
|
|
}, cancellationToken);
|
|
|
|
public Task<IReadOnlyCollection<LearningProductItem>> GetProductsAsync(
|
|
Guid tenantId,
|
|
CancellationToken cancellationToken = default) =>
|
|
ExecuteTenantAsync<IReadOnlyCollection<LearningProductItem>>(
|
|
tenantId, "List tenant learning products", async (db, _, token) =>
|
|
{
|
|
var products = await db.LearningProducts.AsNoTracking()
|
|
.Where(item => item.TenantId == tenantId).OrderBy(item => item.Code).ToArrayAsync(token);
|
|
var scopes = await db.LearningProductScopes.AsNoTracking()
|
|
.Where(item => item.TenantId == tenantId)
|
|
.GroupBy(item => item.ProductId)
|
|
.ToDictionaryAsync(group => group.Key, group => group.Select(item => item.ContentSliceId).ToArray(), token);
|
|
return products.Select(item => ToProductItem(item, scopes.GetValueOrDefault(item.Id, []))).ToArray();
|
|
}, cancellationToken);
|
|
|
|
public Task<LearningProductItem> UpsertProductAsync(
|
|
UpsertLearningProductCommand command,
|
|
CancellationToken cancellationToken = default) =>
|
|
ExecuteTenantAsync(command.TenantId, "Upsert tenant learning product", async (db, access, token) =>
|
|
{
|
|
ValidateCodeAndName(command.Code, command.Name);
|
|
await RequireTenantAndBusinessAsync(db, command.TenantId, command.BusinessLineId, token);
|
|
var sliceIds = command.ContentSliceIds.Distinct().ToArray();
|
|
if (sliceIds.Length != await db.ContentSlices.CountAsync(item =>
|
|
item.TenantId == command.TenantId &&
|
|
sliceIds.Contains(item.Id) &&
|
|
item.BusinessLineId == command.BusinessLineId, token))
|
|
throw Error("content_slice_not_found", "One or more content slices are invalid for this product.");
|
|
var entity = command.Id.HasValue
|
|
? await db.LearningProducts.SingleOrDefaultAsync(
|
|
item => item.TenantId == command.TenantId && item.Id == command.Id.Value, token)
|
|
: null;
|
|
entity ??= new LearningProduct { TenantId = command.TenantId };
|
|
var isNew = db.Entry(entity).State == EntityState.Detached;
|
|
entity.BusinessLineId = command.BusinessLineId;
|
|
entity.Code = command.Code.Trim().ToLowerInvariant();
|
|
entity.Name = command.Name.Trim();
|
|
entity.IsActive = command.IsActive;
|
|
if (isNew) db.LearningProducts.Add(entity);
|
|
else
|
|
await db.LearningProductScopes.Where(item =>
|
|
item.TenantId == command.TenantId && item.ProductId == entity.Id).ExecuteDeleteAsync(token);
|
|
db.LearningProductScopes.AddRange(sliceIds.Select(sliceId => new LearningProductScope
|
|
{
|
|
TenantId = command.TenantId,
|
|
ProductId = entity.Id,
|
|
ContentSliceOwnerTenantId = command.TenantId,
|
|
ContentSliceId = sliceId
|
|
}));
|
|
await db.SaveChangesAsync(token);
|
|
var userIds = await db.Entitlements.AsNoTracking().Where(item =>
|
|
item.TenantId == command.TenantId && item.LearningProductId == entity.Id)
|
|
.Select(item => item.UserId).Distinct().ToArrayAsync(token);
|
|
await BumpVersionsAsync(db, command.TenantId, userIds, content: false, strong: false, token);
|
|
await db.SaveChangesAsync(token);
|
|
await InvalidateAsync(access, command.TenantId, userIds, token);
|
|
return ToProductItem(entity, sliceIds);
|
|
}, cancellationToken);
|
|
|
|
private Task<TResult> ExecuteGlobalAsync<TResult>(
|
|
string reason,
|
|
Func<ILearningAccessPersistence, ILearningAccessService, CancellationToken, Task<TResult>> operation,
|
|
CancellationToken cancellationToken) =>
|
|
ExecuteAsync(null, true, reason, operation, cancellationToken);
|
|
|
|
private Task<TResult> ExecuteTenantAsync<TResult>(
|
|
Guid tenantId,
|
|
string reason,
|
|
Func<ILearningAccessPersistence, ILearningAccessService, CancellationToken, Task<TResult>> operation,
|
|
CancellationToken cancellationToken) =>
|
|
ExecuteAsync(tenantId, false, reason, operation, cancellationToken);
|
|
|
|
private Task<TResult> ExecuteAsync<TResult>(
|
|
Guid? tenantId,
|
|
bool global,
|
|
string reason,
|
|
Func<ILearningAccessPersistence, ILearningAccessService, CancellationToken, Task<TResult>> operation,
|
|
CancellationToken cancellationToken) =>
|
|
tenantExecutionScope.ExecuteAsync(
|
|
new SystemScopeRequest(
|
|
tenantId,
|
|
SystemScopeCallerType.Platform,
|
|
nameof(PlatformLearningAccessAdministrationService),
|
|
reason,
|
|
Guid.NewGuid().ToString("N"),
|
|
global),
|
|
async (provider, token) => await operation(
|
|
provider.GetRequiredService<ILearningAccessPersistence>(),
|
|
provider.GetRequiredService<ILearningAccessService>(), token),
|
|
cancellationToken);
|
|
|
|
private static async Task RequireTenantAndBusinessAsync(
|
|
ILearningAccessPersistence db,
|
|
Guid tenantId,
|
|
Guid businessLineId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!await db.Tenants.AnyAsync(item =>
|
|
item.Id == tenantId && item.Mode == TenantMode.Saas, cancellationToken))
|
|
throw Error("tenant_not_found", "The SaaS tenant was not found.");
|
|
if (!await db.BusinessLines.AnyAsync(item =>
|
|
item.Id == businessLineId && item.IsActive, cancellationToken))
|
|
throw Error("business_line_not_found", "The active business line was not found.");
|
|
}
|
|
|
|
private static async Task<Guid[]> ActiveStudentIdsAsync(
|
|
ILearningAccessPersistence db,
|
|
Guid tenantId,
|
|
CancellationToken cancellationToken) =>
|
|
await db.TenantMemberships.AsNoTracking().Where(item =>
|
|
item.TenantId == tenantId &&
|
|
item.Role == TenantRole.Student &&
|
|
item.Status == MembershipStatus.Active)
|
|
.Select(item => item.UserId).Distinct().ToArrayAsync(cancellationToken);
|
|
|
|
private static async Task<Guid[]> AffectedStudentIdsAsync(
|
|
ILearningAccessPersistence db,
|
|
Guid tenantId,
|
|
Guid sliceId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var entitled = await (
|
|
from scope in db.LearningProductScopes.AsNoTracking()
|
|
join entitlement in db.Entitlements.AsNoTracking()
|
|
on new { scope.TenantId, ProductId = (Guid?)scope.ProductId }
|
|
equals new { entitlement.TenantId, ProductId = entitlement.LearningProductId }
|
|
where scope.TenantId == tenantId && scope.ContentSliceId == sliceId
|
|
select entitlement.UserId)
|
|
.Distinct().ToArrayAsync(cancellationToken);
|
|
var assigned = await (
|
|
from assignment in db.ClassContentAssignments.AsNoTracking()
|
|
join member in db.TenantClassMembers.AsNoTracking()
|
|
on new { assignment.TenantId, assignment.ClassId }
|
|
equals new { member.TenantId, member.ClassId }
|
|
where assignment.TenantId == tenantId &&
|
|
assignment.ContentSliceId == sliceId &&
|
|
member.MemberType == TenantClassMemberType.Student &&
|
|
member.Status == TenantClassMemberStatus.Active
|
|
select member.UserId)
|
|
.Distinct().ToArrayAsync(cancellationToken);
|
|
return entitled.Concat(assigned).Distinct().ToArray();
|
|
}
|
|
|
|
private static async Task BumpVersionsAsync(
|
|
ILearningAccessPersistence db,
|
|
Guid tenantId,
|
|
IReadOnlyCollection<Guid> userIds,
|
|
bool content,
|
|
bool strong,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (userIds.Count == 0) return;
|
|
var versions = await db.LearningAccessVersions
|
|
.Where(item => item.TenantId == tenantId && userIds.Contains(item.UserId))
|
|
.ToDictionaryAsync(item => item.UserId, cancellationToken);
|
|
foreach (var userId in userIds)
|
|
{
|
|
if (!versions.TryGetValue(userId, out var version))
|
|
{
|
|
version = new LearningAccessVersion { TenantId = tenantId, UserId = userId };
|
|
db.LearningAccessVersions.Add(version);
|
|
}
|
|
version.GrantVersion++;
|
|
if (content) version.ContentVersion++;
|
|
if (strong) version.StrongRevocationVersion++;
|
|
version.UpdatedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
}
|
|
|
|
private static async Task InvalidateAsync(
|
|
ILearningAccessService access,
|
|
Guid tenantId,
|
|
IEnumerable<Guid> userIds,
|
|
CancellationToken cancellationToken) =>
|
|
await Task.WhenAll(userIds.Distinct().Select(userId =>
|
|
access.InvalidateAsync(tenantId, userId, cancellationToken)));
|
|
|
|
private static async Task<TenantLearningLicenseItem> ToLicenseItemAsync(
|
|
ILearningAccessPersistence db,
|
|
TenantLearningLicense entity,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var regionIds = await db.TenantLearningLicenseRegions.AsNoTracking()
|
|
.Where(item => item.TenantId == entity.TenantId && item.LicenseId == entity.Id)
|
|
.Select(item => item.MarketRegionId).ToArrayAsync(cancellationToken);
|
|
return new TenantLearningLicenseItem(
|
|
entity.Id, entity.TenantId, entity.BusinessLineId, entity.IsPrimary,
|
|
entity.IncludesNational, entity.AllowsAnyTargetRegion, entity.Status,
|
|
entity.StartsAt, entity.EndsAt, entity.Version, regionIds);
|
|
}
|
|
|
|
private static ContentSliceItem ToSliceItem(ContentSlice entity) => new(
|
|
entity.Id, entity.TenantId, entity.BusinessLineId, entity.MarketRegionId,
|
|
entity.RegionScope, entity.ResourceType, entity.ResourceId, entity.ContentVersion, entity.Status);
|
|
|
|
private static LearningProductItem ToProductItem(
|
|
LearningProduct entity,
|
|
IReadOnlyCollection<Guid> sliceIds) => new(
|
|
entity.Id, entity.TenantId, entity.BusinessLineId, entity.Code, entity.Name, entity.IsActive, sliceIds);
|
|
|
|
private static void ValidateCodeAndName(string code, string name)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name))
|
|
throw Error("learning_access_name_required", "Code and name are required.");
|
|
}
|
|
|
|
private static LearningAccessException Error(string code, string message) => new(code, message);
|
|
}
|