using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.QuestionBanks; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.QuestionBanks; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.QuestionBanks; public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestionBankQueryService { private const int DefaultQuestionLimit = 200; private const int MaxQuestionLimit = 500; private const int DefaultBankLimit = 100; private const int MaxBankLimit = 500; public async Task> GetQuestionBanksAsync( QuestionBankFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.QuestionBanks .AsNoTracking() .Where(bank => bank.TenantId == filter.TenantId && bank.Status == QuestionBankStatus.Active); if (filter.RegionId.HasValue) { query = query.Where(bank => bank.RegionId == filter.RegionId.Value || bank.RegionId == null); } query = ApplyKeyword(query, filter.Keyword); var items = await query .OrderBy(bank => bank.Name) .ThenBy(bank => bank.CreatedAt) .Take(ResolveLimit(filter.Limit, DefaultBankLimit, MaxBankLimit)) .Select(bank => new QuestionBankCatalogItem( bank.Id, bank.RegionId, bank.Name, bank.SourceScope, bank.Status, bank.Metadata)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetQuestionsAsync( QuestionBankFilter filter, CancellationToken cancellationToken = default) { var questions = ApplyQuestionFilters(BaseQuestionQuery(), filter) .OrderByDescending(question => question.CreatedAt) .Take(ResolveLimit(filter.Limit, DefaultQuestionLimit, MaxQuestionLimit)); var items = await ProjectQuestions(questions) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task GetQuestionAsync( QuestionBankFilter filter, CancellationToken cancellationToken = default) { if (!filter.QuestionId.HasValue) { throw new QuestionBankRequiredFieldException("questionId is required."); } var questions = ApplyQuestionFilters(BaseQuestionQuery(), filter) .Where(question => question.Id == filter.QuestionId.Value); var question = await ProjectQuestions(questions) .SingleOrDefaultAsync(cancellationToken); return question ?? throw new QuestionBankNotFoundException("Question was not found."); } public async Task> GetQuestionVersionsAsync( QuestionBankFilter filter, CancellationToken cancellationToken = default) { if (!filter.QuestionId.HasValue) { throw new QuestionBankRequiredFieldException("questionId is required."); } var questionExists = await dbContext.Questions .AsNoTracking() .AnyAsync( question => question.TenantId == filter.TenantId && question.Id == filter.QuestionId.Value && question.Status == QuestionStatus.Published, cancellationToken); if (!questionExists) { throw new QuestionBankNotFoundException("Question was not found."); } var items = await dbContext.QuestionVersions .AsNoTracking() .Where(version => version.TenantId == filter.TenantId && version.QuestionId == filter.QuestionId.Value) .OrderByDescending(version => version.VersionNo) .Select(version => new QuestionVersionCatalogItem( version.Id, version.QuestionId, version.VersionNo, version.Content, version.Options, version.CorrectOptionIndex, version.CorrectOptionIndices, version.AnswerText, version.Explanation, version.SubQuestions, version.CodeLang, version.CodeTemplate, version.CreatedAt)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } private IQueryable BaseQuestionQuery() { return dbContext.Questions .AsNoTracking() .Where(question => question.Status == QuestionStatus.Published); } private IQueryable ApplyQuestionFilters( IQueryable query, QuestionBankFilter filter) { query = query.Where(question => question.TenantId == filter.TenantId); if (filter.QuestionBankId.HasValue) { query = query.Where(question => question.QuestionBankId == filter.QuestionBankId.Value); } if (filter.SubjectId.HasValue) { query = query.Where(question => question.SubjectId == filter.SubjectId.Value); } if (filter.CategoryId.HasValue) { query = query.Where(question => question.CategoryId == filter.CategoryId.Value); } if (filter.NodeId.HasValue) { query = query.Where(question => question.NodeId == filter.NodeId.Value); } if (filter.EntryId.HasValue) { query = query.Where(question => question.EntryId == filter.EntryId.Value); } if (filter.ContentNodeId.HasValue) { query = query.Where(question => question.ContentNodeId == filter.ContentNodeId.Value); } if (filter.CollectionId.HasValue) { query = query.Where(question => question.PrimaryCollectionId == filter.CollectionId.Value || dbContext.QuestionCollectionItems.Any(item => item.TenantId == question.TenantId && item.QuestionId == question.Id && item.CollectionId == filter.CollectionId.Value)); } if (filter.QuestionIds?.Count > 0) { query = query.Where(question => filter.QuestionIds.Contains(question.Id)); } if (!string.IsNullOrWhiteSpace(filter.Type)) { var type = filter.Type.Trim(); query = query.Where(question => question.Type == type); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(question => question.Type.Contains(keyword) || (question.TypeLabel != null && question.TypeLabel.Contains(keyword)) || dbContext.QuestionVersions.Any(version => version.TenantId == question.TenantId && version.QuestionId == question.Id && version.Id == question.CurrentVersionId && version.Content != null && version.Content.Contains(keyword))); } return query; } private static IQueryable ApplyKeyword(IQueryable query, string? keyword) where T : class { if (string.IsNullOrWhiteSpace(keyword)) { return query; } var trimmed = keyword.Trim(); return query.Where(entity => EF.Property(entity, nameof(QuestionBank.Name)).Contains(trimmed)); } private IQueryable ProjectQuestions(IQueryable questions) { var emptyOptions = JsonDefaults.Array(); var emptyCorrectOptionIndices = JsonDefaults.Array(); var emptySubQuestions = JsonDefaults.Array(); return from question in questions join version in dbContext.QuestionVersions.AsNoTracking() on new { question.TenantId, QuestionId = question.Id, VersionId = question.CurrentVersionId } equals new { version.TenantId, version.QuestionId, VersionId = (Guid?)version.Id } into versions from version in versions.DefaultIfEmpty() select new QuestionCatalogItem( question.Id, question.QuestionBankId, question.SubjectId, question.CategoryId, question.NodeId, question.EntryId, question.ContentNodeId, question.PrimaryCollectionId, question.LegacyId, question.LegacySubjectId, question.LegacyCategoryId, question.LegacyNodeId, question.Type, question.TypeLabel, question.Difficulty, question.Tags, question.ExamMarkers, question.MediaUrl, question.HasVideoExplanation, question.Status, version == null ? null : version.Id, version == null ? null : version.VersionNo, version == null ? null : version.Content, version == null ? emptyOptions : version.Options, version == null ? null : version.CorrectOptionIndex, version == null ? emptyCorrectOptionIndices : version.CorrectOptionIndices, version == null ? null : version.AnswerText, version == null ? null : version.Explanation, version == null ? emptySubQuestions : version.SubQuestions, version == null ? null : version.CodeLang, version == null ? null : version.CodeTemplate); } private static int ResolveLimit(int? limit, int defaultLimit, int maxLimit) { return Math.Clamp(limit ?? defaultLimit, 1, maxLimit); } } public sealed class QuestionBankRequiredFieldException(string message) : Exception(message); public sealed class QuestionBankNotFoundException(string message) : Exception(message);