370 lines
18 KiB
C#
370 lines
18 KiB
C#
using System.Data;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Tiku.Application.Content;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Content;
|
|
using Tiku.Domain.Learning;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.ContentV2;
|
|
|
|
internal sealed class ContentReleaseCompiler(
|
|
IQuestionBankPersistence content,
|
|
ILearningPersistence learning,
|
|
ITenantExecutionScope tenantExecutionScope) : IContentReleaseCompiler
|
|
{
|
|
internal const string ReleasePublishedEvent = "content_release_published";
|
|
|
|
public async Task<ContentReleaseCompilationResult> PublishAsync(
|
|
PublishContentReleaseCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await using var transaction = await content.Database.BeginTransactionAsync(
|
|
IsolationLevel.RepeatableRead,
|
|
cancellationToken);
|
|
var publicationLockKey = $"content-release:{command.TenantId:N}:{command.CurriculumVersionId:N}";
|
|
await content.Database.ExecuteSqlInterpolatedAsync(
|
|
$"SELECT pg_advisory_xact_lock(hashtextextended({publicationLockKey}, 0))",
|
|
cancellationToken);
|
|
|
|
var curriculumVersion = await content.CurriculumVersions.AsNoTracking().SingleOrDefaultAsync(
|
|
item => item.TenantId == command.TenantId &&
|
|
item.Id == command.CurriculumVersionId &&
|
|
item.Status == ContentDefinitionStatus.Published,
|
|
cancellationToken) ?? throw Error("curriculum_version_not_publishable",
|
|
"A published curriculum version is required.");
|
|
var curriculum = await content.Curricula.AsNoTracking().SingleAsync(
|
|
item => item.TenantId == command.TenantId && item.Id == curriculumVersion.CurriculumId,
|
|
cancellationToken);
|
|
|
|
var placements = await content.QuestionPlacements.AsNoTracking()
|
|
.Where(item => item.TenantId == command.TenantId &&
|
|
item.CurriculumVersionId == curriculumVersion.Id &&
|
|
item.Status == QuestionPlacementStatus.Active &&
|
|
(item.ValidFrom == null || item.ValidFrom <= DateTimeOffset.UtcNow) &&
|
|
(item.ValidTo == null || item.ValidTo > DateTimeOffset.UtcNow))
|
|
.OrderBy(item => item.Id)
|
|
.ToArrayAsync(cancellationToken);
|
|
if (placements.Length == 0)
|
|
throw Error("content_release_empty", "The curriculum has no active question placements.");
|
|
|
|
var platformOwnerIds = await tenantExecutionScope.ExecuteAsync(
|
|
new SystemScopeRequest(
|
|
null,
|
|
SystemScopeCallerType.Platform,
|
|
nameof(ContentReleaseCompiler),
|
|
"Resolve the unique platform V2 content owner",
|
|
Guid.NewGuid().ToString("N"),
|
|
true),
|
|
(provider, token) => provider.GetRequiredService<ITenancyPersistence>().Tenants.AsNoTracking()
|
|
.Where(item => item.Mode == TenantMode.PlatformOwned)
|
|
.Select(item => item.Id)
|
|
.ToArrayAsync(token),
|
|
cancellationToken);
|
|
if (platformOwnerIds.Length != 1)
|
|
throw Error("platform_content_owner_invalid",
|
|
"Exactly one platform-owned content tenant is required before publishing content.");
|
|
var permittedOwnerIds = platformOwnerIds.Append(command.TenantId).Distinct().ToArray();
|
|
if (placements.Any(item => !permittedOwnerIds.Contains(item.QuestionAssetOwnerTenantId)))
|
|
throw Error("question_asset_owner_forbidden",
|
|
"A placement may reference only this tenant's private question or the platform public owner.");
|
|
|
|
var assetIds = placements.Select(item => item.QuestionAssetId).Distinct().ToArray();
|
|
var assets = new List<QuestionAsset>();
|
|
foreach (var ownerTenantId in permittedOwnerIds)
|
|
assets.AddRange(await ReadForOwnerAsync(
|
|
ownerTenantId,
|
|
"Load explicitly placed published V2 question assets",
|
|
(persistence, token) => persistence.QuestionAssets.AsNoTracking()
|
|
.Where(item => item.TenantId == ownerTenantId &&
|
|
assetIds.Contains(item.Id) &&
|
|
item.Status == QuestionAssetStatus.Published &&
|
|
item.CurrentRevisionId != null)
|
|
.ToArrayAsync(token),
|
|
cancellationToken));
|
|
var assetsByOwner = assets.ToDictionary(item => (item.TenantId, item.Id));
|
|
var requestedAssets = placements
|
|
.Select(item => (item.QuestionAssetOwnerTenantId, item.QuestionAssetId))
|
|
.Distinct()
|
|
.ToArray();
|
|
if (requestedAssets.Any(key => !assetsByOwner.ContainsKey(key)))
|
|
throw Error("content_release_question_unpublished",
|
|
"Every placement must reference a published question asset with a current revision.");
|
|
var revisionIds = assets.Select(item => item.CurrentRevisionId!.Value).Distinct().ToArray();
|
|
var revisions = new List<QuestionRevision>();
|
|
foreach (var ownerTenantId in permittedOwnerIds)
|
|
revisions.AddRange(await ReadForOwnerAsync(
|
|
ownerTenantId,
|
|
"Load immutable V2 question revisions for release compilation",
|
|
(persistence, token) => persistence.QuestionRevisions.AsNoTracking()
|
|
.Where(item => item.TenantId == ownerTenantId && revisionIds.Contains(item.Id))
|
|
.ToArrayAsync(token),
|
|
cancellationToken));
|
|
var revisionsByOwner = revisions.ToDictionary(item => (item.TenantId, item.Id));
|
|
if (requestedAssets.Any(key =>
|
|
!revisionsByOwner.ContainsKey((key.QuestionAssetOwnerTenantId,
|
|
assetsByOwner[key].CurrentRevisionId!.Value))))
|
|
throw Error("content_release_revision_missing", "One or more immutable question revisions are missing.");
|
|
|
|
var policyVersionIds = placements.Select(item => item.AssessmentPolicyVersionId).Distinct().ToArray();
|
|
var publishedPolicyIds = await content.AssessmentPolicyVersions.AsNoTracking()
|
|
.Where(item => item.TenantId == command.TenantId &&
|
|
policyVersionIds.Contains(item.Id) &&
|
|
item.Status == ContentDefinitionStatus.Published)
|
|
.Select(item => item.Id)
|
|
.ToArrayAsync(cancellationToken);
|
|
if (publishedPolicyIds.Length != policyVersionIds.Length)
|
|
throw Error("content_release_assessment_unpublished",
|
|
"Every placement must reference a published assessment policy version.");
|
|
|
|
var profileVersions = await (
|
|
from profile in content.ExamTargetProfiles.AsNoTracking()
|
|
join version in content.ExamTargetProfileVersions.AsNoTracking()
|
|
on profile.Id equals version.ExamTargetProfileId
|
|
where profile.BusinessLineId == curriculum.BusinessLineId &&
|
|
profile.IsActive &&
|
|
version.Status == ContentDefinitionStatus.Published
|
|
select version)
|
|
.ToArrayAsync(cancellationToken);
|
|
if (profileVersions.Length == 0)
|
|
throw Error("content_release_target_profiles_missing",
|
|
"At least one published exam target profile is required.");
|
|
var profileFacts = await LoadProfileFactsAsync(profileVersions, cancellationToken);
|
|
var placementRules = await LoadPlacementRulesAsync(placements, cancellationToken);
|
|
|
|
var placementMatches = placements.Select(placement =>
|
|
{
|
|
var groups = placementRules.GetValueOrDefault(placement.Id) ?? [];
|
|
var matchingProfiles = profileFacts
|
|
.Where(pair => QuestionApplicabilityEvaluator.Matches(pair.Value, groups))
|
|
.Select(pair => pair.Key)
|
|
.Order()
|
|
.ToArray();
|
|
if (matchingProfiles.Length == 0)
|
|
throw Error("content_release_rule_matches_nothing",
|
|
$"Placement '{placement.Id}' does not match any published target profile.");
|
|
return new PlacementMatch(placement, matchingProfiles, HashIds(matchingProfiles));
|
|
}).ToArray();
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
var nextReleaseNo = await content.ContentReleases
|
|
.Where(item => item.TenantId == command.TenantId &&
|
|
item.CurriculumVersionId == curriculumVersion.Id)
|
|
.Select(item => (int?)item.ReleaseNo)
|
|
.MaxAsync(cancellationToken) ?? 0;
|
|
var sourceFingerprint = ComputeSourceFingerprint(placementMatches, assetsByOwner, revisionsByOwner);
|
|
var release = new ContentRelease
|
|
{
|
|
TenantId = command.TenantId,
|
|
BusinessLineId = curriculum.BusinessLineId,
|
|
CurriculumVersionId = curriculumVersion.Id,
|
|
ReleaseNo = nextReleaseNo + 1,
|
|
Name = command.Name.Trim(),
|
|
Status = ContentReleaseStatus.Compiling,
|
|
SourceFingerprint = sourceFingerprint,
|
|
PublishedAt = now,
|
|
CreatedBy = command.ActorUserId
|
|
};
|
|
content.ContentReleases.Add(release);
|
|
await content.SaveChangesAsync(cancellationToken);
|
|
|
|
var segmentsByHash = new Dictionary<string, AudienceSegment>(StringComparer.Ordinal);
|
|
foreach (var match in placementMatches)
|
|
{
|
|
if (segmentsByHash.ContainsKey(match.AudienceHash)) continue;
|
|
var segment = new AudienceSegment
|
|
{
|
|
TenantId = command.TenantId,
|
|
ContentReleaseId = release.Id,
|
|
RuleHash = match.AudienceHash,
|
|
SegmentOrdinal = segmentsByHash.Count
|
|
};
|
|
segmentsByHash.Add(match.AudienceHash, segment);
|
|
content.AudienceSegments.Add(segment);
|
|
foreach (var profileVersionId in match.ProfileVersionIds)
|
|
content.AudienceSegmentMembers.Add(new AudienceSegmentMember
|
|
{
|
|
TenantId = command.TenantId,
|
|
ContentReleaseId = release.Id,
|
|
AudienceSegmentId = segment.Id,
|
|
ExamTargetProfileVersionId = profileVersionId
|
|
});
|
|
}
|
|
|
|
foreach (var group in placementMatches
|
|
.OrderBy(item => item.Placement.CurriculumNodeId)
|
|
.ThenBy(item => assetsByOwner[(item.Placement.QuestionAssetOwnerTenantId,
|
|
item.Placement.QuestionAssetId)].DeliveryFingerprint)
|
|
.GroupBy(item => new { item.AudienceHash, item.Placement.CurriculumNodeId }))
|
|
{
|
|
var ordinal = 0;
|
|
foreach (var match in group)
|
|
{
|
|
var asset = assetsByOwner[(match.Placement.QuestionAssetOwnerTenantId,
|
|
match.Placement.QuestionAssetId)];
|
|
var revision = revisionsByOwner[(asset.TenantId, asset.CurrentRevisionId!.Value)];
|
|
content.ContentReleaseQuestions.Add(new ContentReleaseQuestion
|
|
{
|
|
TenantId = command.TenantId,
|
|
ContentReleaseId = release.Id,
|
|
AudienceSegmentId = segmentsByHash[match.AudienceHash].Id,
|
|
CurriculumNodeId = match.Placement.CurriculumNodeId,
|
|
QuestionPlacementId = match.Placement.Id,
|
|
QuestionAssetOwnerTenantId = asset.TenantId,
|
|
QuestionAssetId = asset.Id,
|
|
QuestionRevisionId = revision.Id,
|
|
AssessmentPolicyVersionId = match.Placement.AssessmentPolicyVersionId,
|
|
Ordinal = ordinal++,
|
|
Difficulty = match.Placement.DifficultyOverride ?? revision.Difficulty,
|
|
QuestionType = revision.QuestionType,
|
|
Status = ContentReleaseQuestionStatus.Active
|
|
});
|
|
}
|
|
}
|
|
|
|
await content.SaveChangesAsync(cancellationToken);
|
|
release.Status = ContentReleaseStatus.Published;
|
|
learning.LearningOutboxMessages.Add(new LearningOutboxMessage
|
|
{
|
|
TenantId = command.TenantId,
|
|
EventType = ReleasePublishedEvent,
|
|
SchemaVersion = 1,
|
|
AggregateId = release.Id,
|
|
Payload = JsonSerializer.SerializeToElement(new
|
|
{
|
|
releaseId = release.Id,
|
|
curriculumVersionId = curriculumVersion.Id,
|
|
businessLineId = curriculum.BusinessLineId,
|
|
releaseNo = release.ReleaseNo,
|
|
sourceFingerprint
|
|
}),
|
|
OccurredAt = now,
|
|
AvailableAt = now
|
|
});
|
|
await content.SaveChangesAsync(cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
return new ContentReleaseCompilationResult(
|
|
release.Id,
|
|
release.ReleaseNo,
|
|
placements.Length,
|
|
placements.Length,
|
|
segmentsByHash.Count,
|
|
profileVersions.Length,
|
|
sourceFingerprint,
|
|
now);
|
|
}
|
|
|
|
private async Task<Dictionary<Guid, IReadOnlyCollection<TargetProfileFact>>> LoadProfileFactsAsync(
|
|
IReadOnlyCollection<ExamTargetProfileVersion> versions,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var versionIds = versions.Select(item => item.Id).ToArray();
|
|
var values = await content.ExamTargetProfileValues.AsNoTracking()
|
|
.Where(item => versionIds.Contains(item.ExamTargetProfileVersionId))
|
|
.ToArrayAsync(cancellationToken);
|
|
var nodeIds = values.Select(item => item.TargetNodeId).Distinct().ToArray();
|
|
var allNodes = await content.TargetNodes.AsNoTracking()
|
|
.Select(item => new NodeParent(item.Id, item.ParentId))
|
|
.ToDictionaryAsync(item => item.Id, cancellationToken);
|
|
var ancestors = nodeIds.ToDictionary(nodeId => nodeId, nodeId => ResolveAncestors(nodeId, allNodes));
|
|
return versions.ToDictionary(
|
|
version => version.Id,
|
|
version => (IReadOnlyCollection<TargetProfileFact>)values
|
|
.Where(value => value.ExamTargetProfileVersionId == version.Id)
|
|
.Select(value => new TargetProfileFact(
|
|
value.TargetDimensionDefinitionId,
|
|
value.TargetNodeId,
|
|
ancestors[value.TargetNodeId]))
|
|
.ToArray());
|
|
}
|
|
|
|
private async Task<Dictionary<Guid, IReadOnlyCollection<PlacementRuleGroupFact>>> LoadPlacementRulesAsync(
|
|
IReadOnlyCollection<QuestionPlacement> placements,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var placementIds = placements.Select(item => item.Id).ToArray();
|
|
var groups = await content.QuestionPlacementRuleGroups.AsNoTracking()
|
|
.Where(item => item.TenantId == placements.First().TenantId && placementIds.Contains(item.PlacementId))
|
|
.ToArrayAsync(cancellationToken);
|
|
var groupIds = groups.Select(item => item.Id).ToArray();
|
|
var conditions = await content.QuestionPlacementRuleConditions.AsNoTracking()
|
|
.Where(item => item.TenantId == placements.First().TenantId && groupIds.Contains(item.RuleGroupId))
|
|
.ToArrayAsync(cancellationToken);
|
|
return groups.GroupBy(item => item.PlacementId).ToDictionary(
|
|
group => group.Key,
|
|
group => (IReadOnlyCollection<PlacementRuleGroupFact>)group.OrderBy(item => item.GroupOrder)
|
|
.Select(item => new PlacementRuleGroupFact(
|
|
item.GroupOrder,
|
|
conditions.Where(condition => condition.RuleGroupId == item.Id)
|
|
.Select(condition => new PlacementRuleConditionFact(
|
|
condition.TargetDimensionDefinitionId,
|
|
condition.TargetNodeId,
|
|
condition.Operator))
|
|
.ToArray()))
|
|
.ToArray());
|
|
}
|
|
|
|
private static IReadOnlySet<Guid> ResolveAncestors(
|
|
Guid nodeId,
|
|
IReadOnlyDictionary<Guid, NodeParent> nodes)
|
|
{
|
|
var result = new HashSet<Guid>();
|
|
var current = nodeId;
|
|
while (nodes.TryGetValue(current, out var node) && node.ParentId is Guid parentId)
|
|
{
|
|
if (!result.Add(parentId)) throw Error("target_node_cycle", "A target-node hierarchy contains a cycle.");
|
|
current = parentId;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static string ComputeSourceFingerprint(
|
|
IEnumerable<PlacementMatch> matches,
|
|
IReadOnlyDictionary<(Guid OwnerTenantId, Guid AssetId), QuestionAsset> assets,
|
|
IReadOnlyDictionary<(Guid OwnerTenantId, Guid RevisionId), QuestionRevision> revisions)
|
|
{
|
|
var values = matches.OrderBy(item => item.Placement.Id).Select(item =>
|
|
{
|
|
var asset = assets[(item.Placement.QuestionAssetOwnerTenantId, item.Placement.QuestionAssetId)];
|
|
var revision = revisions[(asset.TenantId, asset.CurrentRevisionId!.Value)];
|
|
return $"{item.Placement.Id:N}:{asset.TenantId:N}:{revision.Id:N}:{item.Placement.AssessmentPolicyVersionId:N}:{item.AudienceHash}";
|
|
});
|
|
return HashText(string.Join('|', values));
|
|
}
|
|
|
|
private static string HashIds(IEnumerable<Guid> ids) => HashText(string.Join(',', ids.Select(id => id.ToString("N"))));
|
|
private static string HashText(string value) =>
|
|
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
|
private static ContentV2Exception Error(string code, string message) => new(code, message);
|
|
|
|
private Task<T[]> ReadForOwnerAsync<T>(
|
|
Guid ownerTenantId,
|
|
string reason,
|
|
Func<IQuestionBankPersistence, CancellationToken, Task<T[]>> read,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return tenantExecutionScope.ExecuteAsync(
|
|
new SystemScopeRequest(
|
|
ownerTenantId,
|
|
SystemScopeCallerType.PublicQuestionBank,
|
|
nameof(ContentReleaseCompiler),
|
|
reason,
|
|
Guid.NewGuid().ToString("N")),
|
|
(provider, token) => read(provider.GetRequiredService<IQuestionBankPersistence>(), token),
|
|
cancellationToken);
|
|
}
|
|
|
|
private sealed record PlacementMatch(
|
|
QuestionPlacement Placement,
|
|
Guid[] ProfileVersionIds,
|
|
string AudienceHash);
|
|
|
|
private sealed record NodeParent(Guid Id, Guid? ParentId);
|
|
}
|