using System.Text; using System.Text.Json; 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 PracticeBlueprintManagementService(ContentManagementDependencies dependencies) : ContentManagementServiceBase(dependencies), IPracticeBlueprintManagementService { public async Task> GetPracticeBlueprintsAsync( ContentManagementActor actor, ContentManagementFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var query = questionBankPersistence.PracticeBlueprints .AsNoTracking() .Where(blueprint => blueprint.TenantId == actor.TenantId) .ApplyDataScope( scope, blueprint => blueprint.CreatedBy == actor.UserId, blueprint => blueprint.RegionId.HasValue && regionIds.Contains(blueprint.RegionId.Value)); if (!filter.IncludeInactive) query = query.Where(blueprint => blueprint.Status == ContentStatus.Active); if (filter.RegionId.HasValue) query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value); if (filter.EntryId.HasValue) query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value); if (filter.NodeId.HasValue) query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value); if (filter.CollectionId.HasValue) query = query.Where(blueprint => blueprint.CollectionId == filter.CollectionId.Value); if (TryParse(filter.Mode, out PracticeMode mode)) query = query.Where(blueprint => blueprint.Mode == mode); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(blueprint => blueprint.Name.Contains(keyword)); } var items = await query .OrderBy(blueprint => blueprint.SortOrder) .ThenBy(blueprint => blueprint.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(blueprint => ToBlueprintItem(blueprint)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> UpsertPracticeBlueprintAsync( ContentManagementActor actor, UpsertPracticeBlueprintCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken); await AssertReferenceAsync(actor.TenantId, command.CollectionId, "collection_not_found", cancellationToken); var blueprint = await ResolveEntityByIdOrLegacyAsync( questionBankPersistence.PracticeBlueprints, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = blueprint is null; if (command.Id.HasValue && (blueprint is null || blueprint.Id != command.Id.Value)) throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); if (blueprint is not null && !scope.AllowsResource(actor.UserId, blueprint.CreatedBy, blueprint.RegionId)) throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); if (blueprint is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); blueprint ??= new PracticeBlueprint { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, CreatedBy = actor.UserId }; blueprint.RegionId = command.RegionId; blueprint.EntryId = command.EntryId; blueprint.NodeId = command.NodeId; blueprint.CollectionId = command.CollectionId; blueprint.LegacyId = Normalize(command.LegacyId); blueprint.Name = command.Name.Trim(); blueprint.Mode = Parse(command.Mode, PracticeMode.Sequential, "practice_mode_invalid"); blueprint.AssemblyType = Parse(command.AssemblyType, PracticeAssemblyType.Collection, "practice_assembly_type_invalid"); blueprint.QuestionLimit = command.QuestionLimit; blueprint.DurationMinutes = command.DurationMinutes; blueprint.TotalScore = command.TotalScore; blueprint.PassScore = command.PassScore; blueprint.Sections = JsonArrayOrDefault(command.Sections); blueprint.Rules = JsonObjectOrDefault(command.Rules); blueprint.AccessRules = JsonObjectOrDefault(command.AccessRules); blueprint.Status = Parse(command.Status, ContentStatus.Active, "content_status_invalid"); blueprint.SortOrder = command.Order ?? 0; blueprint.UpdatedBy = actor.UserId; if (isNew) questionBankPersistence.PracticeBlueprints.Add(blueprint); await questionBankPersistence.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToBlueprintItem(blueprint)); } public ImportFieldMappingItem GetImportFieldMapping(string importType) { var spec = ResolveImportSpec(importType); return new ImportFieldMappingItem( spec.ImportType, spec.Title, spec.Description, spec.Fields, spec.Fields.Where(field => field.Required).Select(field => field.Field).ToArray()); } public ImportTemplateItem GetImportTemplate(string importType, string? format) { var spec = ResolveImportSpec(importType); var normalizedFormat = string.IsNullOrWhiteSpace(format) ? "json" : format.Trim().ToLowerInvariant(); var content = normalizedFormat switch { "json" => JsonSerializer.Serialize(spec.JsonExample, new JsonSerializerOptions { WriteIndented = true }), "csv" => string.Join( "\n", spec.CsvRows.Select(row => string.Join(",", row.Select(EscapeCsv)))), _ => throw new ContentManagementException("Import template format is not supported.", "import_template_format_invalid") }; return new ImportTemplateItem( spec.ImportType, normalizedFormat, $"{spec.ImportType}-import-template.{normalizedFormat}", normalizedFormat == "csv" ? "text/csv" : "application/json", Convert.ToBase64String(Encoding.UTF8.GetBytes(content)), content, spec.Fields); } }