using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Domain.Catalog; using Tiku.Domain.Content; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; internal sealed class HandbookManagementService(DirectContentServiceDependencies dependencies) : DirectContentServiceBase(dependencies), IHandbookManagementService { public async Task> GetHandbookSubjectsAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var query = contentAssetPersistence.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(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) { var scope = await RequireDataScopeAsync(actor, cancellationToken); 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(contentAssetPersistence.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(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) contentAssetPersistence.HandbookSubjects.Add(item); await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } public async Task> GetHandbookChaptersAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var query = contentAssetPersistence.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(contentAssetPersistence.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) contentAssetPersistence.HandbookChapters.Add(item); await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } public async Task> GetHandbookEntriesAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var query = contentAssetPersistence.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(contentAssetPersistence.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) contentAssetPersistence.HandbookEntries.Add(item); await unitOfWork.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } }