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

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