Files
tiku-backend.net/Tiku.Infrastructure/Learning/Access/PlatformLearningAccessAdministrationService.cs

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);
}