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> 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 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 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 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 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 userIds, Guid businessLineId, CancellationToken cancellationToken) { foreach (var userId in userIds) await effectiveAccess.InvalidateAsync(tenantId, userId, businessLineId, cancellationToken); } private async Task 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); }