using System.Text.Json; using Microsoft.EntityFrameworkCore; using Npgsql; using Tiku.Application.Learning; using Tiku.Domain.Learning; namespace Tiku.Infrastructure.Learning; internal sealed class PracticeSessionService( LearningServiceDependencies dependencies, IV2PracticeSessionService v2PracticeSessionService) : LearningActivityServiceBase(dependencies), IPracticeSessionService { public async Task CreatePracticeSessionAsync( LearningActor actor, PracticeSessionCommand command, CancellationToken cancellationToken = default) { return await v2PracticeSessionService.CreateAsync(actor, command, cancellationToken); } public async Task GetPracticeSessionDetailAsync( LearningActor actor, PracticeSessionFilter filter, CancellationToken cancellationToken = default) { var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken); await strongRevocationService.EnsureVersionAsync( actor, session.StrongRevocationVersion, cancellationToken); var orderedQuestions = await LoadSessionQuestionItemsAsync( actor.TenantId, session.Id, cancellationToken); var answers = await learningPersistence.AnswerRecords .AsNoTracking() .Where(answer => answer.TenantId == actor.TenantId && answer.UserId == actor.UserId && answer.PracticeSessionId == session.Id && learningPersistence.CurrentAnswers.Any(current => current.TenantId == answer.TenantId && current.AnswerRecordId == answer.Id)) .ToArrayAsync(cancellationToken); var answersByQuestion = answers .GroupBy(answer => answer.SessionQuestionId) .ToDictionary( group => group.Key, group => ToItem( group.OrderByDescending(answer => answer.Revision).First())); var revealAll = session.Status is PracticeSessionStatus.Submitted or PracticeSessionStatus.PendingReview; var revealAnswered = !DefersSolutionUntilSubmission(session.Mode); var revealQuestionIds = revealAll ? orderedQuestions.Select(item => item.SessionQuestionId).ToArray() : revealAnswered ? answersByQuestion.Keys.ToArray() : []; var solutions = revealQuestionIds.Length == 0 ? new Dictionary() : await LoadSolutionsAsync(actor.TenantId, session.Id, revealQuestionIds, cancellationToken); return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion, solutions); } private async Task> LoadSolutionsAsync( Guid tenantId, Guid sessionId, IReadOnlyCollection revealQuestionIds, CancellationToken cancellationToken) { var sessionQuestions = await learningPersistence.PracticeSessionQuestions.AsNoTracking() .Where(item => item.TenantId == tenantId && item.PracticeSessionId == sessionId && revealQuestionIds.Contains(item.Id)) .ToArrayAsync(cancellationToken); return await v2PracticeSessionService.LoadSolutionsAsync(sessionQuestions, cancellationToken); } public async Task SubmitPracticeSessionAsync( LearningActor actor, SubmitPracticeSessionCommand command, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(command.IdempotencyKey)) throw new LearningValidationException("idempotency_key_required", "An idempotency key is required."); await using var transaction = await unitOfWork.Database.BeginTransactionAsync(cancellationToken); var submissionLockKey = $"practice-submit:{actor.TenantId:N}:{actor.UserId:N}:{command.PracticeSessionId:N}"; await unitOfWork.Database.ExecuteSqlInterpolatedAsync( $"SELECT pg_advisory_xact_lock(hashtextextended({submissionLockKey}, 0))", cancellationToken); var session = await learningPersistence.PracticeSessions.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Id == command.PracticeSessionId, cancellationToken) ?? throw new LearningResourceNotFoundException( "practice_session_not_found", "Practice session was not found."); await strongRevocationService.EnsureVersionAsync( actor, session.StrongRevocationVersion, cancellationToken); var requestHash = HashSubmission(command); var existingOperation = await learningPersistence.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.PracticeSessionId == session.Id && item.OperationType == "submit" && item.IdempotencyKey == command.IdempotencyKey, cancellationToken); if (existingOperation is not null) { if (!string.Equals(existingOperation.RequestHash, requestHash, StringComparison.Ordinal)) { SubmissionConflicts.Add(1); throw new LearningValidationException("idempotency_conflict", "The idempotency key was used with a different request."); } IdempotencyReplays.Add(1); return existingOperation.ResponseSnapshot.Deserialize() ?? throw new InvalidOperationException("The stored report response is invalid."); } if (session.Status != PracticeSessionStatus.Active) throw new LearningValidationException("practice_session_not_active", "Only an active practice session can be submitted."); if (session.ExpiresAt.HasValue && session.ExpiresAt <= DateTimeOffset.UtcNow) { session.Status = PracticeSessionStatus.Expired; session.Version++; await unitOfWork.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); throw new LearningValidationException("practice_session_expired", "The practice session has expired."); } session.Status = PracticeSessionStatus.Scoring; session.Version++; var report = await BuildPracticeSessionReportAsync(actor, session, cancellationToken); session.Status = report.IsFinal ? PracticeSessionStatus.Submitted : PracticeSessionStatus.PendingReview; session.FinishedAt = report.SubmittedAt; session.Version++; var response = ToItem(report); learningPersistence.LearningOperationIdempotencies.Add(new LearningOperationIdempotency { TenantId = actor.TenantId, UserId = actor.UserId, PracticeSessionId = session.Id, OperationType = "submit", IdempotencyKey = command.IdempotencyKey.Trim(), RequestHash = requestHash, ResponseSnapshot = JsonSerializer.SerializeToElement(response), CompletedAt = report.SubmittedAt }); try { await unitOfWork.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); } catch (DbUpdateConcurrencyException) { SubmissionConflicts.Add(1); throw new LearningValidationException("practice_session_version_conflict", "The practice session changed during submission."); } catch (DbUpdateException exception) when (exception.InnerException is NpgsqlException) { SubmissionConflicts.Add(1); throw new LearningValidationException("practice_submission_conflict", "The practice session was already submitted by another request."); } return response; } }