2032 lines
84 KiB
C#
2032 lines
84 KiB
C#
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.Application.QuestionBanks;
|
|
using Tiku.Application.Security;
|
|
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;
|
|
using Tiku.Infrastructure.Security;
|
|
|
|
namespace Tiku.Infrastructure.Content;
|
|
|
|
public sealed class DirectContentService(
|
|
TikuDbContext dbContext,
|
|
IQuestionReferenceService questionReferenceService,
|
|
ICurrentAccessContext currentAccessContext) : 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<string> SupportedImportTypes = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"questions",
|
|
"vocabulary",
|
|
"handbook",
|
|
"scoreline",
|
|
"videos"
|
|
};
|
|
|
|
public async Task<ContentManagementResult<QuestionManagementItem>> CreateQuestionAsync(
|
|
DirectContentActor actor,
|
|
QuestionWriteCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken);
|
|
await using var transaction = dbContext.Database.CurrentTransaction is null
|
|
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
|
: null;
|
|
|
|
var question = new Question
|
|
{
|
|
Id = command.QuestionId ?? Guid.NewGuid(),
|
|
TenantId = actor.TenantId
|
|
};
|
|
ApplyQuestion(question, command);
|
|
dbContext.Questions.Add(question);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
|
|
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);
|
|
if (transaction is not null)
|
|
{
|
|
await transaction.CommitAsync(cancellationToken);
|
|
}
|
|
return new ContentManagementResult<QuestionManagementItem>(ToQuestionItem(question, version));
|
|
}
|
|
|
|
public async Task<ContentManagementResult<QuestionManagementItem>> 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<QuestionManagementItem>(ToQuestionItem(question, version));
|
|
}
|
|
|
|
public async Task<CatalogList<VocabularyUnit>> GetVocabularyUnitsAsync(
|
|
DirectContentActor actor,
|
|
AdminLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
var regionIds = scope.RegionIds.ToArray();
|
|
var query = dbContext.VocabularyUnits.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId)
|
|
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
|
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<VocabularyUnit>(await query
|
|
.OrderBy(item => item.SortOrder)
|
|
.ThenBy(item => item.CreatedAt)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.ToArrayAsync(cancellationToken));
|
|
}
|
|
|
|
public async Task<ContentManagementResult<VocabularyUnit>> UpsertVocabularyUnitAsync(
|
|
DirectContentActor actor,
|
|
VocabularyUnitCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
|
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ContentNode>(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;
|
|
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "vocabulary_unit_not_found");
|
|
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<VocabularyUnit>(item);
|
|
}
|
|
|
|
public async Task<CatalogList<VocabularyWord>> 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<VocabularyWord>(await query
|
|
.OrderBy(item => item.SortOrder)
|
|
.ThenBy(item => item.Word)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.ToArrayAsync(cancellationToken));
|
|
}
|
|
|
|
public async Task<ContentManagementResult<VocabularyWord>> UpsertVocabularyWordAsync(
|
|
DirectContentActor actor,
|
|
VocabularyWordCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Word);
|
|
await AssertReferenceAsync<VocabularyUnit>(actor.TenantId, command.UnitId, "vocabulary_unit_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ContentNode>(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<VocabularyWord>(item);
|
|
}
|
|
|
|
public async Task<CatalogList<HandbookSubject>> GetHandbookSubjectsAsync(
|
|
DirectContentActor actor,
|
|
AdminLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
var regionIds = scope.RegionIds.ToArray();
|
|
var query = dbContext.HandbookSubjects.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId)
|
|
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
|
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<HandbookSubject>(await query
|
|
.OrderBy(item => item.SortOrder)
|
|
.ThenBy(item => item.Name)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.ToArrayAsync(cancellationToken));
|
|
}
|
|
|
|
public async Task<ContentManagementResult<HandbookSubject>> UpsertHandbookSubjectAsync(
|
|
DirectContentActor actor,
|
|
HandbookSubjectCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
|
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
|
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
|
|
await AssertReferenceAsync<Major>(actor.TenantId, command.MajorId, "major_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ContentNode>(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;
|
|
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "handbook_subject_not_found");
|
|
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<HandbookSubjectType>(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<HandbookSubject>(item);
|
|
}
|
|
|
|
public async Task<CatalogList<HandbookChapter>> 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<HandbookChapter>(await query
|
|
.OrderBy(item => item.SortOrder)
|
|
.ThenBy(item => item.Name)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.ToArrayAsync(cancellationToken));
|
|
}
|
|
|
|
public async Task<ContentManagementResult<HandbookChapter>> UpsertHandbookChapterAsync(
|
|
DirectContentActor actor,
|
|
HandbookChapterCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
|
await AssertReferenceAsync<HandbookSubject>(actor.TenantId, command.SubjectId, "handbook_subject_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ContentNode>(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<HandbookChapter>(item);
|
|
}
|
|
|
|
public async Task<CatalogList<HandbookEntry>> 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<HandbookEntry>(await query
|
|
.OrderBy(item => item.SortOrder)
|
|
.ThenBy(item => item.Title)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.ToArrayAsync(cancellationToken));
|
|
}
|
|
|
|
public async Task<ContentManagementResult<HandbookEntry>> UpsertHandbookEntryAsync(
|
|
DirectContentActor actor,
|
|
HandbookEntryCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
|
|
await AssertReferenceAsync<HandbookChapter>(actor.TenantId, command.ChapterId, "handbook_chapter_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ContentNode>(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<HandbookEntry>(item);
|
|
}
|
|
|
|
public async Task<CatalogList<School>> GetSchoolsAsync(
|
|
DirectContentActor actor,
|
|
AdminLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
var regionIds = scope.RegionIds.ToArray();
|
|
var query = dbContext.Schools.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId)
|
|
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
|
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<School>(await query
|
|
.OrderBy(item => item.Name)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.ToArrayAsync(cancellationToken));
|
|
}
|
|
|
|
public async Task<ContentManagementResult<School>> UpsertSchoolAsync(
|
|
DirectContentActor actor,
|
|
SchoolCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
|
await AssertReferenceAsync<Region>(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;
|
|
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "school_not_found");
|
|
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<School>(item);
|
|
}
|
|
|
|
public async Task<CatalogList<Major>> GetMajorsAsync(
|
|
DirectContentActor actor,
|
|
AdminLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
var regionIds = scope.RegionIds.ToArray();
|
|
var query = dbContext.Majors.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId)
|
|
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
|
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<Major>(await query
|
|
.OrderBy(item => item.SortOrder)
|
|
.ThenBy(item => item.Name)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.ToArrayAsync(cancellationToken));
|
|
}
|
|
|
|
public async Task<ContentManagementResult<Major>> UpsertMajorAsync(
|
|
DirectContentActor actor,
|
|
MajorCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
|
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
|
await AssertReferenceAsync<School>(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;
|
|
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "major_not_found");
|
|
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<Major>(item);
|
|
}
|
|
|
|
public async Task<CatalogList<ScorelineField>> GetScorelineFieldsAsync(
|
|
DirectContentActor actor,
|
|
AdminLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
var regionIds = scope.RegionIds.ToArray();
|
|
var query = dbContext.ScorelineFields.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId)
|
|
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
|
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<ScorelineField>(await query
|
|
.OrderBy(item => item.SortOrder)
|
|
.ThenBy(item => item.FieldName)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.ToArrayAsync(cancellationToken));
|
|
}
|
|
|
|
public async Task<ContentManagementResult<ScorelineField>> UpsertScorelineFieldAsync(
|
|
DirectContentActor actor,
|
|
ScorelineFieldCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
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<Region>(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;
|
|
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_field_not_found");
|
|
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<ScorelineField>(item);
|
|
}
|
|
|
|
public async Task<CatalogList<ScorelineRecord>> GetScorelineRecordsAsync(
|
|
DirectContentActor actor,
|
|
AdminLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
var regionIds = scope.RegionIds.ToArray();
|
|
var query = dbContext.ScorelineRecords.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId)
|
|
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
|
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<ScorelineRecord>(await query
|
|
.OrderByDescending(item => item.Year)
|
|
.ThenBy(item => item.SchoolName)
|
|
.ThenBy(item => item.MajorName)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.ToArrayAsync(cancellationToken));
|
|
}
|
|
|
|
public async Task<ContentManagementResult<ScorelineRecord>> UpsertScorelineRecordAsync(
|
|
DirectContentActor actor,
|
|
ScorelineRecordCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
if (command.Year is < 1900 or > 3000)
|
|
{
|
|
throw new ContentManagementException("Scoreline record year is invalid.", "scoreline_year_invalid");
|
|
}
|
|
|
|
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
|
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
|
|
await AssertReferenceAsync<Major>(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;
|
|
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_record_not_found");
|
|
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<ScorelineRecord>(item);
|
|
}
|
|
|
|
public async Task<CatalogList<int>> GetScorelineYearsAsync(
|
|
DirectContentActor actor,
|
|
AdminLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
|
var regionIds = scope.RegionIds.ToArray();
|
|
var query = dbContext.ScorelineRecords.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId)
|
|
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
|
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<int>(years);
|
|
}
|
|
|
|
public async Task<CatalogList<ScorelineTrendItem>> GetScorelineTrendAsync(
|
|
DirectContentActor actor,
|
|
AdminLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var years = await GetScorelineYearsAsync(actor, filter, cancellationToken);
|
|
var items = new List<ScorelineTrendItem>();
|
|
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<ScorelineTrendItem>(items);
|
|
}
|
|
|
|
public async Task<CatalogList<VideoManagementItem>> 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<VideoManagementItem>(items);
|
|
}
|
|
|
|
public async Task<ContentManagementResult<VideoManagementItem>> UpsertVideoAsync(
|
|
DirectContentActor actor,
|
|
VideoExplanationCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
|
|
await AssertReferenceAsync<Subject>(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<VideoManagementItem>(ToVideoItem(item));
|
|
}
|
|
|
|
public async Task<ContentManagementResult<QuestionVideoManagementItem>> BindQuestionVideoAsync(
|
|
DirectContentActor actor,
|
|
QuestionVideoCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertReferenceAsync<Question>(actor.TenantId, command.QuestionId, "question_not_found", cancellationToken);
|
|
await AssertReferenceAsync<VideoExplanation>(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<QuestionVideoManagementItem>(ToQuestionVideoItem(item));
|
|
}
|
|
|
|
public async Task<CatalogList<OperationContentItem>> 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<OperationContentItem>(items);
|
|
}
|
|
|
|
public async Task<ContentManagementResult<OperationContentItem>> 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<OperationContentItem>(item);
|
|
}
|
|
|
|
public Task<SimpleImportResult> PreviewImportAsync(
|
|
DirectContentActor actor,
|
|
SimpleImportCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return CreateImportJobAsync(actor, command with { DryRun = true }, execute: false, cancellationToken);
|
|
}
|
|
|
|
public Task<SimpleImportResult> ExecuteImportAsync(
|
|
DirectContentActor actor,
|
|
SimpleImportCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return CreateImportJobAsync(actor, command with { DryRun = false }, execute: true, cancellationToken);
|
|
}
|
|
|
|
public async Task<ContentImportJobDetail> GetImportJobAsync(
|
|
DirectContentActor actor,
|
|
Guid jobId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var job = await dbContext.ContentImportJobs.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId && item.Id == jobId)
|
|
.Select(item => ToJobItem(item))
|
|
.SingleOrDefaultAsync(cancellationToken);
|
|
if (job is null)
|
|
{
|
|
throw new ContentManagementException("Import job was not found.", "import_job_not_found");
|
|
}
|
|
|
|
var items = await dbContext.ContentImportItems.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId && item.JobId == jobId)
|
|
.OrderBy(item => item.RowNo)
|
|
.Take(MaxLimit)
|
|
.Select(item => ToImportItem(item))
|
|
.ToArrayAsync(cancellationToken);
|
|
var issues = await dbContext.ContentImportIssues.AsNoTracking()
|
|
.Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId)
|
|
.OrderBy(issue => issue.RowNo)
|
|
.ThenBy(issue => issue.CreatedAt)
|
|
.Take(MaxLimit)
|
|
.Select(issue => new ContentImportIssueModel(
|
|
issue.Id,
|
|
issue.JobId,
|
|
issue.ItemId,
|
|
issue.RowNo,
|
|
issue.Severity,
|
|
issue.Code,
|
|
issue.FieldPath,
|
|
issue.Message,
|
|
issue.Details))
|
|
.ToArrayAsync(cancellationToken);
|
|
|
|
return new ContentImportJobDetail(job, items, issues);
|
|
}
|
|
|
|
public async Task<CatalogList<ContentImportIssueModel>> 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<ContentImportIssueModel>(issues);
|
|
}
|
|
|
|
public async Task<ImportPostCheckResult> 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<ImportPostCheckResult> 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<SimpleImportResult> 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<ContentImportItem>();
|
|
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<Banner> UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken)
|
|
{
|
|
await AssertReferenceAsync<Region>(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<Faq> UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken)
|
|
{
|
|
await AssertReferenceAsync<Region>(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<Announcement> 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<ExamDate> UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.ExamName);
|
|
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
|
await AssertReferenceAsync<School>(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<QuestionBank>(tenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken);
|
|
await AssertReferenceAsync<Subject>(tenantId, command.SubjectId, "subject_not_found", cancellationToken);
|
|
await AssertReferenceAsync<Category>(tenantId, command.CategoryId, "category_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ModuleNode>(tenantId, command.NodeId, "module_node_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ContentEntry>(tenantId, command.EntryId, "entry_not_found", cancellationToken);
|
|
await AssertReferenceAsync<ContentNode>(tenantId, command.ContentNodeId, "node_not_found", cancellationToken);
|
|
await AssertReferenceAsync<QuestionCollection>(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 reference = await questionReferenceService.ResolveAsync(
|
|
actor.TenantId,
|
|
actor.UserId,
|
|
new QuestionLocator(QuestionSource.Tenant, question.Id),
|
|
cancellationToken);
|
|
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,
|
|
QuestionReferenceId = reference.Id,
|
|
QuestionOwnerTenantId = reference.QuestionOwnerTenantId,
|
|
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<CurrentDataScope> RequireDataScopeAsync(
|
|
DirectContentActor actor,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var access = await currentAccessContext.GetAsync(cancellationToken);
|
|
if (!access.IsCurrentTenantMember ||
|
|
access.UserId != actor.UserId ||
|
|
access.TenantId != actor.TenantId ||
|
|
!access.HasTenantPermission(BackendPermissions.TenantContentManage))
|
|
{
|
|
throw new ContentManagementException("Tenant content access was denied.", "content_access_denied");
|
|
}
|
|
|
|
return access.DataScope;
|
|
}
|
|
|
|
private static void EnsureRegionWriteAllowed(
|
|
CurrentDataScope scope,
|
|
DirectContentActor actor,
|
|
Guid? currentRegionId,
|
|
Guid? targetRegionId,
|
|
bool isNew,
|
|
string notFoundCode)
|
|
{
|
|
var canAccessCurrent = isNew || scope.AllowsResource(actor.UserId, regionId: currentRegionId);
|
|
var canAccessTarget = scope.AllowsResource(actor.UserId, regionId: targetRegionId);
|
|
if (!canAccessCurrent || !canAccessTarget)
|
|
{
|
|
throw new ContentManagementException("Content resource was not found.", notFoundCode);
|
|
}
|
|
}
|
|
|
|
private async Task<TEntity?> ResolveByIdOrLegacyAsync<TEntity>(
|
|
DbSet<TEntity> 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<string?>(item, "LegacyId") == normalizedLegacyId,
|
|
cancellationToken);
|
|
}
|
|
|
|
private async Task AssertReferenceAsync<TEntity>(
|
|
Guid tenantId,
|
|
Guid? id,
|
|
string code,
|
|
CancellationToken cancellationToken)
|
|
where TEntity : class
|
|
{
|
|
if (!id.HasValue)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var exists = await dbContext.Set<TEntity>()
|
|
.AnyAsync(item => EF.Property<Guid>(item, "TenantId") == tenantId && EF.Property<Guid>(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<TEnum>(string? value, TEnum fallback, string code)
|
|
where TEnum : struct
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return fallback;
|
|
}
|
|
|
|
if (Enum.TryParse<TEnum>(value.Trim(), ignoreCase: true, out var parsed))
|
|
{
|
|
return parsed;
|
|
}
|
|
|
|
throw new ContentManagementException("Enum value is invalid.", code);
|
|
}
|
|
|
|
private static TEnum? ParseNullable<TEnum>(string? value, string code)
|
|
where TEnum : struct
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (Enum.TryParse<TEnum>(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
|
|
};
|
|
}
|
|
}
|