263 lines
12 KiB
C#
263 lines
12 KiB
C#
using System.Data;
|
|
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 V2LearningAccessAdministrationService(
|
|
ILearningAccessPersistence access,
|
|
IEffectiveLearningAccessService effectiveAccess) : IV2LearningAccessAdministrationService
|
|
{
|
|
public async Task<IReadOnlyCollection<ClassAssignmentGrantItem>> GetClassGrantsAsync(
|
|
Guid tenantId,
|
|
Guid classId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await RequireClassAsync(tenantId, classId, cancellationToken);
|
|
var items = await access.ClassAssignmentGrants.AsNoTracking()
|
|
.Where(item => item.TenantId == tenantId && item.ClassId == classId)
|
|
.OrderByDescending(item => item.CreatedAt)
|
|
.ToArrayAsync(cancellationToken);
|
|
return items.Select(ToItem).ToArray();
|
|
}
|
|
|
|
public async Task<ClassAssignmentGrantItem> UpsertClassGrantAsync(
|
|
Guid tenantId,
|
|
Guid actorUserId,
|
|
UpsertClassAssignmentGrantCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var startsAt = command.StartsAt ?? now;
|
|
if (command.EndsAt <= startsAt)
|
|
throw Error("class_assignment_period_invalid", "The assignment end must be after its start.");
|
|
|
|
await using var transaction = await access.Database.BeginTransactionAsync(
|
|
IsolationLevel.ReadCommitted,
|
|
cancellationToken);
|
|
await RequireClassAsync(tenantId, command.ClassId, cancellationToken);
|
|
var businessLineId = await EnsureManifestResourceLicensedAsync(tenantId, command, now, cancellationToken);
|
|
|
|
ClassAssignmentGrant item;
|
|
if (command.Id.HasValue)
|
|
{
|
|
item = await access.ClassAssignmentGrants.SingleOrDefaultAsync(value =>
|
|
value.TenantId == tenantId && value.Id == command.Id.Value,
|
|
cancellationToken)
|
|
?? throw Error("class_assignment_not_found", "The class assignment was not found.");
|
|
if (item.Status != ClassAssignmentGrantStatus.Active)
|
|
throw Error("class_assignment_not_active", "Only an active class assignment can be changed.");
|
|
if (item.ClassId != command.ClassId)
|
|
throw Error("class_assignment_class_immutable", "The assignment class cannot be changed.");
|
|
}
|
|
else
|
|
{
|
|
item = new ClassAssignmentGrant
|
|
{
|
|
TenantId = tenantId,
|
|
ClassId = command.ClassId,
|
|
CreatedBy = actorUserId
|
|
};
|
|
access.ClassAssignmentGrants.Add(item);
|
|
}
|
|
|
|
item.ProductAccessManifestVersionId = command.ProductAccessManifestVersionId;
|
|
item.ResourceType = command.ResourceType;
|
|
item.ResourceId = command.ResourceId;
|
|
item.StartsAt = startsAt;
|
|
item.EndsAt = command.EndsAt;
|
|
item.UpdatedAt = now;
|
|
await access.SaveChangesAsync(cancellationToken);
|
|
var affectedUsers = await BumpAffectedStudentsAsync(tenantId, command.ClassId, now, cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
await InvalidateAsync(tenantId, affectedUsers, businessLineId, cancellationToken);
|
|
return ToItem(item);
|
|
}
|
|
|
|
public async Task<ClassAssignmentGrantItem> RevokeClassGrantAsync(
|
|
Guid tenantId,
|
|
Guid actorUserId,
|
|
Guid classId,
|
|
Guid grantId,
|
|
string reason,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(reason))
|
|
throw Error("class_assignment_revoke_reason_required", "A revoke reason is required.");
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
await using var transaction = await access.Database.BeginTransactionAsync(cancellationToken);
|
|
var item = await access.ClassAssignmentGrants.SingleOrDefaultAsync(value =>
|
|
value.TenantId == tenantId && value.ClassId == classId && value.Id == grantId,
|
|
cancellationToken)
|
|
?? throw Error("class_assignment_not_found", "The class assignment was not found.");
|
|
var businessLineId = await ResolveManifestBusinessLineAsync(
|
|
tenantId, item.ProductAccessManifestVersionId, cancellationToken);
|
|
if (item.Status == ClassAssignmentGrantStatus.Revoked)
|
|
{
|
|
await transaction.CommitAsync(cancellationToken);
|
|
return ToItem(item);
|
|
}
|
|
|
|
item.Status = ClassAssignmentGrantStatus.Revoked;
|
|
item.RevokedBy = actorUserId;
|
|
item.RevokedReason = reason.Trim();
|
|
item.UpdatedAt = now;
|
|
await access.SaveChangesAsync(cancellationToken);
|
|
var affectedUsers = await BumpAffectedStudentsAsync(tenantId, item.ClassId, now, cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
await InvalidateAsync(tenantId, affectedUsers, businessLineId, cancellationToken);
|
|
return ToItem(item);
|
|
}
|
|
|
|
private async Task<Guid> EnsureManifestResourceLicensedAsync(
|
|
Guid tenantId,
|
|
UpsertClassAssignmentGrantCommand command,
|
|
DateTimeOffset now,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var manifest = await (
|
|
from version in access.ProductAccessManifestVersions.AsNoTracking()
|
|
join definition in access.ProductAccessManifests.AsNoTracking()
|
|
on new { version.TenantId, Id = version.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 }
|
|
join resource in access.ProductManifestResources.AsNoTracking()
|
|
on new { version.TenantId, VersionId = version.Id }
|
|
equals new { resource.TenantId, VersionId = resource.ProductAccessManifestVersionId }
|
|
where version.TenantId == tenantId &&
|
|
version.Id == command.ProductAccessManifestVersionId &&
|
|
version.Status == AccessManifestStatus.Published &&
|
|
definition.IsActive && product.IsActive &&
|
|
resource.ResourceType == command.ResourceType &&
|
|
resource.ResourceId == command.ResourceId
|
|
select new { product.BusinessLineId })
|
|
.SingleOrDefaultAsync(cancellationToken)
|
|
?? throw Error("class_assignment_manifest_resource_invalid",
|
|
"The resource is not present in an active published manifest.");
|
|
|
|
var license = await access.TenantBusinessLicenses.AsNoTracking().SingleOrDefaultAsync(item =>
|
|
item.TenantId == tenantId &&
|
|
item.BusinessLineId == manifest.BusinessLineId &&
|
|
item.Status == LicenseLifecycleStatus.Active &&
|
|
item.StartsAt <= now &&
|
|
(item.EndsAt == null || item.EndsAt > now), cancellationToken)
|
|
?? throw Error("class_assignment_tenant_license_required",
|
|
"The tenant is not licensed for the manifest business.");
|
|
|
|
var manifestTargetIds = await access.ProductManifestTargets.AsNoTracking()
|
|
.Where(item => item.TenantId == tenantId &&
|
|
item.ProductAccessManifestVersionId == command.ProductAccessManifestVersionId)
|
|
.Select(item => item.ExamTargetProfileVersionId)
|
|
.Distinct()
|
|
.ToArrayAsync(cancellationToken);
|
|
var licensedTargetCount = await access.TenantLicensedTargets.AsNoTracking()
|
|
.Where(item => item.TenantId == tenantId &&
|
|
item.TenantBusinessLicenseId == license.Id &&
|
|
item.StartsAt <= now &&
|
|
(item.EndsAt == null || item.EndsAt > now) &&
|
|
manifestTargetIds.Contains(item.ExamTargetProfileVersionId))
|
|
.Select(item => item.ExamTargetProfileVersionId)
|
|
.Distinct()
|
|
.CountAsync(cancellationToken);
|
|
if (licensedTargetCount != manifestTargetIds.Length)
|
|
throw Error("class_assignment_target_not_licensed",
|
|
"The class assignment manifest exceeds the tenant licensed targets.");
|
|
return manifest.BusinessLineId;
|
|
}
|
|
|
|
private async Task<Guid[]> BumpAffectedStudentsAsync(
|
|
Guid tenantId,
|
|
Guid classId,
|
|
DateTimeOffset now,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var userIds = await access.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);
|
|
if (userIds.Length == 0) return userIds;
|
|
|
|
var versions = await access.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 };
|
|
access.LearningAccessVersions.Add(version);
|
|
}
|
|
|
|
version.GrantVersion++;
|
|
version.UpdatedAt = now;
|
|
}
|
|
|
|
await access.EffectiveAccessProjections
|
|
.Where(item => item.TenantId == tenantId && userIds.Contains(item.UserId))
|
|
.ExecuteDeleteAsync(cancellationToken);
|
|
await access.SaveChangesAsync(cancellationToken);
|
|
return userIds;
|
|
}
|
|
|
|
private async Task InvalidateAsync(
|
|
Guid tenantId,
|
|
IEnumerable<Guid> userIds,
|
|
Guid businessLineId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
foreach (var userId in userIds)
|
|
await effectiveAccess.InvalidateAsync(tenantId, userId, businessLineId, cancellationToken);
|
|
}
|
|
|
|
private async Task<Guid> ResolveManifestBusinessLineAsync(
|
|
Guid tenantId,
|
|
Guid manifestVersionId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return await (
|
|
from version in access.ProductAccessManifestVersions.AsNoTracking()
|
|
join definition in access.ProductAccessManifests.AsNoTracking()
|
|
on new { version.TenantId, Id = version.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 version.TenantId == tenantId && version.Id == manifestVersionId
|
|
select product.BusinessLineId)
|
|
.SingleAsync(cancellationToken);
|
|
}
|
|
|
|
private async Task RequireClassAsync(Guid tenantId, Guid classId, CancellationToken cancellationToken)
|
|
{
|
|
if (!await access.TenantClasses.AsNoTracking().AnyAsync(
|
|
item => item.TenantId == tenantId && item.Id == classId,
|
|
cancellationToken))
|
|
throw Error("class_not_found", "The class was not found.");
|
|
}
|
|
|
|
private static ClassAssignmentGrantItem ToItem(ClassAssignmentGrant item) => new(
|
|
item.Id,
|
|
item.ClassId,
|
|
item.ProductAccessManifestVersionId,
|
|
item.ResourceType,
|
|
item.ResourceId,
|
|
item.StartsAt,
|
|
item.EndsAt,
|
|
item.Status,
|
|
item.CreatedBy,
|
|
item.RevokedBy,
|
|
item.RevokedReason);
|
|
|
|
private static LearningAccessException Error(string code, string message) => new(code, message);
|
|
}
|