using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Catalog; using Tiku.Application.QuestionBanks; using Tiku.Application.Security; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.QuestionBanks; public sealed class QuestionBankQueryService( TikuDbContext dbContext, IPublicQuestionAccessPolicy accessPolicy, ITenantExecutionScope tenantExecutionScope) : 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 limit = ResolveLimit(filter.Limit, DefaultBankLimit, MaxBankLimit); var tenantItems = filter.Source == QuestionSource.Platform ? [] : await dbContext.QuestionBanks .AsNoTracking() .Where(bank => bank.TenantId == filter.TenantId && bank.Status == QuestionBankStatus.Active && (!filter.RegionId.HasValue || bank.RegionId == filter.RegionId.Value || bank.RegionId == null) && (string.IsNullOrWhiteSpace(filter.Keyword) || bank.Name.Contains(filter.Keyword.Trim()))) .OrderBy(bank => bank.Name) .ThenBy(bank => bank.CreatedAt) .Take(limit) .Select(bank => new QuestionBankCatalogItem( bank.Id, bank.RegionId, bank.Name, QuestionSource.Tenant, bank.Status, bank.Metadata)) .ToArrayAsync(cancellationToken); var platformItems = filter.Source == QuestionSource.Tenant || !await CanAccessPlatformAsync(filter.TenantId, cancellationToken) ? [] : await tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( filter.TenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService), "List platform question banks for an entitled tenant", Guid.NewGuid().ToString("N")), async (provider, token) => { var systemDbContext = provider.GetRequiredService(); return await systemDbContext.QuestionBanks.AsNoTracking() .Join( systemDbContext.Tenants.AsNoTracking().Where(tenant => tenant.Mode == TenantMode.PlatformOwned), bank => bank.TenantId, tenant => tenant.Id, (bank, tenant) => bank) .Where(bank => bank.Status == QuestionBankStatus.Active && (!filter.RegionId.HasValue || bank.RegionId == filter.RegionId.Value || bank.RegionId == null) && (string.IsNullOrWhiteSpace(filter.Keyword) || bank.Name.Contains(filter.Keyword.Trim()))) .OrderBy(bank => bank.Name) .Take(limit) .Select(bank => new QuestionBankCatalogItem( bank.Id, bank.RegionId, bank.Name, QuestionSource.Platform, bank.Status, bank.Metadata)) .ToArrayAsync(token); }, cancellationToken); return new CatalogList(tenantItems .Concat(platformItems) .OrderBy(item => item.Name) .Take(limit) .ToArray()); } public async Task> GetQuestionsAsync( QuestionBankFilter filter, CancellationToken cancellationToken = default) { var limit = ResolveLimit(filter.Limit, DefaultQuestionLimit, MaxQuestionLimit); var tenantItems = filter.Source == QuestionSource.Platform ? [] : await ProjectQuestions( dbContext, ApplyQuestionFilters(BaseQuestionQuery(), filter) .OrderByDescending(question => question.CreatedAt) .Take(limit), QuestionSource.Tenant) .ToArrayAsync(cancellationToken); var platformItems = filter.Source == QuestionSource.Tenant || !await CanAccessPlatformAsync(filter.TenantId, cancellationToken) ? [] : await GetPlatformQuestionsAsync(filter, limit, cancellationToken); return new CatalogList(tenantItems.Concat(platformItems).Take(limit).ToArray()); } public async Task GetQuestionAsync( QuestionBankFilter filter, CancellationToken cancellationToken = default) { if (!filter.QuestionId.HasValue) { throw new QuestionBankRequiredFieldException("questionId is required."); } var result = await GetQuestionsAsync(filter with { Limit = 2 }, cancellationToken); return result.Items.SingleOrDefault() ?? 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."); } if (filter.Source == QuestionSource.Platform) { await accessPolicy.EnsureCanStartAsync(filter.TenantId, cancellationToken); var platformItems = await GetPlatformVersionsAsync(filter.QuestionId.Value, filter.TenantId, cancellationToken); return new CatalogList(platformItems); } 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 static IQueryable ProjectQuestions( TikuDbContext context, IQueryable questions, QuestionSource source) { var emptyOptions = JsonDefaults.Array(); var emptyCorrectOptionIndices = JsonDefaults.Array(); var emptySubQuestions = JsonDefaults.Array(); return from question in questions join version in context.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, new QuestionLocator(source, question.Id)); } private async Task CanAccessPlatformAsync(Guid tenantId, CancellationToken cancellationToken) { try { await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken); return true; } catch (PublicQuestionAccessDeniedException) { return false; } } private Task GetPlatformQuestionsAsync( QuestionBankFilter filter, int limit, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( filter.TenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService), "List platform questions for an entitled tenant", Guid.NewGuid().ToString("N")), async (provider, token) => { var systemDbContext = provider.GetRequiredService(); var platformTenantId = await systemDbContext.Tenants.AsNoTracking() .Where(tenant => tenant.Mode == TenantMode.PlatformOwned) .Select(tenant => tenant.Id) .SingleAsync(token); var query = systemDbContext.Questions.AsNoTracking().Where(question => question.TenantId == platformTenantId && question.Status == QuestionStatus.Published && (!filter.QuestionId.HasValue || question.Id == filter.QuestionId.Value) && (!filter.QuestionBankId.HasValue || question.QuestionBankId == filter.QuestionBankId.Value) && (!filter.SubjectId.HasValue || question.SubjectId == filter.SubjectId.Value) && (!filter.CategoryId.HasValue || question.CategoryId == filter.CategoryId.Value) && (!filter.NodeId.HasValue || question.NodeId == filter.NodeId.Value) && (!filter.EntryId.HasValue || question.EntryId == filter.EntryId.Value) && (!filter.ContentNodeId.HasValue || question.ContentNodeId == filter.ContentNodeId.Value) && (filter.QuestionIds == null || filter.QuestionIds.Count == 0 || filter.QuestionIds.Contains(question.Id)) && (string.IsNullOrWhiteSpace(filter.Type) || question.Type == filter.Type.Trim())); 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))); } return await ProjectQuestions( systemDbContext, query.OrderByDescending(question => question.CreatedAt).Take(limit), QuestionSource.Platform) .ToArrayAsync(token); }, cancellationToken); } private Task GetPlatformVersionsAsync( Guid questionId, Guid tenantId, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService), "Read platform question versions for an entitled tenant", Guid.NewGuid().ToString("N")), async (provider, token) => { var systemDbContext = provider.GetRequiredService(); var platformQuestion = await systemDbContext.Questions.AsNoTracking() .Where(question => question.Id == questionId && question.Status == QuestionStatus.Published) .Join( systemDbContext.Tenants.AsNoTracking().Where(tenant => tenant.Mode == TenantMode.PlatformOwned), question => question.TenantId, tenant => tenant.Id, (question, tenant) => new { question.TenantId, question.Id }) .SingleOrDefaultAsync(token); if (platformQuestion is null) { throw new QuestionBankNotFoundException("Question was not found."); } return await systemDbContext.QuestionVersions.AsNoTracking() .Where(version => version.TenantId == platformQuestion.TenantId && version.QuestionId == platformQuestion.Id) .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(token); }, cancellationToken); } 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);