using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.QuestionBanks; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Content; public sealed class DirectContentService(TikuDbContext dbContext) : IDirectContentService { private const int DefaultLimit = 100; private const int MaxLimit = 1000; private static readonly Regex ScorelineFieldKeyRegex = new("^[A-Za-z][A-Za-z0-9_]{0,63}$", RegexOptions.Compiled); private static readonly HashSet SupportedImportTypes = new(StringComparer.OrdinalIgnoreCase) { "questions", "vocabulary", "handbook", "scoreline", "videos" }; public async Task> CreateQuestionAsync( DirectContentActor actor, QuestionWriteCommand command, CancellationToken cancellationToken = default) { await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken); var question = new Question { Id = command.QuestionId ?? Guid.NewGuid(), TenantId = actor.TenantId }; ApplyQuestion(question, command); dbContext.Questions.Add(question); var version = BuildQuestionVersion(actor, question.Id, 1, command); dbContext.QuestionVersions.Add(version); question.CurrentVersionId = version.Id; await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToQuestionItem(question, version)); } public async Task> UpdateQuestionAsync( DirectContentActor actor, QuestionWriteCommand command, CancellationToken cancellationToken = default) { if (!command.QuestionId.HasValue) { throw new ContentManagementException("questionId is required.", "question_id_required"); } var question = await dbContext.Questions.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.QuestionId.Value, cancellationToken); if (question is null) { throw new ContentManagementException("Question was not found.", "question_not_found"); } await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken); ApplyQuestion(question, command); QuestionVersion? version; if (command.CreateVersion || !question.CurrentVersionId.HasValue) { var nextVersionNo = await dbContext.QuestionVersions .Where(item => item.TenantId == actor.TenantId && item.QuestionId == question.Id) .Select(item => (int?)item.VersionNo) .MaxAsync(cancellationToken) ?? 0; version = BuildQuestionVersion(actor, question.Id, nextVersionNo + 1, command); dbContext.QuestionVersions.Add(version); question.CurrentVersionId = version.Id; } else { version = await dbContext.QuestionVersions.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.QuestionId == question.Id && item.Id == question.CurrentVersionId.Value, cancellationToken); if (version is null) { version = BuildQuestionVersion(actor, question.Id, 1, command); dbContext.QuestionVersions.Add(version); question.CurrentVersionId = version.Id; } else { ApplyQuestionVersion(version, command); } } await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToQuestionItem(question, version)); } public async Task> GetVocabularyUnitsAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.VocabularyUnits.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value); } if (filter.EntryId.HasValue) { query = query.Where(item => item.EntryId == filter.EntryId.Value); } if (filter.ContentNodeId.HasValue) { query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); } if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) { query = query.Where(item => item.IsActive); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); } return new CatalogList(await query .OrderBy(item => item.SortOrder) .ThenBy(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)); } public async Task> UpsertVocabularyUnitAsync( DirectContentActor actor, VocabularyUnitCommand command, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyUnits, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new VocabularyUnit { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.EntryId = command.EntryId; item.ContentNodeId = command.ContentNodeId; item.LegacyId = Normalize(command.LegacyId); item.Name = command.Name.Trim(); item.Description = Normalize(command.Description); item.WordCount = command.WordCount; item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; item.Metadata = JsonObjectOrDefault(command.Metadata); if (isNew) { dbContext.VocabularyUnits.Add(item); } await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } public async Task> GetVocabularyWordsAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.VocabularyWords.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (filter.UnitId.HasValue) { query = query.Where(item => item.UnitId == filter.UnitId.Value); } if (filter.EntryId.HasValue) { query = query.Where(item => item.EntryId == filter.EntryId.Value); } if (filter.ContentNodeId.HasValue) { query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); } if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) { query = query.Where(item => item.IsActive); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => item.Word.Contains(keyword) || (item.Meaning != null && item.Meaning.Contains(keyword))); } return new CatalogList(await query .OrderBy(item => item.SortOrder) .ThenBy(item => item.Word) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)); } public async Task> UpsertVocabularyWordAsync( DirectContentActor actor, VocabularyWordCommand command, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.Word); await AssertReferenceAsync(actor.TenantId, command.UnitId, "vocabulary_unit_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyWords, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new VocabularyWord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; var vocabularyNavigation = await ResolveVocabularyNavigationAsync( actor.TenantId, command.UnitId, command.EntryId, command.ContentNodeId, cancellationToken); item.UnitId = command.UnitId; item.EntryId = vocabularyNavigation.EntryId; item.ContentNodeId = vocabularyNavigation.ContentNodeId; item.LegacyId = Normalize(command.LegacyId); item.Word = command.Word.Trim(); item.Phonetic = Normalize(command.Phonetic); item.Meaning = Normalize(command.Meaning); item.Example = Normalize(command.Example); item.ExampleTranslation = Normalize(command.ExampleTranslation); item.Difficulty = command.Difficulty; item.Tags = JsonArrayOrDefault(command.Tags); item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; item.Metadata = JsonObjectOrDefault(command.Metadata); if (isNew) { dbContext.VocabularyWords.Add(item); } await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } public async Task> GetHandbookSubjectsAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.HandbookSubjects.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value); } if (filter.EntryId.HasValue) { query = query.Where(item => item.EntryId == filter.EntryId.Value); } if (filter.ContentNodeId.HasValue) { query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); } if (filter.SchoolId.HasValue) { query = query.Where(item => item.SchoolId == filter.SchoolId.Value); } if (filter.MajorId.HasValue) { query = query.Where(item => item.MajorId == filter.MajorId.Value); } if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) { query = query.Where(item => item.IsActive); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); } return new CatalogList(await query .OrderBy(item => item.SortOrder) .ThenBy(item => item.Name) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)); } public async Task> UpsertHandbookSubjectAsync( DirectContentActor actor, HandbookSubjectCommand command, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.MajorId, "major_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookSubjects, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new HandbookSubject { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.SchoolId = command.SchoolId; item.MajorId = command.MajorId; item.EntryId = command.EntryId; item.ContentNodeId = command.ContentNodeId; item.LegacyId = Normalize(command.LegacyId); item.Name = command.Name.Trim(); item.Type = ParseNullable(command.Type, "handbook_subject_type_invalid"); item.Icon = Normalize(command.Icon); item.Color = Normalize(command.Color); item.Description = Normalize(command.Description); item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; item.Metadata = JsonObjectOrDefault(command.Metadata); if (isNew) { dbContext.HandbookSubjects.Add(item); } await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } public async Task> GetHandbookChaptersAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.HandbookChapters.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (filter.SubjectId.HasValue) { query = query.Where(item => item.SubjectId == filter.SubjectId.Value); } if (filter.EntryId.HasValue) { query = query.Where(item => item.EntryId == filter.EntryId.Value); } if (filter.ContentNodeId.HasValue) { query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); } if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) { query = query.Where(item => item.IsActive); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); } return new CatalogList(await query .OrderBy(item => item.SortOrder) .ThenBy(item => item.Name) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)); } public async Task> UpsertHandbookChapterAsync( DirectContentActor actor, HandbookChapterCommand command, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.SubjectId, "handbook_subject_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookChapters, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new HandbookChapter { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; var chapterNavigation = await ResolveHandbookSubjectNavigationAsync( actor.TenantId, command.SubjectId, command.EntryId, command.ContentNodeId, cancellationToken); item.SubjectId = command.SubjectId; item.EntryId = chapterNavigation.EntryId; item.ContentNodeId = chapterNavigation.ContentNodeId; item.LegacyId = Normalize(command.LegacyId); item.Name = command.Name.Trim(); item.Description = Normalize(command.Description); item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; item.Metadata = JsonObjectOrDefault(command.Metadata); if (isNew) { dbContext.HandbookChapters.Add(item); } await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } public async Task> GetHandbookEntriesAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.HandbookEntries.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (filter.ChapterId.HasValue) { query = query.Where(item => item.ChapterId == filter.ChapterId.Value); } if (filter.EntryId.HasValue) { query = query.Where(item => item.EntryId == filter.EntryId.Value); } if (filter.ContentNodeId.HasValue) { query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value); } if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) { query = query.Where(item => item.IsActive); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => item.Title.Contains(keyword) || (item.Content != null && item.Content.Contains(keyword))); } return new CatalogList(await query .OrderBy(item => item.SortOrder) .ThenBy(item => item.Title) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)); } public async Task> UpsertHandbookEntryAsync( DirectContentActor actor, HandbookEntryCommand command, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); await AssertReferenceAsync(actor.TenantId, command.ChapterId, "handbook_chapter_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookEntries, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new HandbookEntry { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; var entryNavigation = await ResolveHandbookChapterNavigationAsync( actor.TenantId, command.ChapterId, command.EntryId, command.ContentNodeId, cancellationToken); item.ChapterId = command.ChapterId; item.EntryId = entryNavigation.EntryId; item.ContentNodeId = entryNavigation.ContentNodeId; item.LegacyId = Normalize(command.LegacyId); item.Title = command.Title.Trim(); item.Summary = Normalize(command.Summary); item.Content = Normalize(command.Content); item.Tags = JsonArrayOrDefault(command.Tags); item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; item.Metadata = JsonObjectOrDefault(command.Metadata); if (isNew) { dbContext.HandbookEntries.Add(item); } await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } public async Task> GetSchoolsAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.Schools.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => item.Name.Contains(keyword)); } return new CatalogList(await query .OrderBy(item => item.Name) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)); } public async Task> UpsertSchoolAsync( DirectContentActor actor, SchoolCommand command, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.Schools, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new School { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.LegacyId = Normalize(command.LegacyId); item.Name = command.Name.Trim(); item.ProfessionalExamDate = Normalize(command.ProfessionalExamDate); item.Metadata = JsonObjectOrDefault(command.Metadata); if (isNew) { dbContext.Schools.Add(item); } await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } public async Task> GetMajorsAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.Majors.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value); } if (filter.SchoolId.HasValue) { query = query.Where(item => item.SchoolId == filter.SchoolId.Value); } if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) { query = query.Where(item => item.IsActive); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); } return new CatalogList(await query .OrderBy(item => item.SortOrder) .ThenBy(item => item.Name) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)); } public async Task> UpsertMajorAsync( DirectContentActor actor, MajorCommand command, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.Majors, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new Major { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.SchoolId = command.SchoolId; item.LegacyId = Normalize(command.LegacyId); item.Name = command.Name.Trim(); item.Description = Normalize(command.Description); item.StudyTips = Normalize(command.StudyTips); item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; if (isNew) { dbContext.Majors.Add(item); } await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } public async Task> GetScorelineFieldsAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.ScorelineFields.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value || item.RegionId == null); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => item.FieldKey.Contains(keyword) || item.FieldName.Contains(keyword)); } return new CatalogList(await query .OrderBy(item => item.SortOrder) .ThenBy(item => item.FieldName) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)); } public async Task> UpsertScorelineFieldAsync( DirectContentActor actor, ScorelineFieldCommand command, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldKey); ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldName); if (!ScorelineFieldKeyRegex.IsMatch(command.FieldKey.Trim())) { throw new ContentManagementException("Scoreline field key is invalid.", "scoreline_field_key_invalid"); } await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineFields, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new ScorelineField { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.LegacyId = Normalize(command.LegacyId); item.FieldKey = command.FieldKey.Trim(); item.FieldName = command.FieldName.Trim(); item.FieldType = Normalize(command.FieldType) ?? "text"; item.Unit = Normalize(command.Unit); item.IsFilter = command.IsFilter ?? item.IsFilter; item.IsRequired = command.IsRequired ?? item.IsRequired; item.IsVisible = command.IsVisible ?? item.IsVisible; item.IsTrend = command.IsTrend ?? item.IsTrend; item.Options = JsonArrayOrDefault(command.Options); item.Placeholder = Normalize(command.Placeholder); item.Description = Normalize(command.Description); item.SortOrder = command.Order ?? item.SortOrder; if (isNew) { dbContext.ScorelineFields.Add(item); } await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } public async Task> GetScorelineRecordsAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.ScorelineRecords.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value); } if (filter.SchoolId.HasValue) { query = query.Where(item => item.SchoolId == filter.SchoolId.Value); } if (filter.MajorId.HasValue) { query = query.Where(item => item.MajorId == filter.MajorId.Value); } if (filter.Year.HasValue) { query = query.Where(item => item.Year == filter.Year.Value); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => (item.SchoolName != null && item.SchoolName.Contains(keyword)) || (item.MajorName != null && item.MajorName.Contains(keyword))); } return new CatalogList(await query .OrderByDescending(item => item.Year) .ThenBy(item => item.SchoolName) .ThenBy(item => item.MajorName) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)); } public async Task> UpsertScorelineRecordAsync( DirectContentActor actor, ScorelineRecordCommand command, CancellationToken cancellationToken = default) { if (command.Year is < 1900 or > 3000) { throw new ContentManagementException("Scoreline record year is invalid.", "scoreline_year_invalid"); } await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.MajorId, "major_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineRecords, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new ScorelineRecord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.SchoolId = command.SchoolId; item.MajorId = command.MajorId; item.LegacyId = Normalize(command.LegacyId); item.Year = command.Year; item.SchoolName = Normalize(command.SchoolName); item.MajorName = Normalize(command.MajorName); item.FieldValues = JsonObjectOrDefault(command.FieldValues); if (isNew) { dbContext.ScorelineRecords.Add(item); } await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } public async Task> GetScorelineYearsAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.ScorelineRecords.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value); } if (filter.SchoolId.HasValue) { query = query.Where(item => item.SchoolId == filter.SchoolId.Value); } var years = await query .Select(item => item.Year) .Distinct() .OrderByDescending(year => year) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); return new CatalogList(years); } public async Task> GetScorelineTrendAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var years = await GetScorelineYearsAsync(actor, filter, cancellationToken); var items = new List(); foreach (var year in years.Items) { var schoolCount = await dbContext.ScorelineRecords.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Year == year) .Select(item => item.SchoolId) .Where(id => id.HasValue) .Distinct() .CountAsync(cancellationToken); var majorCount = await dbContext.ScorelineRecords.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Year == year) .Select(item => item.MajorId) .Where(id => id.HasValue) .Distinct() .CountAsync(cancellationToken); items.Add(new ScorelineTrendItem(year, schoolCount, majorCount)); } return new CatalogList(items); } public async Task> GetVideosAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.VideoExplanations.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (filter.SubjectId.HasValue) { query = query.Where(item => item.SubjectId == filter.SubjectId.Value); } if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) { query = query.Where(item => item.IsActive); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => item.Title.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword))); } var items = await query .OrderBy(item => item.SortOrder) .ThenByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(item => ToVideoItem(item)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> UpsertVideoAsync( DirectContentActor actor, VideoExplanationCommand command, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); await AssertReferenceAsync(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.VideoExplanations, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new VideoExplanation { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.SubjectId = command.SubjectId; item.LegacyId = Normalize(command.LegacyId); item.Title = command.Title.Trim(); item.Description = Normalize(command.Description); item.VideoUrl = Normalize(command.VideoUrl); item.ThumbnailUrl = Normalize(command.ThumbnailUrl); item.DurationSeconds = command.DurationSeconds; item.KnowledgeTags = JsonArrayOrDefault(command.KnowledgeTags); item.IsGeneral = command.IsGeneral ?? item.IsGeneral; item.Difficulty = command.Difficulty; item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; item.Metadata = JsonObjectOrDefault(command.Metadata); if (isNew) { dbContext.VideoExplanations.Add(item); } await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToVideoItem(item)); } public async Task> BindQuestionVideoAsync( DirectContentActor actor, QuestionVideoCommand command, CancellationToken cancellationToken = default) { await AssertReferenceAsync(actor.TenantId, command.QuestionId, "question_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.VideoId, "video_not_found", cancellationToken); var item = await dbContext.QuestionVideos.SingleOrDefaultAsync( link => link.TenantId == actor.TenantId && link.QuestionId == command.QuestionId && link.VideoId == command.VideoId, cancellationToken); var isNew = item is null; item ??= new QuestionVideo { TenantId = actor.TenantId }; item.QuestionId = command.QuestionId; item.VideoId = command.VideoId; item.LegacyId = Normalize(command.LegacyId); item.VideoType = Parse(command.VideoType, QuestionVideoType.Specific, "question_video_type_invalid"); item.SortOrder = command.Order ?? item.SortOrder; item.Metadata = JsonObjectOrDefault(command.Metadata); if (isNew) { dbContext.QuestionVideos.Add(item); } var question = await dbContext.Questions.SingleAsync( question => question.TenantId == actor.TenantId && question.Id == command.QuestionId, cancellationToken); question.HasVideoExplanation = true; await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToQuestionVideoItem(item)); } public async Task> GetOperationContentAsync( DirectContentActor actor, string kind, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var items = NormalizeOperationKind(kind) switch { "banners" => (await dbContext.Banners.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .Where(item => !filter.RegionId.HasValue || item.RegionId == filter.RegionId.Value) .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) .OrderBy(item => item.SortOrder) .ThenByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), "faqs" => (await dbContext.Faqs.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .Where(item => !filter.RegionId.HasValue || item.RegionId == filter.RegionId.Value) .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) .OrderBy(item => item.SortOrder) .ThenBy(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), "announcements" => (await dbContext.Announcements.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) .OrderBy(item => item.SortOrder) .ThenByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), "exam-dates" => (await dbContext.ExamDates.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .Where(item => !filter.RegionId.HasValue || item.RegionId == filter.RegionId.Value) .Where(item => !filter.SchoolId.HasValue || item.SchoolId == filter.SchoolId.Value) .Where(item => string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase) || item.IsActive) .OrderBy(item => item.ExamAt == null) .ThenBy(item => item.ExamAt) .ThenBy(item => item.SortOrder) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)).Select(ToOperationItem).ToArray(), _ => throw new ContentManagementException("Operation content kind is invalid.", "operation_content_kind_invalid") }; return new CatalogList(items); } public async Task> UpsertOperationContentAsync( DirectContentActor actor, string kind, OperationContentCommand command, CancellationToken cancellationToken = default) { OperationContentItem item = NormalizeOperationKind(kind) switch { "banners" => ToOperationItem(await UpsertBannerAsync(actor, command, cancellationToken)), "faqs" => ToOperationItem(await UpsertFaqAsync(actor, command, cancellationToken)), "announcements" => ToOperationItem(await UpsertAnnouncementAsync(actor, command, cancellationToken)), "exam-dates" => ToOperationItem(await UpsertExamDateAsync(actor, command, cancellationToken)), _ => throw new ContentManagementException("Operation content kind is invalid.", "operation_content_kind_invalid") }; return new ContentManagementResult(item); } public Task PreviewImportAsync( DirectContentActor actor, SimpleImportCommand command, CancellationToken cancellationToken = default) { return CreateImportJobAsync(actor, command with { DryRun = true }, execute: false, cancellationToken); } public Task ExecuteImportAsync( DirectContentActor actor, SimpleImportCommand command, CancellationToken cancellationToken = default) { return CreateImportJobAsync(actor, command with { DryRun = false }, execute: true, cancellationToken); } public async Task> GetImportIssuesAsync( DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default) { await AssertImportJobAsync(actor.TenantId, jobId, cancellationToken); var issues = await dbContext.ContentImportIssues.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.JobId == jobId) .OrderBy(item => item.RowNo) .ThenBy(item => item.CreatedAt) .Take(MaxLimit) .Select(item => new ContentImportIssueModel( item.Id, item.JobId, item.ItemId, item.RowNo, item.Severity, item.Code, item.FieldPath, item.Message, item.Details)) .ToArrayAsync(cancellationToken); return new CatalogList(issues); } public async Task RunImportPostCheckAsync( DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default) { var job = await dbContext.ContentImportJobs.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == jobId, cancellationToken); if (job is null) { throw new ContentManagementException("Import job was not found.", "import_job_not_found"); } var counts = JsonSerializer.SerializeToElement(new { job.TotalCount, job.ValidCount, job.ErrorCount, job.WarningCount, job.InsertedCount, job.UpdatedCount, job.SkippedCount }); job.Summary = JsonSerializer.SerializeToElement(new { postCheck = new { status = job.ErrorCount == 0 ? "passed" : "warning", checkedAt = DateTimeOffset.UtcNow, counts } }); await dbContext.SaveChangesAsync(cancellationToken); return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, []); } public async Task GetImportPostCheckAsync( DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default) { var job = await dbContext.ContentImportJobs.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == jobId, cancellationToken); if (job is null) { throw new ContentManagementException("Import job was not found.", "import_job_not_found"); } var issues = await GetImportIssuesAsync(actor, jobId, cancellationToken); var counts = JsonSerializer.SerializeToElement(new { job.TotalCount, job.ValidCount, job.ErrorCount, job.WarningCount, job.InsertedCount, job.UpdatedCount, job.SkippedCount }); return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, issues.Items); } private async Task CreateImportJobAsync( DirectContentActor actor, SimpleImportCommand command, bool execute, CancellationToken cancellationToken) { if (!SupportedImportTypes.Contains(command.ImportType)) { throw new ContentManagementException("Import type is invalid.", "import_type_invalid"); } var importType = ParseImportType(command.ImportType); var sourceFormat = Parse(command.SourceFormat, ImportSourceFormat.Json, "import_source_format_invalid"); var items = command.Items.Select(item => item.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : item).ToArray(); var job = new ContentImportJob { TenantId = actor.TenantId, CreatedBy = actor.UserId, TargetRegionId = command.RegionId, TargetSubjectId = command.SubjectId, TargetCategoryId = command.CategoryId, TargetContentNodeId = command.ContentNodeId, TargetQuestionBankId = command.QuestionBankId, ImportType = importType, SourceFormat = sourceFormat, Status = execute ? ContentImportStatus.Completed : ContentImportStatus.Preview, SourceName = Normalize(command.SourceName), DryRun = command.DryRun, TotalCount = items.Length, ValidCount = items.Length, RawPayload = JsonSerializer.SerializeToElement(items), NormalizedPayload = JsonSerializer.SerializeToElement(items), StartedAt = execute ? DateTimeOffset.UtcNow : null, FinishedAt = execute ? DateTimeOffset.UtcNow : null }; dbContext.ContentImportJobs.Add(job); var importItems = new List(); var rowNo = 1; foreach (var payload in items) { var importItem = new ContentImportItem { TenantId = actor.TenantId, JobId = job.Id, RowNo = rowNo++, ExternalId = GetString(payload, "legacyId") ?? GetString(payload, "id"), Status = execute ? ContentImportItemStatus.Inserted : ContentImportItemStatus.Valid, SourcePayload = payload, NormalizedPayload = payload }; if (execute) { var target = await WriteImportedItemAsync(actor, command, payload, cancellationToken); importItem.TargetType = target.TargetType; importItem.TargetId = target.TargetId; job.InsertedCount++; } importItems.Add(importItem); } dbContext.ContentImportItems.AddRange(importItems); job.Summary = JsonSerializer.SerializeToElement(new { mode = execute ? "execute" : "preview", supportedTypes = SupportedImportTypes, note = "Synchronous direct migration import skeleton; async worker will be introduced later." }); await dbContext.SaveChangesAsync(cancellationToken); return new SimpleImportResult( ToJobItem(job), importItems.Select(ToImportItem).ToArray(), []); } private async Task<(string TargetType, Guid TargetId)> WriteImportedItemAsync( DirectContentActor actor, SimpleImportCommand command, JsonElement payload, CancellationToken cancellationToken) { switch (command.ImportType.ToLowerInvariant()) { case "questions": var result = await CreateQuestionAsync(actor, new QuestionWriteCommand( null, command.QuestionBankId, command.SubjectId, command.CategoryId, null, command.EntryId, command.ContentNodeId, command.CollectionId, GetString(payload, "legacyId"), GetString(payload, "type") ?? "choice", GetString(payload, "typeLabel"), GetInt(payload, "difficulty"), GetElement(payload, "tags", JsonDefaults.Array()), GetString(payload, "content") ?? GetString(payload, "title"), GetElement(payload, "options", JsonDefaults.Array()), GetInt(payload, "correctOptionIndex"), GetElement(payload, "correctOptionIndices", JsonDefaults.Array()), GetString(payload, "answerText") ?? GetString(payload, "answer"), GetString(payload, "explanation"), GetElement(payload, "subQuestions", JsonDefaults.Array()), GetString(payload, "codeLang"), GetString(payload, "codeTemplate"), GetString(payload, "mediaUrl"), "Published", GetElement(payload, "examMarkers", JsonDefaults.Object()), GetString(payload, "sourceHash"), true), cancellationToken); return ("question", result.Item.Id); case "vocabulary": var word = await UpsertVocabularyWordAsync(actor, new VocabularyWordCommand( null, null, command.EntryId, command.ContentNodeId, GetString(payload, "legacyId"), GetString(payload, "word") ?? GetString(payload, "name") ?? "未命名单词", GetString(payload, "phonetic"), GetString(payload, "meaning"), GetString(payload, "example"), GetString(payload, "exampleTranslation"), GetInt(payload, "difficulty"), GetElement(payload, "tags", JsonDefaults.Array()), GetInt(payload, "order"), true, GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); return ("vocabulary_word", word.Item.Id); case "handbook": var entry = await UpsertHandbookEntryAsync(actor, new HandbookEntryCommand( null, null, command.EntryId, command.ContentNodeId, GetString(payload, "legacyId"), GetString(payload, "title") ?? GetString(payload, "name") ?? "未命名条目", GetString(payload, "summary"), GetString(payload, "content"), GetElement(payload, "tags", JsonDefaults.Array()), GetInt(payload, "order"), true, GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); return ("handbook_entry", entry.Item.Id); case "scoreline": var scoreline = await UpsertScorelineRecordAsync(actor, new ScorelineRecordCommand( null, command.RegionId, GetGuid(payload, "schoolId"), GetGuid(payload, "majorId"), GetString(payload, "legacyId"), GetInt(payload, "year") ?? DateTimeOffset.UtcNow.Year, GetString(payload, "schoolName"), GetString(payload, "majorName"), GetElement(payload, "fieldValues", payload)), cancellationToken); return ("scoreline_record", scoreline.Item.Id); case "videos": var video = await UpsertVideoAsync(actor, new VideoExplanationCommand( null, command.SubjectId, GetString(payload, "legacyId"), GetString(payload, "title") ?? "未命名视频", GetString(payload, "description"), GetString(payload, "videoUrl") ?? GetString(payload, "url"), GetString(payload, "thumbnailUrl"), GetInt(payload, "durationSeconds"), GetElement(payload, "knowledgeTags", JsonDefaults.Array()), GetBool(payload, "isGeneral"), GetInt(payload, "difficulty"), GetInt(payload, "order"), true, GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken); return ("video_explanation", video.Item.Id); default: throw new ContentManagementException("Import type is invalid.", "import_type_invalid"); } } private async Task UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) { await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.Banners, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new Banner { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.LegacyId = Normalize(command.LegacyId); item.Title = Normalize(command.Title); item.Subtitle = Normalize(command.Subtitle); item.Content = Normalize(command.Content); item.ButtonText = Normalize(command.ButtonText); item.ButtonLink = Normalize(command.ButtonLink); item.BackgroundColor = Normalize(command.BackgroundColor); item.BorderColor = Normalize(command.BorderColor); item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; if (isNew) { dbContext.Banners.Add(item); } await dbContext.SaveChangesAsync(cancellationToken); return item; } private async Task UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) { await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.Faqs, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new Faq { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.LegacyId = Normalize(command.LegacyId); item.Question = Normalize(command.Question) ?? Normalize(command.Title); item.Answer = Normalize(command.Answer) ?? Normalize(command.Content); item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; if (isNew) { dbContext.Faqs.Add(item); } await dbContext.SaveChangesAsync(cancellationToken); return item; } private async Task UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) { var item = await ResolveByIdOrLegacyAsync(dbContext.Announcements, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new Announcement { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.LegacyId = Normalize(command.LegacyId); item.Content = Normalize(command.Content) ?? Normalize(command.Title); item.Link = Normalize(command.Link); item.BackgroundColor = Normalize(command.BackgroundColor); item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; if (isNew) { dbContext.Announcements.Add(item); } await dbContext.SaveChangesAsync(cancellationToken); return item; } private async Task UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(command.ExamName); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.ExamDates, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new ExamDate { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.SchoolId = command.SchoolId; item.LegacyId = Normalize(command.LegacyId); item.ExamName = command.ExamName.Trim(); item.ExamAt = command.ExamAt; item.ExamType = Normalize(command.ExamType); item.Description = Normalize(command.Description) ?? Normalize(command.Content); item.SortOrder = command.Order ?? item.SortOrder; item.IsActive = command.IsActive ?? item.IsActive; item.Metadata = JsonObjectOrDefault(command.Metadata); if (isNew) { dbContext.ExamDates.Add(item); } await dbContext.SaveChangesAsync(cancellationToken); return item; } private static void ApplyQuestion(Question question, QuestionWriteCommand command) { question.QuestionBankId = command.QuestionBankId; question.SubjectId = command.SubjectId; question.CategoryId = command.CategoryId; question.NodeId = command.NodeId; question.EntryId = command.EntryId; question.ContentNodeId = command.ContentNodeId; question.PrimaryCollectionId = command.PrimaryCollectionId; question.LegacyId = Normalize(command.LegacyId); question.Type = Normalize(command.Type) ?? question.Type; question.TypeLabel = Normalize(command.TypeLabel); question.Difficulty = command.Difficulty; question.Tags = JsonArrayOrDefault(command.Tags); question.ExamMarkers = JsonObjectOrDefault(command.ExamMarkers); question.MediaUrl = Normalize(command.MediaUrl); question.Status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid"); } private static QuestionVersion BuildQuestionVersion( DirectContentActor actor, Guid questionId, int versionNo, QuestionWriteCommand command) { var version = new QuestionVersion { TenantId = actor.TenantId, QuestionId = questionId, VersionNo = versionNo, CreatedBy = actor.UserId }; ApplyQuestionVersion(version, command); return version; } private static void ApplyQuestionVersion(QuestionVersion version, QuestionWriteCommand command) { version.Content = Normalize(command.Content); version.Options = JsonArrayOrDefault(command.Options); version.CorrectOptionIndex = command.CorrectOptionIndex; version.CorrectOptionIndices = JsonArrayOrDefault(command.CorrectOptionIndices); version.AnswerText = Normalize(command.AnswerText); version.Explanation = Normalize(command.Explanation); version.SubQuestions = JsonArrayOrDefault(command.SubQuestions); version.CodeLang = Normalize(command.CodeLang); version.CodeTemplate = Normalize(command.CodeTemplate); version.SourceHash = Normalize(command.SourceHash); } private async Task AssertQuestionReferencesAsync(Guid tenantId, QuestionWriteCommand command, CancellationToken cancellationToken) { await AssertReferenceAsync(tenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken); await AssertReferenceAsync(tenantId, command.SubjectId, "subject_not_found", cancellationToken); await AssertReferenceAsync(tenantId, command.CategoryId, "category_not_found", cancellationToken); await AssertReferenceAsync(tenantId, command.NodeId, "module_node_not_found", cancellationToken); await AssertReferenceAsync(tenantId, command.EntryId, "entry_not_found", cancellationToken); await AssertReferenceAsync(tenantId, command.ContentNodeId, "node_not_found", cancellationToken); await AssertReferenceAsync(tenantId, command.PrimaryCollectionId, "collection_not_found", cancellationToken); } private async Task SyncPrimaryCollectionItemAsync( DirectContentActor actor, Question question, CancellationToken cancellationToken) { if (!question.PrimaryCollectionId.HasValue) { return; } var existing = await dbContext.QuestionCollectionItems.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value && item.QuestionId == question.Id, cancellationToken); if (existing is null) { var nextOrder = await dbContext.QuestionCollectionItems .Where(item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value) .Select(item => (int?)item.SortOrder) .MaxAsync(cancellationToken) ?? -1; dbContext.QuestionCollectionItems.Add(new QuestionCollectionItem { TenantId = actor.TenantId, CollectionId = question.PrimaryCollectionId.Value, QuestionId = question.Id, SortOrder = nextOrder + 1 }); } var collection = await dbContext.QuestionCollections.SingleAsync( item => item.TenantId == actor.TenantId && item.Id == question.PrimaryCollectionId.Value, cancellationToken); collection.QuestionCount = await dbContext.QuestionCollectionItems.CountAsync( item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value, cancellationToken) + (existing is null ? 1 : 0); collection.UpdatedBy = actor.UserId; } private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveVocabularyNavigationAsync( Guid tenantId, Guid? unitId, Guid? entryId, Guid? contentNodeId, CancellationToken cancellationToken) { if (!unitId.HasValue) { return (entryId, contentNodeId); } var unit = await dbContext.VocabularyUnits.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == unitId.Value, cancellationToken); if (unit is null) { throw new ContentManagementException("Vocabulary unit was not found.", "vocabulary_unit_not_found"); } return (entryId ?? unit.EntryId, contentNodeId ?? unit.ContentNodeId); } private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookSubjectNavigationAsync( Guid tenantId, Guid? subjectId, Guid? entryId, Guid? contentNodeId, CancellationToken cancellationToken) { if (!subjectId.HasValue) { return (entryId, contentNodeId); } var subject = await dbContext.HandbookSubjects.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == subjectId.Value, cancellationToken); if (subject is null) { throw new ContentManagementException("Handbook subject was not found.", "handbook_subject_not_found"); } return (entryId ?? subject.EntryId, contentNodeId ?? subject.ContentNodeId); } private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookChapterNavigationAsync( Guid tenantId, Guid? chapterId, Guid? entryId, Guid? contentNodeId, CancellationToken cancellationToken) { if (!chapterId.HasValue) { return (entryId, contentNodeId); } var chapter = await dbContext.HandbookChapters.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == chapterId.Value, cancellationToken); if (chapter is null) { throw new ContentManagementException("Handbook chapter was not found.", "handbook_chapter_not_found"); } return (entryId ?? chapter.EntryId, contentNodeId ?? chapter.ContentNodeId); } private static QuestionManagementItem ToQuestionItem(Question question, QuestionVersion? version) { return new QuestionManagementItem( question.Id, version?.Id, question.QuestionBankId, question.SubjectId, question.CategoryId, question.NodeId, question.EntryId, question.ContentNodeId, question.PrimaryCollectionId, question.LegacyId, question.Type, question.TypeLabel, question.Difficulty, question.Tags, version?.Content, version?.Options ?? JsonDefaults.Array(), version?.CorrectOptionIndex, version?.CorrectOptionIndices ?? JsonDefaults.Array(), version?.AnswerText, version?.Explanation, version?.SubQuestions ?? JsonDefaults.Array(), version?.CodeLang, version?.CodeTemplate, question.MediaUrl, question.HasVideoExplanation, question.Status); } private static VideoManagementItem ToVideoItem(VideoExplanation item) { return new VideoManagementItem( item.Id, item.SubjectId, item.LegacyId, item.Title, item.Description, item.VideoUrl, item.ThumbnailUrl, item.DurationSeconds, item.KnowledgeTags, item.IsGeneral, item.Difficulty, item.SortOrder, item.IsActive, item.Metadata); } private static QuestionVideoManagementItem ToQuestionVideoItem(QuestionVideo item) { return new QuestionVideoManagementItem( item.Id, item.QuestionId, item.VideoId, item.LegacyId, item.VideoType, item.SortOrder, item.Metadata); } private static OperationContentItem ToOperationItem(Banner item) { return new OperationContentItem( item.Id, "banners", item.RegionId, null, item.LegacyId, item.Title, item.Content, null, null, null, null, item.SortOrder, item.IsActive, JsonSerializer.SerializeToElement(new { item.Subtitle, item.ButtonText, item.ButtonLink, item.BackgroundColor, item.BorderColor })); } private static OperationContentItem ToOperationItem(Faq item) { return new OperationContentItem( item.Id, "faqs", item.RegionId, null, item.LegacyId, null, null, item.Question, item.Answer, null, null, item.SortOrder, item.IsActive, JsonDefaults.Object()); } private static OperationContentItem ToOperationItem(Announcement item) { return new OperationContentItem( item.Id, "announcements", null, null, item.LegacyId, null, item.Content, null, null, null, null, item.SortOrder, item.IsActive, JsonSerializer.SerializeToElement(new { item.Link, item.BackgroundColor })); } private static OperationContentItem ToOperationItem(ExamDate item) { return new OperationContentItem( item.Id, "exam-dates", item.RegionId, item.SchoolId, item.LegacyId, item.ExamName, item.Description, null, null, item.ExamAt, item.ExamType, item.SortOrder, item.IsActive, item.Metadata); } private static ContentImportJobItem ToJobItem(ContentImportJob job) { return new ContentImportJobItem( job.Id, job.TargetRegionId, job.TargetSubjectId, job.TargetCategoryId, job.TargetContentNodeId, job.TargetQuestionBankId, job.ImportType, job.SourceFormat, job.Status, job.SourceName, job.SourceHash, job.DryRun, job.TotalCount, job.ValidCount, job.ErrorCount, job.WarningCount, job.InsertedCount, job.UpdatedCount, job.SkippedCount, job.Summary, job.ErrorMessage, job.StartedAt, job.FinishedAt, job.CreatedAt, job.UpdatedAt); } private static ContentImportItemModel ToImportItem(ContentImportItem item) { return new ContentImportItemModel( item.Id, item.JobId, item.RowNo, item.ExternalId, item.Status, item.TargetType, item.TargetId, item.SourcePayload, item.NormalizedPayload, item.ContentHash, item.IssuesCount); } private async Task ResolveByIdOrLegacyAsync( DbSet set, Guid tenantId, Guid? id, string? legacyId, CancellationToken cancellationToken) where TEntity : AuditableTenantEntity { if (id.HasValue) { return await set.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == id.Value, cancellationToken); } var normalizedLegacyId = Normalize(legacyId); return normalizedLegacyId is null ? null : await set.SingleOrDefaultAsync( item => item.TenantId == tenantId && EF.Property(item, "LegacyId") == normalizedLegacyId, cancellationToken); } private async Task AssertReferenceAsync( Guid tenantId, Guid? id, string code, CancellationToken cancellationToken) where TEntity : class { if (!id.HasValue) { return; } var exists = await dbContext.Set() .AnyAsync(item => EF.Property(item, "TenantId") == tenantId && EF.Property(item, "Id") == id.Value, cancellationToken); if (!exists) { throw new ContentManagementException("Referenced entity was not found.", code); } } private async Task AssertImportJobAsync(Guid tenantId, Guid jobId, CancellationToken cancellationToken) { var exists = await dbContext.ContentImportJobs.AnyAsync( item => item.TenantId == tenantId && item.Id == jobId, cancellationToken); if (!exists) { throw new ContentManagementException("Import job was not found.", "import_job_not_found"); } } private static string NormalizeOperationKind(string kind) { var normalized = Normalize(kind)?.ToLowerInvariant(); return normalized switch { "banner" or "banners" => "banners", "faq" or "faqs" => "faqs", "announcement" or "announcements" => "announcements", "exam-date" or "exam-dates" or "examdates" => "exam-dates", _ => normalized ?? string.Empty }; } private static ContentImportType ParseImportType(string value) { return value.ToLowerInvariant() switch { "questions" => ContentImportType.Questions, "vocabulary" => ContentImportType.Vocabulary, "handbook" => ContentImportType.Handbook, "scoreline" => ContentImportType.Scoreline, "videos" => ContentImportType.Videos, _ => throw new ContentManagementException("Import type is invalid.", "import_type_invalid") }; } private static TEnum Parse(string? value, TEnum fallback, string code) where TEnum : struct { if (string.IsNullOrWhiteSpace(value)) { return fallback; } if (Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed)) { return parsed; } throw new ContentManagementException("Enum value is invalid.", code); } private static TEnum? ParseNullable(string? value, string code) where TEnum : struct { if (string.IsNullOrWhiteSpace(value)) { return null; } if (Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed)) { return parsed; } throw new ContentManagementException("Enum value is invalid.", code); } private static string? Normalize(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private static int ResolveLimit(int? limit) { return !limit.HasValue || limit <= 0 ? DefaultLimit : Math.Min(limit.Value, MaxLimit); } private static JsonElement JsonObjectOrDefault(JsonElement value) { return value.ValueKind is JsonValueKind.Object ? value : JsonDefaults.Object(); } private static JsonElement JsonArrayOrDefault(JsonElement value) { return value.ValueKind is JsonValueKind.Array ? value : JsonDefaults.Array(); } private static JsonElement GetElement(JsonElement payload, string name, JsonElement fallback) { return payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty(name, out var value) ? value : fallback; } private static string? GetString(JsonElement payload, string name) { if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) { return null; } return value.ValueKind == JsonValueKind.String ? Normalize(value.GetString()) : value.ToString(); } private static int? GetInt(JsonElement payload, string name) { if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) { return null; } return value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number) ? number : int.TryParse(value.ToString(), out number) ? number : null; } private static Guid? GetGuid(JsonElement payload, string name) { if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) { return null; } return value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out var guid) ? guid : null; } private static bool? GetBool(JsonElement payload, string name) { if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value)) { return null; } return value.ValueKind switch { JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.String when bool.TryParse(value.GetString(), out var parsed) => parsed, _ => null }; } }