using Microsoft.EntityFrameworkCore; using System.Text.Json; using Tiku.Application.Learning; using Tiku.Domain.Content; using Tiku.Domain.Learning; using Tiku.Domain.QuestionBanks; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Learning; public sealed class LearningActivityService(TikuDbContext dbContext) : ILearningActivityService { private const int DefaultLimit = 100; private const int MaxLimit = 500; public async Task SubmitAnswerAsync( LearningActor actor, SubmitAnswerCommand command, CancellationToken cancellationToken = default) { var question = await dbContext.Questions .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Id == command.QuestionId && item.Status == QuestionStatus.Published) .Select(item => new { item.Id, item.CurrentVersionId }) .SingleOrDefaultAsync(cancellationToken); if (question is null) { throw new LearningResourceNotFoundException("question_not_found", "Question was not found."); } if (command.PracticeSessionId.HasValue) { var sessionExists = await dbContext.PracticeSessions.AnyAsync( session => session.TenantId == actor.TenantId && session.UserId == actor.UserId && session.Id == command.PracticeSessionId.Value, cancellationToken); if (!sessionExists) { throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found."); } } var now = DateTimeOffset.UtcNow; var record = new AnswerRecord { TenantId = actor.TenantId, UserId = actor.UserId, QuestionId = question.Id, QuestionVersionId = question.CurrentVersionId, PracticeSessionId = command.PracticeSessionId, SelectedOptions = JsonSerializer.SerializeToElement(command.SelectedOptions ?? []), AnswerText = command.AnswerText, IsCorrect = command.SelfJudgedCorrect, AnsweredAt = now, CreatedAt = now }; dbContext.AnswerRecords.Add(record); if (command.SelfJudgedCorrect == false) { var wrongQuestion = await dbContext.WrongQuestions.FindAsync( [actor.TenantId, actor.UserId, question.Id], cancellationToken); if (wrongQuestion is null) { dbContext.WrongQuestions.Add(new WrongQuestion { TenantId = actor.TenantId, UserId = actor.UserId, QuestionId = question.Id, WrongCount = 1, LastWrongAt = now }); } else { wrongQuestion.WrongCount++; wrongQuestion.LastWrongAt = now; wrongQuestion.ResolvedAt = null; } } await dbContext.SaveChangesAsync(cancellationToken); return ToItem(record); } public async Task> GetFavoriteQuestionsAsync( LearningActor actor, LearningLimitFilter filter, CancellationToken cancellationToken = default) { var items = await dbContext.FavoriteQuestions .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId) .OrderByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(item => new FavoriteQuestionItem( item.QuestionId, item.Source, item.CreatedAt)) .ToArrayAsync(cancellationToken); return new LearningList(items); } public async Task ToggleFavoriteQuestionAsync( LearningActor actor, QuestionActionCommand command, CancellationToken cancellationToken = default) { await EnsureQuestionExistsAsync(actor.TenantId, command.QuestionId, cancellationToken); var favorite = command.Favorite ?? true; var item = await dbContext.FavoriteQuestions.FindAsync( [actor.TenantId, actor.UserId, command.QuestionId], cancellationToken); if (favorite) { if (item is null) { dbContext.FavoriteQuestions.Add(new FavoriteQuestion { TenantId = actor.TenantId, UserId = actor.UserId, QuestionId = command.QuestionId, Source = "api", CreatedAt = DateTimeOffset.UtcNow }); } } else if (item is not null) { dbContext.FavoriteQuestions.Remove(item); } await dbContext.SaveChangesAsync(cancellationToken); return new LearningActionResult(true, favorite); } public async Task> GetWrongQuestionsAsync( LearningActor actor, LearningLimitFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.WrongQuestions .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase)) { query = query.Where(item => item.ResolvedAt == null); } var items = await query .OrderByDescending(item => item.LastWrongAt) .Take(ResolveLimit(filter.Limit)) .Select(item => new WrongQuestionItem( item.QuestionId, item.WrongCount, item.LastWrongAt, item.ResolvedAt)) .ToArrayAsync(cancellationToken); return new LearningList(items); } public async Task ResolveWrongQuestionAsync( LearningActor actor, QuestionActionCommand command, CancellationToken cancellationToken = default) { var item = await dbContext.WrongQuestions.FindAsync( [actor.TenantId, actor.UserId, command.QuestionId], cancellationToken); if (item is null) { throw new LearningResourceNotFoundException("wrong_question_not_found", "Wrong question was not found."); } item.ResolvedAt = DateTimeOffset.UtcNow; await dbContext.SaveChangesAsync(cancellationToken); return new LearningActionResult(true); } public async Task> GetWordProgressAsync( LearningActor actor, LearningLimitFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.UserWordProgress .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (filter.UnitId.HasValue) { query = query.Where(item => dbContext.VocabularyWords.Any(word => word.TenantId == actor.TenantId && word.Id == item.WordId && word.UnitId == filter.UnitId.Value)); } if (TryParseWordProgressStatus(filter.Status, out var status)) { query = query.Where(item => item.Status == status); } var items = await query .OrderBy(item => item.NextReviewAt == null) .ThenBy(item => item.NextReviewAt) .ThenByDescending(item => item.UpdatedAt) .Take(ResolveLimit(filter.Limit)) .Select(item => new WordProgressItem( item.WordId, item.Status, item.CorrectCount, item.WrongCount, item.LastReviewAt, item.NextReviewAt, item.ReviewCount, item.CorrectStreak, item.LastResult, item.DueLevel, item.Metadata)) .ToArrayAsync(cancellationToken); return new LearningList(items); } public async Task UpdateWordProgressAsync( LearningActor actor, WordProgressCommand command, CancellationToken cancellationToken = default) { await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken); var now = DateTimeOffset.UtcNow; var item = await dbContext.UserWordProgress .SingleOrDefaultAsync( progress => progress.TenantId == actor.TenantId && progress.UserId == actor.UserId && progress.WordId == command.WordId, cancellationToken); if (item is null) { item = new UserWordProgress { TenantId = actor.TenantId, UserId = actor.UserId, WordId = command.WordId }; dbContext.UserWordProgress.Add(item); } if (TryParseWordProgressStatus(command.Status, out var status)) { item.Status = status; } else if (string.IsNullOrWhiteSpace(command.Status)) { item.Status = WordProgressStatus.Learning; } else { throw new LearningValidationException("invalid_word_status", "Word progress status is invalid."); } var correctDelta = command.CorrectDelta ?? 0; var wrongDelta = command.WrongDelta ?? 0; item.CorrectCount += correctDelta; item.WrongCount += wrongDelta; item.ReviewCount += correctDelta + wrongDelta; item.CorrectStreak = wrongDelta > 0 ? 0 : item.CorrectStreak + correctDelta; item.LastResult = wrongDelta > 0 ? WordReviewResult.Wrong : correctDelta > 0 ? WordReviewResult.Correct : item.LastResult; item.LastReviewAt = correctDelta + wrongDelta > 0 ? now : item.LastReviewAt; item.NextReviewAt = command.NextReviewAt ?? item.NextReviewAt; item.DueLevel = item.Status == WordProgressStatus.Mastered ? WordDueLevel.Mastered : item.WrongCount > 0 && item.CorrectStreak == 0 ? WordDueLevel.Again : item.DueLevel; await dbContext.SaveChangesAsync(cancellationToken); return ToItem(item); } public async Task> GetFavoriteWordsAsync( LearningActor actor, LearningLimitFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.UserWordFavorites .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (filter.UnitId.HasValue) { query = query.Where(item => dbContext.VocabularyWords.Any(word => word.TenantId == actor.TenantId && word.Id == item.WordId && word.UnitId == filter.UnitId.Value)); } var items = await query .OrderByDescending(item => item.FavoritedAt ?? item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(item => new FavoriteWordItem( item.WordId, item.Note, item.FavoritedAt)) .ToArrayAsync(cancellationToken); return new LearningList(items); } public async Task ToggleFavoriteWordAsync( LearningActor actor, FavoriteWordCommand command, CancellationToken cancellationToken = default) { await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken); var favorite = command.Favorite ?? true; var item = await dbContext.UserWordFavorites .SingleOrDefaultAsync( favoriteWord => favoriteWord.TenantId == actor.TenantId && favoriteWord.UserId == actor.UserId && favoriteWord.WordId == command.WordId, cancellationToken); if (favorite) { if (item is null) { item = new UserWordFavorite { TenantId = actor.TenantId, UserId = actor.UserId, WordId = command.WordId }; dbContext.UserWordFavorites.Add(item); } item.Note = command.Note ?? item.Note; item.FavoritedAt ??= DateTimeOffset.UtcNow; } else if (item is not null) { dbContext.UserWordFavorites.Remove(item); } await dbContext.SaveChangesAsync(cancellationToken); return new LearningActionResult(true, favorite); } private async Task EnsureQuestionExistsAsync( Guid tenantId, Guid questionId, CancellationToken cancellationToken) { var exists = await dbContext.Questions.AnyAsync( question => question.TenantId == tenantId && question.Id == questionId && question.Status == QuestionStatus.Published, cancellationToken); if (!exists) { throw new LearningResourceNotFoundException("question_not_found", "Question was not found."); } } private async Task EnsureWordExistsAsync( Guid tenantId, Guid wordId, CancellationToken cancellationToken) { var exists = await dbContext.VocabularyWords.AnyAsync( word => word.TenantId == tenantId && word.Id == wordId && word.IsActive, cancellationToken); if (!exists) { throw new LearningResourceNotFoundException("word_not_found", "Word was not found."); } } private static AnswerRecordItem ToItem(AnswerRecord record) { return new AnswerRecordItem( record.Id, record.QuestionId!.Value, record.QuestionVersionId, record.PracticeSessionId, record.SelectedOptions, record.AnswerText, record.IsCorrect, record.AnsweredAt); } private static WordProgressItem ToItem(UserWordProgress item) { return new WordProgressItem( item.WordId, item.Status, item.CorrectCount, item.WrongCount, item.LastReviewAt, item.NextReviewAt, item.ReviewCount, item.CorrectStreak, item.LastResult, item.DueLevel, item.Metadata); } private static bool TryParseWordProgressStatus(string? value, out WordProgressStatus status) { return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out status); } private static int ResolveLimit(int? limit) { return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit); } private static string? NormalizeEnumValue(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Replace("_", string.Empty, StringComparison.Ordinal) .Replace("-", string.Empty, StringComparison.Ordinal); } } public class LearningException(string code, string message) : Exception(message) { public string Code { get; } = code; } public sealed class LearningResourceNotFoundException(string code, string message) : LearningException(code, message); public sealed class LearningValidationException(string code, string message) : LearningException(code, message);