feat: add question bank readonly endpoints
This commit is contained in:
@@ -4,10 +4,12 @@ using Npgsql;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Catalog;
|
||||
using Tiku.Infrastructure.Content;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.QuestionBanks;
|
||||
|
||||
namespace Tiku.Infrastructure;
|
||||
|
||||
@@ -34,6 +36,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
|
||||
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
|
||||
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
274
Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs
Normal file
274
Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs
Normal file
@@ -0,0 +1,274 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
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<CatalogList<QuestionBankCatalogItem>> 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<QuestionBankCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<QuestionCatalogItem>> 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<QuestionCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<QuestionCatalogItem> 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<CatalogList<QuestionVersionCatalogItem>> 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<QuestionVersionCatalogItem>(items);
|
||||
}
|
||||
|
||||
private IQueryable<Question> BaseQuestionQuery()
|
||||
{
|
||||
return dbContext.Questions
|
||||
.AsNoTracking()
|
||||
.Where(question => question.Status == QuestionStatus.Published);
|
||||
}
|
||||
|
||||
private IQueryable<Question> ApplyQuestionFilters(
|
||||
IQueryable<Question> 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<T> ApplyKeyword<T>(IQueryable<T> query, string? keyword)
|
||||
where T : class
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
var trimmed = keyword.Trim();
|
||||
return query.Where(entity => EF.Property<string>(entity, nameof(QuestionBank.Name)).Contains(trimmed));
|
||||
}
|
||||
|
||||
private IQueryable<QuestionCatalogItem> ProjectQuestions(IQueryable<Question> questions)
|
||||
{
|
||||
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 ? default : version.Options,
|
||||
version == null ? null : version.CorrectOptionIndex,
|
||||
version == null ? default : version.CorrectOptionIndices,
|
||||
version == null ? null : version.AnswerText,
|
||||
version == null ? null : version.Explanation,
|
||||
version == null ? default : 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);
|
||||
Reference in New Issue
Block a user