using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Domain.Content; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; internal sealed class ContentNodeManagementService(ContentManagementDependencies dependencies) : ContentManagementServiceBase(dependencies), IContentNodeManagementService { public async Task> GetNodesAsync( ContentManagementActor actor, ContentManagementFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); if (!filter.EntryId.HasValue) throw new ContentManagementException("entryId is required.", "entry_id_required"); await AssertEntryAsync(actor, scope, filter.EntryId, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var query = questionBankPersistence.ContentNodes .AsNoTracking() .Where(node => node.TenantId == actor.TenantId && node.EntryId == filter.EntryId.Value) .ApplyDataScope( scope, node => node.CreatedBy == actor.UserId, node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value)); if (!filter.IncludeInactive) query = query.Where(node => node.IsActive); if (filter.RegionId.HasValue) query = query.Where(node => node.RegionId == filter.RegionId.Value); if (filter.ParentId is not null) { if (string.Equals(filter.ParentId, "root", StringComparison.OrdinalIgnoreCase)) query = query.Where(node => node.ParentId == null); else if (Guid.TryParse(filter.ParentId, out var parentId)) query = query.Where(node => node.ParentId == parentId); } if (TryParse(filter.MarkerType, out ContentMarkerType markerType)) query = query.Where(node => node.MarkerType == markerType); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(node => node.Name.Contains(keyword) || (node.NodeKey != null && node.NodeKey.Contains(keyword))); } query = string.Equals(filter.Mode, "flat", StringComparison.OrdinalIgnoreCase) ? query.OrderBy(node => node.Path).ThenBy(node => node.SortOrder) : query.OrderBy(node => node.SortOrder).ThenBy(node => node.CreatedAt); var items = await query .Take(ResolveLimit(filter.Limit)) .Select(node => ToNodeItem(node)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> UpsertNodeAsync( ContentManagementActor actor, UpsertContentNodeCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); await AssertNodeAsync(actor, scope, command.ParentId, cancellationToken); var nodeKey = Normalize(command.NodeKey) ?? Normalize(command.Id?.ToString("N")) ?? Guid.NewGuid().ToString("N"); var node = await ResolveEntityAsync( questionBankPersistence.ContentNodes, actor.TenantId, command.Id, item => item.EntryId == command.EntryId && item.NodeKey == nodeKey, cancellationToken); var isNew = node is null; if (command.Id.HasValue && (node is null || node.Id != command.Id.Value)) throw new ContentManagementException("Content node was not found.", "node_not_found"); if (node is not null && !scope.AllowsResource(actor.UserId, node.CreatedBy, node.RegionId)) throw new ContentManagementException("Content node was not found.", "node_not_found"); if (node is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) throw new ContentManagementException("Content node was not found.", "node_not_found"); node ??= new ContentNode { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, EntryId = command.EntryId, NodeKey = nodeKey, CreatedBy = actor.UserId }; var path = await BuildNodePathAsync(actor.TenantId, command.EntryId, node.Id, command.ParentId, cancellationToken); node.EntryId = command.EntryId; node.RegionId = command.RegionId; node.ParentId = command.ParentId; node.LegacyId = Normalize(command.LegacyId); node.Name = command.Name.Trim(); node.NodeType = Parse(command.NodeType, ContentNodeType.Category, "node_type_invalid"); node.MarkerType = ParseNullable(command.MarkerType, "marker_type_invalid"); node.MarkerConfig = JsonObjectOrDefault(command.MarkerConfig); node.Path = path.Path; node.Depth = path.Depth; node.SortOrder = command.Order ?? 0; node.IsActive = command.IsActive ?? true; node.IsSelectable = command.IsSelectable ?? true; node.IsLeaf = command.IsLeaf ?? false; node.AccessRules = JsonObjectOrDefault(command.AccessRules); node.Metadata = JsonObjectOrDefault(command.Metadata); node.UpdatedBy = actor.UserId; if (isNew) questionBankPersistence.ContentNodes.Add(node); if (command.ParentId.HasValue) { var parent = await questionBankPersistence.ContentNodes.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.ParentId.Value, cancellationToken); if (parent is not null) parent.IsLeaf = false; } await questionBankPersistence.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToNodeItem(node)); } }