397 lines
19 KiB
C#
397 lines
19 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Learning;
|
|
using Tiku.Domain.Learning;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.Learning;
|
|
|
|
internal sealed class LearningAccessAdministrationService(
|
|
ILearningAccessPersistence persistence,
|
|
ILearningAccessService accessService) : ILearningAccessAdministrationService
|
|
{
|
|
public async Task<StudentTargetRegionItem?> GetTargetRegionAsync(
|
|
LearningActor actor,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var business = await (
|
|
from license in persistence.TenantLearningLicenses.AsNoTracking()
|
|
join item in persistence.BusinessLines.AsNoTracking()
|
|
on license.BusinessLineId equals item.Id
|
|
where license.TenantId == actor.TenantId && license.IsPrimary
|
|
select new { BusinessLineId = item.Id, item.TargetRegionCooldownDays })
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
if (business is null) return null;
|
|
return await (
|
|
from history in persistence.StudentTargetRegionHistory.AsNoTracking()
|
|
join region in persistence.MarketRegions.AsNoTracking()
|
|
on history.MarketRegionId equals region.Id
|
|
where history.TenantId == actor.TenantId &&
|
|
history.UserId == actor.UserId &&
|
|
history.BusinessLineId == business.BusinessLineId &&
|
|
history.IsCurrent
|
|
select new StudentTargetRegionItem(
|
|
region.Id,
|
|
region.Code,
|
|
region.Name,
|
|
history.EffectiveAt,
|
|
history.EffectiveAt.AddDays(business.TargetRegionCooldownDays)))
|
|
.SingleOrDefaultAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<StudentTargetRegionItem> ChangeTargetRegionAsync(
|
|
LearningActor actor,
|
|
ChangeStudentTargetRegionCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var license = await (
|
|
from item in persistence.TenantLearningLicenses
|
|
join business in persistence.BusinessLines on item.BusinessLineId equals business.Id
|
|
where item.TenantId == actor.TenantId &&
|
|
item.IsPrimary &&
|
|
item.Status == LearningLicenseStatus.Active
|
|
select new
|
|
{
|
|
License = item,
|
|
business.RegionAccessStrategy,
|
|
business.TargetRegionCooldownDays
|
|
})
|
|
.SingleOrDefaultAsync(cancellationToken);
|
|
if (license is null || license.RegionAccessStrategy !=
|
|
LearningRegionAccessStrategy.NationalWithStudentTargetRegion)
|
|
throw new LearningAccessException(
|
|
"student_target_region_not_supported",
|
|
"The tenant business does not use personal target regions.");
|
|
|
|
var region = await persistence.MarketRegions.AsNoTracking()
|
|
.SingleOrDefaultAsync(item => item.Id == command.MarketRegionId && item.IsActive, cancellationToken)
|
|
?? throw new LearningAccessException("market_region_not_found", "The target region was not found.");
|
|
var regionLicensed = license.License.AllowsAnyTargetRegion ||
|
|
await persistence.TenantLearningLicenseRegions.AsNoTracking().AnyAsync(
|
|
item => item.TenantId == actor.TenantId &&
|
|
item.LicenseId == license.License.Id &&
|
|
item.MarketRegionId == region.Id,
|
|
cancellationToken);
|
|
if (!regionLicensed)
|
|
throw new LearningAccessException(
|
|
"student_target_region_not_licensed",
|
|
"The selected target region is not covered by the tenant license.");
|
|
|
|
await using var transaction = await persistence.Database.BeginTransactionAsync(cancellationToken);
|
|
var current = await persistence.StudentTargetRegionHistory.SingleOrDefaultAsync(
|
|
item => item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.BusinessLineId == license.License.BusinessLineId &&
|
|
item.IsCurrent,
|
|
cancellationToken);
|
|
if (current?.MarketRegionId == region.Id)
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
return new StudentTargetRegionItem(
|
|
region.Id, region.Code, region.Name, current.EffectiveAt,
|
|
current.EffectiveAt.AddDays(license.TargetRegionCooldownDays));
|
|
}
|
|
|
|
if (current is not null && current.EffectiveAt.AddDays(license.TargetRegionCooldownDays) > now)
|
|
throw new LearningAccessException(
|
|
"student_target_region_cooldown",
|
|
$"The target region cannot be changed before {current.EffectiveAt.AddDays(license.TargetRegionCooldownDays):O}.");
|
|
if (current is not null)
|
|
{
|
|
current.IsCurrent = false;
|
|
current.EndedAt = now;
|
|
}
|
|
|
|
persistence.StudentTargetRegionHistory.Add(new StudentTargetRegionHistory
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = actor.UserId,
|
|
BusinessLineId = license.License.BusinessLineId,
|
|
MarketRegionId = region.Id,
|
|
EffectiveAt = now,
|
|
IsCurrent = true,
|
|
ChangedBy = actor.UserId,
|
|
Reason = "student_self_service"
|
|
});
|
|
await BumpVersionAsync(actor.TenantId, actor.UserId, false, cancellationToken);
|
|
await persistence.SaveChangesAsync(cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
await accessService.InvalidateAsync(actor.TenantId, actor.UserId, cancellationToken);
|
|
return new StudentTargetRegionItem(
|
|
region.Id, region.Code, region.Name, now, now.AddDays(license.TargetRegionCooldownDays));
|
|
}
|
|
|
|
public async Task<StudentTargetRegionItem> OverrideTargetRegionAsync(
|
|
Guid tenantId,
|
|
OverrideStudentTargetRegionCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(command.Reason))
|
|
throw new LearningAccessException(
|
|
"student_target_region_override_reason_required",
|
|
"An override reason is required.");
|
|
|
|
var studentExists = await persistence.TenantMemberships.AsNoTracking().AnyAsync(
|
|
item => item.TenantId == tenantId &&
|
|
item.UserId == command.UserId &&
|
|
item.Role == TenantRole.Student &&
|
|
item.Status == MembershipStatus.Active,
|
|
cancellationToken);
|
|
if (!studentExists)
|
|
throw new LearningAccessException("student_not_found", "The active tenant student was not found.");
|
|
|
|
var license = await (
|
|
from item in persistence.TenantLearningLicenses
|
|
join business in persistence.BusinessLines on item.BusinessLineId equals business.Id
|
|
where item.TenantId == tenantId &&
|
|
item.IsPrimary &&
|
|
item.Status == LearningLicenseStatus.Active
|
|
select new
|
|
{
|
|
License = item,
|
|
business.RegionAccessStrategy,
|
|
business.TargetRegionCooldownDays
|
|
})
|
|
.SingleOrDefaultAsync(cancellationToken);
|
|
if (license is null || license.RegionAccessStrategy !=
|
|
LearningRegionAccessStrategy.NationalWithStudentTargetRegion)
|
|
throw new LearningAccessException(
|
|
"student_target_region_not_supported",
|
|
"The tenant business does not use personal target regions.");
|
|
|
|
var region = await persistence.MarketRegions.AsNoTracking()
|
|
.SingleOrDefaultAsync(item => item.Id == command.MarketRegionId && item.IsActive, cancellationToken)
|
|
?? throw new LearningAccessException("market_region_not_found", "The target region was not found.");
|
|
var regionLicensed = license.License.AllowsAnyTargetRegion ||
|
|
await persistence.TenantLearningLicenseRegions.AsNoTracking().AnyAsync(
|
|
item => item.TenantId == tenantId &&
|
|
item.LicenseId == license.License.Id &&
|
|
item.MarketRegionId == region.Id,
|
|
cancellationToken);
|
|
if (!regionLicensed)
|
|
throw new LearningAccessException(
|
|
"student_target_region_not_licensed",
|
|
"The selected target region is not covered by the tenant license.");
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
await using var transaction = await persistence.Database.BeginTransactionAsync(cancellationToken);
|
|
var current = await persistence.StudentTargetRegionHistory.SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId &&
|
|
item.UserId == command.UserId &&
|
|
item.BusinessLineId == license.License.BusinessLineId &&
|
|
item.IsCurrent,
|
|
cancellationToken);
|
|
if (current?.MarketRegionId != region.Id)
|
|
{
|
|
if (current is not null)
|
|
{
|
|
current.IsCurrent = false;
|
|
current.EndedAt = now;
|
|
}
|
|
|
|
persistence.StudentTargetRegionHistory.Add(new StudentTargetRegionHistory
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = command.UserId,
|
|
BusinessLineId = license.License.BusinessLineId,
|
|
MarketRegionId = region.Id,
|
|
EffectiveAt = now,
|
|
IsCurrent = true,
|
|
ChangedBy = command.ChangedBy,
|
|
Reason = command.Reason.Trim()
|
|
});
|
|
await BumpVersionAsync(tenantId, command.UserId, false, cancellationToken);
|
|
await persistence.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
await transaction.CommitAsync(cancellationToken);
|
|
await accessService.InvalidateAsync(tenantId, command.UserId, cancellationToken);
|
|
var effectiveAt = current?.MarketRegionId == region.Id ? current.EffectiveAt : now;
|
|
return new StudentTargetRegionItem(
|
|
region.Id, region.Code, region.Name, effectiveAt,
|
|
effectiveAt.AddDays(license.TargetRegionCooldownDays));
|
|
}
|
|
|
|
public async Task<IReadOnlyCollection<ClassContentAssignmentItem>> GetClassAssignmentsAsync(
|
|
Guid tenantId,
|
|
Guid classId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await persistence.ClassContentAssignments.AsNoTracking()
|
|
.Where(item => item.TenantId == tenantId && item.ClassId == classId)
|
|
.OrderByDescending(item => item.CreatedAt)
|
|
.Select(item => ToItem(item))
|
|
.ToArrayAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<ClassContentAssignmentItem> UpsertClassAssignmentAsync(
|
|
Guid tenantId,
|
|
Guid actorUserId,
|
|
UpsertClassContentAssignmentCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
if (command.EndsAt.HasValue && command.EndsAt <= (command.StartsAt ?? now))
|
|
throw new LearningAccessException("class_assignment_period_invalid", "Assignment end must follow its start.");
|
|
var tenantClass = await persistence.TenantClasses.AsNoTracking().AnyAsync(
|
|
item => item.TenantId == tenantId &&
|
|
item.Id == command.ClassId &&
|
|
item.Status == TenantRecordStatus.Active,
|
|
cancellationToken);
|
|
if (!tenantClass) throw new LearningAccessException("class_not_found", "The class was not found.");
|
|
|
|
var slice = await persistence.ContentSlices.AsNoTracking().SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId &&
|
|
item.Id == command.ContentSliceId &&
|
|
item.ResourceType == command.ResourceType &&
|
|
item.ResourceId == command.ResourceId &&
|
|
item.Status == ContentSliceStatus.Active,
|
|
cancellationToken) ?? throw new LearningAccessException(
|
|
"content_slice_not_found", "The active content slice was not found.");
|
|
var license = await persistence.TenantLearningLicenses.AsNoTracking().SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId &&
|
|
item.IsPrimary &&
|
|
item.Status == LearningLicenseStatus.Active &&
|
|
item.BusinessLineId == slice.BusinessLineId,
|
|
cancellationToken) ?? throw new LearningAccessException(
|
|
"learning_license_scope_mismatch", "The content is outside the tenant learning license.");
|
|
if (slice.RegionScope == LearningRegionScopeKind.National && !license.IncludesNational)
|
|
throw new LearningAccessException(
|
|
"learning_license_scope_mismatch", "The tenant license does not include national content.");
|
|
if (slice.MarketRegionId.HasValue && !license.AllowsAnyTargetRegion &&
|
|
!await persistence.TenantLearningLicenseRegions.AsNoTracking().AnyAsync(
|
|
item => item.TenantId == tenantId &&
|
|
item.LicenseId == license.Id &&
|
|
item.MarketRegionId == slice.MarketRegionId,
|
|
cancellationToken))
|
|
throw new LearningAccessException(
|
|
"learning_license_scope_mismatch", "The tenant license does not include the content region.");
|
|
|
|
var assignment = command.Id.HasValue
|
|
? await persistence.ClassContentAssignments.SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId && item.Id == command.Id.Value,
|
|
cancellationToken)
|
|
: null;
|
|
assignment ??= new ClassContentAssignment
|
|
{
|
|
TenantId = tenantId,
|
|
ClassId = command.ClassId,
|
|
ContentSliceOwnerTenantId = tenantId,
|
|
ContentSliceId = slice.Id,
|
|
CreatedBy = actorUserId
|
|
};
|
|
var isNew = persistence.Entry(assignment).State == EntityState.Detached;
|
|
assignment.ClassId = command.ClassId;
|
|
assignment.ContentSliceOwnerTenantId = tenantId;
|
|
assignment.ContentSliceId = slice.Id;
|
|
assignment.ResourceType = command.ResourceType;
|
|
assignment.ResourceId = command.ResourceId;
|
|
assignment.StartsAt = command.StartsAt ?? now;
|
|
assignment.EndsAt = command.EndsAt;
|
|
assignment.Status = ClassContentAssignmentStatus.Active;
|
|
assignment.RevokedBy = null;
|
|
assignment.RevokedReason = null;
|
|
if (isNew) persistence.ClassContentAssignments.Add(assignment);
|
|
await persistence.SaveChangesAsync(cancellationToken);
|
|
await InvalidateClassMembersAsync(tenantId, command.ClassId, false, cancellationToken);
|
|
return ToItem(assignment);
|
|
}
|
|
|
|
public async Task<ClassContentAssignmentItem> RevokeClassAssignmentAsync(
|
|
Guid tenantId,
|
|
Guid actorUserId,
|
|
Guid assignmentId,
|
|
string? reason,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var assignment = await persistence.ClassContentAssignments.SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId && item.Id == assignmentId,
|
|
cancellationToken) ?? throw new LearningAccessException("class_assignment_not_found", "Assignment was not found.");
|
|
assignment.Status = ClassContentAssignmentStatus.Revoked;
|
|
assignment.RevokedBy = actorUserId;
|
|
assignment.RevokedReason = string.IsNullOrWhiteSpace(reason) ? "revoked_by_teacher" : reason.Trim();
|
|
assignment.EndsAt = DateTimeOffset.UtcNow;
|
|
await persistence.SaveChangesAsync(cancellationToken);
|
|
await InvalidateClassMembersAsync(tenantId, assignment.ClassId, true, cancellationToken);
|
|
return ToItem(assignment);
|
|
}
|
|
|
|
private async Task InvalidateClassMembersAsync(
|
|
Guid tenantId,
|
|
Guid classId,
|
|
bool strongRevocation,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var userIds = await persistence.TenantClassMembers.AsNoTracking()
|
|
.Where(item => item.TenantId == tenantId &&
|
|
item.ClassId == classId &&
|
|
item.MemberType == TenantClassMemberType.Student &&
|
|
item.Status == TenantClassMemberStatus.Active)
|
|
.Select(item => item.UserId)
|
|
.Distinct()
|
|
.ToArrayAsync(cancellationToken);
|
|
var versions = await persistence.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))
|
|
{
|
|
persistence.LearningAccessVersions.Add(new LearningAccessVersion
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
StrongRevocationVersion = strongRevocation ? 2 : 1
|
|
});
|
|
continue;
|
|
}
|
|
|
|
version.GrantVersion++;
|
|
if (strongRevocation) version.StrongRevocationVersion++;
|
|
version.UpdatedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
await persistence.SaveChangesAsync(cancellationToken);
|
|
await Task.WhenAll(userIds.Select(userId =>
|
|
accessService.InvalidateAsync(tenantId, userId, cancellationToken)));
|
|
}
|
|
|
|
private async Task BumpVersionAsync(
|
|
Guid tenantId,
|
|
Guid userId,
|
|
bool strongRevocation,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var version = await persistence.LearningAccessVersions.SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId && item.UserId == userId,
|
|
cancellationToken);
|
|
if (version is null)
|
|
{
|
|
version = new LearningAccessVersion
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
StrongRevocationVersion = strongRevocation ? 2 : 1
|
|
};
|
|
persistence.LearningAccessVersions.Add(version);
|
|
}
|
|
else
|
|
{
|
|
version.GrantVersion++;
|
|
if (strongRevocation) version.StrongRevocationVersion++;
|
|
version.UpdatedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
}
|
|
|
|
private static ClassContentAssignmentItem ToItem(ClassContentAssignment item) => new(
|
|
item.Id,
|
|
item.ClassId,
|
|
item.ContentSliceId,
|
|
item.ResourceType,
|
|
item.ResourceId,
|
|
item.StartsAt,
|
|
item.EndsAt,
|
|
item.Status);
|
|
}
|