Compare commits
1 commit
main
...
feature/le
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b61a0d35a |
153 changed files with 363 additions and 30139 deletions
44
.env.example
44
.env.example
|
|
@ -1,44 +0,0 @@
|
||||||
# DeutschLernen Environment Configuration
|
|
||||||
# Copy this file to .env and fill in your actual values
|
|
||||||
# This file is in .gitignore and will NOT be committed to git
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# JWT Configuration
|
|
||||||
# ============================================
|
|
||||||
JWT_KEY=your-super-secret-key-at-least-32-characters-long
|
|
||||||
JWT_ISSUER=DeutschLernen
|
|
||||||
JWT_AUDIENCE=DeutschLernen
|
|
||||||
JWT_EXPIREHOURS=24
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Mistral AI Configuration
|
|
||||||
# Use the API key from appsettings.Development.json
|
|
||||||
# ============================================
|
|
||||||
MISTRAL_APIKEY=YOUR_MISTRAL_API_KEY_HERE
|
|
||||||
MISTRAL_BASEURL=https://api.mistral.ai/v1/
|
|
||||||
MISTRAL_DEFAULTMODEL=mistral-small-latest
|
|
||||||
MISTRAL_TIMEOUTSECONDS=30
|
|
||||||
MISTRAL_MAXRETRIES=3
|
|
||||||
MISTRAL_RATELIMITPERMINUTE=10
|
|
||||||
MISTRAL_ENABLECACHING=true
|
|
||||||
MISTRAL_CACHETTLMINUTES=60
|
|
||||||
MISTRAL_CIRCUITBREAKERFAILURETHRESHOLD=5
|
|
||||||
MISTRAL_CIRCUITBREAKERRESETMINUTES=1
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Vosk Speech Recognition Configuration
|
|
||||||
# ============================================
|
|
||||||
VOSK_PYTHONPATH=python3
|
|
||||||
VOSK_MODELPATH=./models/vosk-model-de-0.22
|
|
||||||
VOSK_SAMPLERATE=16000
|
|
||||||
VOSK_TIMEOUTSECONDS=30
|
|
||||||
VOSK_BEAMWIDTH=20
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Coqui TTS Configuration
|
|
||||||
# ============================================
|
|
||||||
COQUI_PYTHONPATH=python3
|
|
||||||
COQUI_MODELNAME=tts_models/de/deu/fairseq/vits
|
|
||||||
COQUI_AUDIOSTORAGEPATH=./tmp/tts-audio
|
|
||||||
COQUI_MAXTEXTLENGTH=5000
|
|
||||||
COQUI_TIMEOUTSECONDS=60
|
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,10 +1,9 @@
|
||||||
# Dependencies
|
# Create .gitignore (if not already present)
|
||||||
|
echo "# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Environment files
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
@ -26,9 +25,6 @@ obj/
|
||||||
*.suo
|
*.suo
|
||||||
*.cache
|
*.cache
|
||||||
|
|
||||||
# Configuration files (contain secrets)
|
|
||||||
appsettings.json
|
|
||||||
appsettings.*.json
|
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
.coverage
|
.coverage
|
||||||
|
" > .gitignore
|
||||||
|
|
@ -65,21 +65,6 @@ steps:
|
||||||
- docker pull registry.lrhdev.dk/lasserh/deutschlernen-frontend:latest
|
- docker pull registry.lrhdev.dk/lasserh/deutschlernen-frontend:latest
|
||||||
- cd /opt/deutschlernen
|
- cd /opt/deutschlernen
|
||||||
- docker compose down
|
- docker compose down
|
||||||
# Pass JWT and Mistral configuration as environment variables to docker compose
|
|
||||||
- export Jwt__Key=$JWT_KEY
|
|
||||||
- export Jwt__Issuer=$JWT_ISSUER
|
|
||||||
- export Jwt__Audience=$JWT_AUDIENCE
|
|
||||||
- export Jwt__ExpireHours=$JWT_EXPIREHOURS
|
|
||||||
- export Mistral__ApiKey=$MISTRAL_APIKEY
|
|
||||||
- export Mistral__BaseUrl=$MISTRAL_BASEURL
|
|
||||||
- export Mistral__DefaultModel=$MISTRAL_DEFAULTMODEL
|
|
||||||
- export Mistral__TimeoutSeconds=$MISTRAL_TIMEOUTSECONDS
|
|
||||||
- export Mistral__MaxRetries=$MISTRAL_MAXRETRIES
|
|
||||||
- export Mistral__RateLimitPerMinute=$MISTRAL_RATELIMITPERMINUTE
|
|
||||||
- export Mistral__EnableCaching=$MISTRAL_ENABLECACHING
|
|
||||||
- export Mistral__CacheTTLMinutes=$MISTRAL_CACHETTLMINUTES
|
|
||||||
- export Mistral__CircuitBreakerFailureThreshold=$MISTRAL_CIRCUITBREAKERFAILURETHRESHOLD
|
|
||||||
- export Mistral__CircuitBreakerResetMinutes=$MISTRAL_CIRCUITBREAKERRESETMINUTES
|
|
||||||
- docker compose up -d
|
- docker compose up -d
|
||||||
- docker image prune -f
|
- docker image prune -f
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -87,35 +72,5 @@ steps:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: REGISTRY_USERNAME
|
||||||
REGISTRY_PASSWORD:
|
REGISTRY_PASSWORD:
|
||||||
from_secret: REGISTRY_PASSWORD
|
from_secret: REGISTRY_PASSWORD
|
||||||
# JWT Configuration - Woodpecker secrets for production
|
|
||||||
JWT_KEY:
|
|
||||||
from_secret: JWT_KEY
|
|
||||||
JWT_ISSUER:
|
|
||||||
from_secret: JWT_ISSUER
|
|
||||||
JWT_AUDIENCE:
|
|
||||||
from_secret: JWT_AUDIENCE
|
|
||||||
JWT_EXPIREHOURS:
|
|
||||||
from_secret: JWT_EXPIREHOURS
|
|
||||||
# Mistral API Configuration - Woodpecker secrets for production
|
|
||||||
MISTRAL_APIKEY:
|
|
||||||
from_secret: MISTRAL_APIKEY
|
|
||||||
MISTRAL_BASEURL:
|
|
||||||
from_secret: MISTRAL_BASEURL
|
|
||||||
MISTRAL_DEFAULTMODEL:
|
|
||||||
from_secret: MISTRAL_DEFAULTMODEL
|
|
||||||
MISTRAL_TIMEOUTSECONDS:
|
|
||||||
from_secret: MISTRAL_TIMEOUTSECONDS
|
|
||||||
MISTRAL_MAXRETRIES:
|
|
||||||
from_secret: MISTRAL_MAXRETRIES
|
|
||||||
MISTRAL_RATELIMITPERMINUTE:
|
|
||||||
from_secret: MISTRAL_RATELIMITPERMINUTE
|
|
||||||
MISTRAL_ENABLECACHING:
|
|
||||||
from_secret: MISTRAL_ENABLECACHING
|
|
||||||
MISTRAL_CACHETTLMINUTES:
|
|
||||||
from_secret: MISTRAL_CACHETTLMINUTES
|
|
||||||
MISTRAL_CIRCUITBREAKERFAILURETHRESHOLD:
|
|
||||||
from_secret: MISTRAL_CIRCUITBREAKERFAILURETHRESHOLD
|
|
||||||
MISTRAL_CIRCUITBREAKERRESETMINUTES:
|
|
||||||
from_secret: MISTRAL_CIRCUITBREAKERRESETMINUTES
|
|
||||||
when:
|
when:
|
||||||
- branch: main
|
- branch: main
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
namespace GermanApp.Application.DTOs;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for admin user information.
|
|
||||||
/// </summary>
|
|
||||||
public record AdminUserDto(
|
|
||||||
int Id,
|
|
||||||
string Username,
|
|
||||||
string Email,
|
|
||||||
string Role,
|
|
||||||
string CurrentLevel,
|
|
||||||
int Streak,
|
|
||||||
int TotalPoints,
|
|
||||||
DateTime CreatedAt);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for user progress report.
|
|
||||||
/// </summary>
|
|
||||||
public record UserProgressReportDto(
|
|
||||||
int UserId,
|
|
||||||
string Username,
|
|
||||||
string Email,
|
|
||||||
string CurrentLevel,
|
|
||||||
int TotalLessonsCompleted,
|
|
||||||
int TotalQuizzesCompleted,
|
|
||||||
double AverageQuizScore,
|
|
||||||
int TotalPoints,
|
|
||||||
int CurrentStreak,
|
|
||||||
DateTime LastActivityDate);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for lesson completion status.
|
|
||||||
/// </summary>
|
|
||||||
public record LessonCompletionDto(
|
|
||||||
int LessonId,
|
|
||||||
string LessonTitle,
|
|
||||||
string LevelCode,
|
|
||||||
bool IsCompleted,
|
|
||||||
DateTime? CompletedAt);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for user quiz result (for admin reports).
|
|
||||||
/// </summary>
|
|
||||||
public record UserQuizResultDto(
|
|
||||||
int QuizId,
|
|
||||||
string QuizTitle,
|
|
||||||
int Score,
|
|
||||||
int PassingScore,
|
|
||||||
bool Passed,
|
|
||||||
DateTime AttemptDate);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for creating a new story via admin.
|
|
||||||
/// </summary>
|
|
||||||
public record AdminCreateStoryDto(
|
|
||||||
int LevelId,
|
|
||||||
string Theme,
|
|
||||||
int SegmentCount = 3,
|
|
||||||
bool GenerateAudio = false);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for admin dashboard statistics.
|
|
||||||
/// </summary>
|
|
||||||
public record AdminDashboardStatsDto(
|
|
||||||
int TotalUsers,
|
|
||||||
int TotalLessons,
|
|
||||||
int TotalQuizzes,
|
|
||||||
int TotalStorySegments,
|
|
||||||
int ActiveUsersThisWeek,
|
|
||||||
double AverageUserProgress);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for user list item (for admin user management).
|
|
||||||
/// </summary>
|
|
||||||
public record AdminUserListItemDto(
|
|
||||||
int Id,
|
|
||||||
string Username,
|
|
||||||
string Email,
|
|
||||||
string Role,
|
|
||||||
string CurrentLevel,
|
|
||||||
int TotalPoints,
|
|
||||||
int Streak,
|
|
||||||
DateTime CreatedAt);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for updating user role (admin only).
|
|
||||||
/// </summary>
|
|
||||||
public record UpdateUserRoleDto(
|
|
||||||
string Role);
|
|
||||||
|
|
@ -8,7 +8,6 @@ public record AuthResponse
|
||||||
public int UserId { get; init; }
|
public int UserId { get; init; }
|
||||||
public string Username { get; init; } = string.Empty;
|
public string Username { get; init; } = string.Empty;
|
||||||
public string Email { get; init; } = string.Empty;
|
public string Email { get; init; } = string.Empty;
|
||||||
public string Role { get; init; } = string.Empty;
|
|
||||||
public string Token { get; init; } = string.Empty;
|
public string Token { get; init; } = string.Empty;
|
||||||
public string RefreshToken { get; init; } = string.Empty;
|
public string RefreshToken { get; init; } = string.Empty;
|
||||||
public DateTime ExpiresAt { get; init; }
|
public DateTime ExpiresAt { get; init; }
|
||||||
|
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.DTOs;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for Quiz - used for API responses.
|
|
||||||
/// This is a read-only representation of a Quiz entity.
|
|
||||||
/// </summary>
|
|
||||||
public record QuizDto(
|
|
||||||
int Id,
|
|
||||||
int LessonId,
|
|
||||||
string LessonTitle,
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
int PassingScore,
|
|
||||||
int TimeLimitMinutes,
|
|
||||||
bool ShuffleQuestions,
|
|
||||||
bool IsActive,
|
|
||||||
int QuestionCount,
|
|
||||||
int TotalPoints,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
DateTime? UpdatedAt);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for creating a new Quiz.
|
|
||||||
/// </summary>
|
|
||||||
public record CreateQuizDto(
|
|
||||||
int LessonId,
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
int PassingScore,
|
|
||||||
int TimeLimitMinutes,
|
|
||||||
bool ShuffleQuestions);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for updating an existing Quiz.
|
|
||||||
/// </summary>
|
|
||||||
public record UpdateQuizDto(
|
|
||||||
int LessonId,
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
int PassingScore,
|
|
||||||
int TimeLimitMinutes,
|
|
||||||
bool ShuffleQuestions,
|
|
||||||
bool IsActive);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for quiz with its questions.
|
|
||||||
/// </summary>
|
|
||||||
public record QuizWithQuestionsDto(
|
|
||||||
int Id,
|
|
||||||
int LessonId,
|
|
||||||
string LessonTitle,
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
int PassingScore,
|
|
||||||
int TimeLimitMinutes,
|
|
||||||
bool ShuffleQuestions,
|
|
||||||
bool IsActive,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
DateTime? UpdatedAt,
|
|
||||||
IReadOnlyList<QuizQuestionDto> Questions);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for quiz list item (summary).
|
|
||||||
/// </summary>
|
|
||||||
public record QuizListItemDto(
|
|
||||||
int Id,
|
|
||||||
int LessonId,
|
|
||||||
string LessonTitle,
|
|
||||||
string Title,
|
|
||||||
bool IsActive,
|
|
||||||
int QuestionCount,
|
|
||||||
DateTime CreatedAt);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for submitting quiz answers.
|
|
||||||
/// </summary>
|
|
||||||
public record SubmitQuizDto(
|
|
||||||
int QuizId,
|
|
||||||
IReadOnlyList<QuizAnswerDto> Answers);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extension methods for mapping between Quiz entity and DTOs.
|
|
||||||
/// </summary>
|
|
||||||
public static class QuizDtoExtensions
|
|
||||||
{
|
|
||||||
public static QuizDto ToDto(this Quiz quiz) => new(
|
|
||||||
quiz.Id,
|
|
||||||
quiz.LessonId,
|
|
||||||
quiz.Lesson?.Title ?? "Unknown",
|
|
||||||
quiz.Title,
|
|
||||||
quiz.Description,
|
|
||||||
quiz.PassingScore,
|
|
||||||
quiz.TimeLimitMinutes,
|
|
||||||
quiz.ShuffleQuestions,
|
|
||||||
quiz.IsActive,
|
|
||||||
quiz.Questions?.Count ?? 0,
|
|
||||||
quiz.Questions?.Sum(q => q.Points) ?? 0,
|
|
||||||
quiz.CreatedAt,
|
|
||||||
quiz.UpdatedAt);
|
|
||||||
|
|
||||||
public static QuizListItemDto ToListItemDto(this Quiz quiz) => new(
|
|
||||||
quiz.Id,
|
|
||||||
quiz.LessonId,
|
|
||||||
quiz.Lesson?.Title ?? "Unknown",
|
|
||||||
quiz.Title,
|
|
||||||
quiz.IsActive,
|
|
||||||
quiz.Questions?.Count ?? 0,
|
|
||||||
quiz.CreatedAt);
|
|
||||||
|
|
||||||
public static QuizWithQuestionsDto ToQuizWithQuestionsDto(this Quiz quiz) => new(
|
|
||||||
quiz.Id,
|
|
||||||
quiz.LessonId,
|
|
||||||
quiz.Lesson?.Title ?? "Unknown",
|
|
||||||
quiz.Title,
|
|
||||||
quiz.Description,
|
|
||||||
quiz.PassingScore,
|
|
||||||
quiz.TimeLimitMinutes,
|
|
||||||
quiz.ShuffleQuestions,
|
|
||||||
quiz.IsActive,
|
|
||||||
quiz.CreatedAt,
|
|
||||||
quiz.UpdatedAt,
|
|
||||||
quiz.Questions?.OrderBy(q => q.Order).Select(q => q.ToDto()).ToList() ?? new List<QuizQuestionDto>());
|
|
||||||
|
|
||||||
public static Quiz ToEntity(this CreateQuizDto dto) =>
|
|
||||||
Quiz.Create(
|
|
||||||
dto.LessonId,
|
|
||||||
dto.Title,
|
|
||||||
dto.Description,
|
|
||||||
dto.PassingScore,
|
|
||||||
dto.TimeLimitMinutes);
|
|
||||||
|
|
||||||
public static void UpdateFromDto(this Quiz quiz, UpdateQuizDto dto)
|
|
||||||
{
|
|
||||||
quiz.UpdateLesson(dto.LessonId);
|
|
||||||
quiz.UpdateTitle(dto.Title);
|
|
||||||
quiz.UpdateDescription(dto.Description);
|
|
||||||
quiz.UpdatePassingScore(dto.PassingScore);
|
|
||||||
quiz.UpdateTimeLimit(dto.TimeLimitMinutes);
|
|
||||||
quiz.UpdateShuffleQuestions(dto.ShuffleQuestions);
|
|
||||||
|
|
||||||
if (dto.IsActive != quiz.IsActive)
|
|
||||||
{
|
|
||||||
if (dto.IsActive)
|
|
||||||
quiz.Activate();
|
|
||||||
else
|
|
||||||
quiz.Deactivate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.DTOs;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for QuizQuestion - used for API responses.
|
|
||||||
/// This is a read-only representation of a QuizQuestion entity.
|
|
||||||
/// </summary>
|
|
||||||
public record QuizQuestionDto(
|
|
||||||
int Id,
|
|
||||||
int QuizId,
|
|
||||||
int LessonId,
|
|
||||||
string QuizTitle,
|
|
||||||
string LessonTitle,
|
|
||||||
string QuestionText,
|
|
||||||
QuestionType Type,
|
|
||||||
string? CorrectAnswer,
|
|
||||||
int Points,
|
|
||||||
int Difficulty,
|
|
||||||
int Order,
|
|
||||||
bool IsActive,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
DateTime? UpdatedAt,
|
|
||||||
IReadOnlyList<QuizOptionDto> Options);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for creating a new QuizQuestion.
|
|
||||||
/// </summary>
|
|
||||||
public record CreateQuizQuestionDto(
|
|
||||||
int QuizId,
|
|
||||||
string QuestionText,
|
|
||||||
QuestionType Type,
|
|
||||||
string? CorrectAnswer,
|
|
||||||
int Points,
|
|
||||||
int Difficulty,
|
|
||||||
int Order,
|
|
||||||
IReadOnlyList<CreateQuizOptionDto>? Options);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for updating an existing QuizQuestion.
|
|
||||||
/// </summary>
|
|
||||||
public record UpdateQuizQuestionDto(
|
|
||||||
int QuizId,
|
|
||||||
string QuestionText,
|
|
||||||
QuestionType Type,
|
|
||||||
string? CorrectAnswer,
|
|
||||||
int Points,
|
|
||||||
int Difficulty,
|
|
||||||
int Order,
|
|
||||||
bool IsActive,
|
|
||||||
IReadOnlyList<UpdateQuizOptionDto>? Options);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for QuizOption - used for API responses.
|
|
||||||
/// </summary>
|
|
||||||
public record QuizOptionDto(
|
|
||||||
int Id,
|
|
||||||
int QuizQuestionId,
|
|
||||||
string Text,
|
|
||||||
bool IsCorrect,
|
|
||||||
int Order);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for creating a new QuizOption.
|
|
||||||
/// </summary>
|
|
||||||
public record CreateQuizOptionDto(
|
|
||||||
string Text,
|
|
||||||
bool IsCorrect,
|
|
||||||
int Order);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for updating an existing QuizOption.
|
|
||||||
/// </summary>
|
|
||||||
public record UpdateQuizOptionDto(
|
|
||||||
int Id,
|
|
||||||
string Text,
|
|
||||||
bool IsCorrect,
|
|
||||||
int Order);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for quiz question with user's answer.
|
|
||||||
/// </summary>
|
|
||||||
public record QuizQuestionWithAnswerDto(
|
|
||||||
int QuizQuestionId,
|
|
||||||
int QuizId,
|
|
||||||
int LessonId,
|
|
||||||
string QuestionText,
|
|
||||||
QuestionType Type,
|
|
||||||
int Points,
|
|
||||||
IReadOnlyList<QuizOptionDto> Options,
|
|
||||||
string? UserAnswer,
|
|
||||||
bool IsCorrect);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for submitting quiz answers.
|
|
||||||
/// </summary>
|
|
||||||
public record SubmitQuizAnswersDto(
|
|
||||||
int QuizId,
|
|
||||||
IReadOnlyList<QuizAnswerDto> Answers);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for a single quiz answer submission.
|
|
||||||
/// </summary>
|
|
||||||
public record QuizAnswerDto(
|
|
||||||
int QuizQuestionId,
|
|
||||||
string? AnswerText,
|
|
||||||
int[]? SelectedOptionIds);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for quiz result.
|
|
||||||
/// </summary>
|
|
||||||
public record QuizResultDto(
|
|
||||||
int QuizId,
|
|
||||||
int LessonId,
|
|
||||||
string QuizTitle,
|
|
||||||
int TotalQuestions,
|
|
||||||
int TotalPoints,
|
|
||||||
int ScorePoints,
|
|
||||||
double ScorePercentage,
|
|
||||||
bool Passed,
|
|
||||||
TimeSpan TimeTaken,
|
|
||||||
IReadOnlyList<QuizQuestionResultDto> QuestionResults);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for individual question result.
|
|
||||||
/// </summary>
|
|
||||||
public record QuizQuestionResultDto(
|
|
||||||
int QuizQuestionId,
|
|
||||||
string QuestionText,
|
|
||||||
QuestionType Type,
|
|
||||||
int Points,
|
|
||||||
bool IsCorrect,
|
|
||||||
string? CorrectAnswer,
|
|
||||||
string? UserAnswer);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extension methods for mapping between QuizQuestion entity and DTOs.
|
|
||||||
/// </summary>
|
|
||||||
public static class QuizQuestionDtoExtensions
|
|
||||||
{
|
|
||||||
public static QuizQuestionDto ToDto(this QuizQuestion quizQuestion) => new(
|
|
||||||
quizQuestion.Id,
|
|
||||||
quizQuestion.QuizId,
|
|
||||||
quizQuestion.Quiz?.LessonId ?? 0,
|
|
||||||
quizQuestion.Quiz?.Title ?? "Unknown",
|
|
||||||
quizQuestion.Quiz?.Lesson?.Title ?? "Unknown",
|
|
||||||
quizQuestion.QuestionText,
|
|
||||||
quizQuestion.Type,
|
|
||||||
quizQuestion.CorrectAnswer,
|
|
||||||
quizQuestion.Points,
|
|
||||||
quizQuestion.Difficulty,
|
|
||||||
quizQuestion.Order,
|
|
||||||
quizQuestion.IsActive,
|
|
||||||
quizQuestion.CreatedAt,
|
|
||||||
quizQuestion.UpdatedAt,
|
|
||||||
quizQuestion.Options?.OrderBy(o => o.Order).Select(o => o.ToDto()).ToList() ?? new List<QuizOptionDto>());
|
|
||||||
|
|
||||||
public static QuizQuestion ToEntity(this CreateQuizQuestionDto dto) =>
|
|
||||||
QuizQuestion.Create(
|
|
||||||
dto.QuizId,
|
|
||||||
dto.QuestionText,
|
|
||||||
dto.Type,
|
|
||||||
dto.CorrectAnswer ?? string.Empty,
|
|
||||||
dto.Points,
|
|
||||||
dto.Difficulty,
|
|
||||||
dto.Order);
|
|
||||||
|
|
||||||
public static void UpdateFromDto(this QuizQuestion quizQuestion, UpdateQuizQuestionDto dto)
|
|
||||||
{
|
|
||||||
quizQuestion.UpdateQuiz(dto.QuizId);
|
|
||||||
quizQuestion.UpdateQuestionText(dto.QuestionText);
|
|
||||||
quizQuestion.UpdateType(dto.Type);
|
|
||||||
quizQuestion.UpdateCorrectAnswer(dto.CorrectAnswer ?? string.Empty);
|
|
||||||
quizQuestion.UpdatePoints(dto.Points);
|
|
||||||
quizQuestion.UpdateDifficulty(dto.Difficulty);
|
|
||||||
quizQuestion.UpdateOrder(dto.Order);
|
|
||||||
|
|
||||||
if (dto.IsActive != quizQuestion.IsActive)
|
|
||||||
{
|
|
||||||
if (dto.IsActive)
|
|
||||||
quizQuestion.Activate();
|
|
||||||
else
|
|
||||||
quizQuestion.Deactivate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static QuizOptionDto ToDto(this QuizOption quizOption) => new(
|
|
||||||
quizOption.Id,
|
|
||||||
quizOption.QuizQuestionId,
|
|
||||||
quizOption.Text,
|
|
||||||
quizOption.IsCorrect,
|
|
||||||
quizOption.Order);
|
|
||||||
|
|
||||||
public static QuizOption ToEntity(this CreateQuizOptionDto dto, int quizQuestionId) =>
|
|
||||||
QuizOption.Create(
|
|
||||||
quizQuestionId,
|
|
||||||
dto.Text,
|
|
||||||
dto.IsCorrect,
|
|
||||||
dto.Order);
|
|
||||||
|
|
||||||
public static void UpdateFromDto(this QuizOption quizOption, UpdateQuizOptionDto dto)
|
|
||||||
{
|
|
||||||
quizOption.UpdateText(dto.Text);
|
|
||||||
quizOption.UpdateIsCorrect(dto.IsCorrect);
|
|
||||||
quizOption.UpdateOrder(dto.Order);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
namespace GermanApp.Application.DTOs;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data Transfer Object for StorySegment.
|
|
||||||
/// Used for API requests and responses.
|
|
||||||
/// </summary>
|
|
||||||
public record StorySegmentDto(
|
|
||||||
int Id,
|
|
||||||
int LevelId,
|
|
||||||
int? LessonId,
|
|
||||||
string Content,
|
|
||||||
string? AudioUrl,
|
|
||||||
int Order,
|
|
||||||
string Title,
|
|
||||||
string Theme,
|
|
||||||
int EstimatedReadingMinutes,
|
|
||||||
bool IsActive,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
DateTime? UpdatedAt)
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a StorySegmentDto from a domain entity.
|
|
||||||
/// </summary>
|
|
||||||
public static StorySegmentDto FromEntity(Domain.Entities.StorySegment segment)
|
|
||||||
{
|
|
||||||
return new StorySegmentDto(
|
|
||||||
segment.Id,
|
|
||||||
segment.LevelId,
|
|
||||||
segment.LessonId,
|
|
||||||
segment.Content,
|
|
||||||
segment.AudioUrl,
|
|
||||||
segment.Order,
|
|
||||||
segment.Title,
|
|
||||||
segment.Theme,
|
|
||||||
segment.EstimatedReadingMinutes,
|
|
||||||
segment.IsActive,
|
|
||||||
segment.CreatedAt,
|
|
||||||
segment.UpdatedAt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for creating a new story segment.
|
|
||||||
/// </summary>
|
|
||||||
public record CreateStorySegmentDto(
|
|
||||||
int LevelId,
|
|
||||||
int? LessonId,
|
|
||||||
string Content,
|
|
||||||
int Order,
|
|
||||||
string Title,
|
|
||||||
string Theme,
|
|
||||||
int EstimatedReadingMinutes = 2);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for updating an existing story segment.
|
|
||||||
/// </summary>
|
|
||||||
public record UpdateStorySegmentDto(
|
|
||||||
string? Content = null,
|
|
||||||
int? Order = null,
|
|
||||||
string? Title = null,
|
|
||||||
string? Theme = null,
|
|
||||||
int? EstimatedReadingMinutes = null,
|
|
||||||
int? LessonId = null,
|
|
||||||
bool? IsActive = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for generating a story for a level.
|
|
||||||
/// </summary>
|
|
||||||
public record StoryGenerationRequestDto(
|
|
||||||
int LevelId,
|
|
||||||
string Theme,
|
|
||||||
int SegmentCount,
|
|
||||||
string? CustomPrompt = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO for story generation.
|
|
||||||
/// </summary>
|
|
||||||
public record StoryGenerationResponseDto(
|
|
||||||
int LevelId,
|
|
||||||
string Theme,
|
|
||||||
int SegmentCount,
|
|
||||||
string FullStoryText,
|
|
||||||
IReadOnlyList<StorySegmentDto> Segments);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for user's story progress.
|
|
||||||
/// </summary>
|
|
||||||
public record StoryProgressDto(
|
|
||||||
int LevelId,
|
|
||||||
string LevelName,
|
|
||||||
int TotalSegments,
|
|
||||||
int UnlockedSegments,
|
|
||||||
int CurrentSegmentOrder,
|
|
||||||
IReadOnlyList<StorySegmentProgressDto> Segments);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for individual segment progress.
|
|
||||||
/// </summary>
|
|
||||||
public record StorySegmentProgressDto(
|
|
||||||
int SegmentId,
|
|
||||||
int Order,
|
|
||||||
string Title,
|
|
||||||
bool IsUnlocked,
|
|
||||||
bool IsCompleted);
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Interface for admin services.
|
|
||||||
/// Part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public interface IAdminService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all users for admin view.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of all users</returns>
|
|
||||||
Task<IReadOnlyList<AdminUserListItemDto>> GetAllUsersAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a specific user by ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The user DTO</returns>
|
|
||||||
Task<AdminUserDto?> GetUserByIdAsync(int userId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates a user's role.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="newRole">The new role</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The updated user DTO</returns>
|
|
||||||
Task<AdminUserDto?> UpdateUserRoleAsync(int userId, string newRole, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a user (admin only, cannot delete self).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID to delete</param>
|
|
||||||
/// <param name="adminUserId">The admin user ID making the request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if successful</returns>
|
|
||||||
Task<bool> DeleteUserAsync(int userId, int adminUserId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets admin dashboard statistics.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Dashboard statistics</returns>
|
|
||||||
Task<AdminDashboardStatsDto> GetDashboardStatsAsync(CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
@ -42,18 +42,4 @@ public interface IAuthService
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="refreshToken">The refresh token to revoke</param>
|
/// <param name="refreshToken">The refresh token to revoke</param>
|
||||||
Task RevokeRefreshTokenAsync(string refreshToken);
|
Task RevokeRefreshTokenAsync(string refreshToken);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates the first admin user (bootstrap).
|
|
||||||
/// This is a special method that creates an admin user without requiring authentication.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="registerDto">Admin user registration data</param>
|
|
||||||
/// <returns>Authentication response with token</returns>
|
|
||||||
Task<AuthResponse> CreateAdminUserAsync(RegisterDto registerDto);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if an admin user already exists.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>True if admin user exists, false otherwise</returns>
|
|
||||||
Task<bool> AdminUserExistsAsync();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Interface for user progress report services.
|
|
||||||
/// Part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public interface IUserReportService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a progress report for a specific user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>User progress report</returns>
|
|
||||||
Task<UserProgressReportDto> GenerateUserReportAsync(int userId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates progress reports for all users.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of all user progress reports</returns>
|
|
||||||
Task<IReadOnlyList<UserProgressReportDto>> GenerateAllUserReportsAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets lesson completion data for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of lesson completion data</returns>
|
|
||||||
Task<IReadOnlyList<LessonCompletionDto>> GetUserLessonProgressAsync(int userId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz results for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of quiz results</returns>
|
|
||||||
Task<IReadOnlyList<UserQuizResultDto>> GetUserQuizResultsAsync(int userId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exports user reports as CSV.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>CSV content as string</returns>
|
|
||||||
Task<string> ExportReportsAsCsvAsync(CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request model for Mistral completion API.
|
|
||||||
/// Used for text generation (stories, feedback, etc.).
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// See: https://docs.mistral.ai/api/#operation/createCompletion
|
|
||||||
/// </remarks>
|
|
||||||
public record MistralRequest
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// ID of the model to use.
|
|
||||||
/// </summary>
|
|
||||||
public string Model { get; init; } = "mistral-medium";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The prompt(s) to generate completions for.
|
|
||||||
/// </summary>
|
|
||||||
public string Prompt { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The maximum number of tokens to generate in the completion.
|
|
||||||
/// Default: 256, Max: 2048 for mistral-medium
|
|
||||||
/// </summary>
|
|
||||||
public int? MaxTokens { get; init; } = 512;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// What sampling temperature to use.
|
|
||||||
/// Higher values means the model will take more risks. Try 0.9 for more creative applications, and 0 for ones with a well-defined answer.
|
|
||||||
/// Default: 0.7
|
|
||||||
/// </summary>
|
|
||||||
public double? Temperature { get; init; } = 0.7;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Nucleus sampling, where the model considers the results of the tokens with top_p probability mass.
|
|
||||||
/// So 0.1 means only the tokens comprising the top 10% probability mass are considered.
|
|
||||||
/// Default: 1.0
|
|
||||||
/// </summary>
|
|
||||||
public double? TopP { get; init; } = 1.0;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How many completions to generate for each prompt.
|
|
||||||
/// Default: 1
|
|
||||||
/// </summary>
|
|
||||||
public int? N { get; init; } = 1;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to stream partial responses.
|
|
||||||
/// Not supported by this connector (use streaming endpoint separately if needed).
|
|
||||||
/// </summary>
|
|
||||||
public bool? Stream { get; init; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to echo the prompt in the response.
|
|
||||||
/// Default: false
|
|
||||||
/// </summary>
|
|
||||||
public bool? Echo { get; init; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Up to 4 sequences where the API will stop generating further tokens.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<string>? Stop { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on their frequency in the text so far.
|
|
||||||
/// </summary>
|
|
||||||
public double? FrequencyPenalty { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far.
|
|
||||||
/// </summary>
|
|
||||||
public double? PresencePenalty { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// User identifier for tracking usage.
|
|
||||||
/// </summary>
|
|
||||||
public string? User { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Factory method to create a completion request with default settings.
|
|
||||||
/// </summary>
|
|
||||||
public static MistralRequest CreateCompletion(string prompt, string model = "mistral-medium", int maxTokens = 512)
|
|
||||||
{
|
|
||||||
return new MistralRequest
|
|
||||||
{
|
|
||||||
Model = model,
|
|
||||||
Prompt = prompt,
|
|
||||||
MaxTokens = maxTokens,
|
|
||||||
Temperature = 0.7,
|
|
||||||
TopP = 1.0,
|
|
||||||
N = 1,
|
|
||||||
Stream = false,
|
|
||||||
Echo = false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request model for Mistral chat completion API.
|
|
||||||
/// Used for conversational text generation.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// See: https://docs.mistral.ai/api/#operation/createChatCompletion
|
|
||||||
/// </remarks>
|
|
||||||
public record MistralChatRequest
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// ID of the model to use.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("model")]
|
|
||||||
public string Model { get; init; } = "mistral-small-latest";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A list of messages comprising the conversation so far.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("messages")]
|
|
||||||
public IReadOnlyList<MistralMessage> Messages { get; init; } = new List<MistralMessage>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The maximum number of tokens to generate in the completion.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("max_tokens")]
|
|
||||||
public int? MaxTokens { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// What sampling temperature to use.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("temperature")]
|
|
||||||
public double? Temperature { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Factory method to create a chat request.
|
|
||||||
/// </summary>
|
|
||||||
public static MistralChatRequest CreateChat(IReadOnlyList<MistralMessage> messages, string model = "mistral-small-latest", int maxTokens = 512)
|
|
||||||
{
|
|
||||||
return new MistralChatRequest
|
|
||||||
{
|
|
||||||
Model = model,
|
|
||||||
Messages = messages,
|
|
||||||
MaxTokens = maxTokens,
|
|
||||||
Temperature = 0.7
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a message in a Mistral chat conversation.
|
|
||||||
/// </summary>
|
|
||||||
public record MistralMessage
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The role of the message author.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("role")]
|
|
||||||
public string Role { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The content of the message.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("content")]
|
|
||||||
public string Content { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Factory method to create a user message.
|
|
||||||
/// </summary>
|
|
||||||
public static MistralMessage User(string content) => new() { Role = "user", Content = content };
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Factory method to create an assistant message.
|
|
||||||
/// </summary>
|
|
||||||
public static MistralMessage Assistant(string content) => new() { Role = "assistant", Content = content };
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Factory method to create a system message.
|
|
||||||
/// </summary>
|
|
||||||
public static MistralMessage System(string content) => new() { Role = "system", Content = content };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Model information from Mistral API.
|
|
||||||
/// </summary>
|
|
||||||
public record MistralModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The model identifier.
|
|
||||||
/// </summary>
|
|
||||||
public string Id { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The model object type.
|
|
||||||
/// </summary>
|
|
||||||
public string Object { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// When the model was created.
|
|
||||||
/// </summary>
|
|
||||||
public long Created { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The model's description.
|
|
||||||
/// </summary>
|
|
||||||
public string Description { get; init; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response model for Mistral completion and chat completion APIs.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// See: https://docs.mistral.ai/api/#operation/createCompletion
|
|
||||||
/// See: https://docs.mistral.ai/api/#operation/createChatCompletion
|
|
||||||
/// </remarks>
|
|
||||||
public record MistralResponse
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Unique identifier for the completion.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("id")]
|
|
||||||
public string Id { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The object type (always "text_completion" or "chat.completion").
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("object")]
|
|
||||||
public string Object { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The Unix timestamp (in seconds) of when the completion was created.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("created")]
|
|
||||||
public long Created { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The model used for the completion.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("model")]
|
|
||||||
public string Model { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The list of completion choices generated by the model.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("choices")]
|
|
||||||
public IReadOnlyList<MistralChoice> Choices { get; init; } = new List<MistralChoice>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Usage statistics for the completion.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("usage")]
|
|
||||||
public MistralUsage Usage { get; init; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the first choice's text content.
|
|
||||||
/// Convenience property for single-completion requests.
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public string FirstChoiceText => Choices.Count > 0 ? Choices[0].Text : string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the first choice's message content (for chat completions).
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public string FirstChoiceMessageContent =>
|
|
||||||
Choices.Count > 0 && Choices[0].Message != null
|
|
||||||
? Choices[0].Message.Content
|
|
||||||
: string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all generated texts from all choices.
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public IReadOnlyList<string> AllChoiceTexts => Choices.Select(c => c.Text).ToList();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if the response contains any valid completions.
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public bool HasValidChoices => Choices != null && Choices.Count > 0 &&
|
|
||||||
Choices.Any(c => !string.IsNullOrWhiteSpace(c.Text) || c.Message != null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A single completion choice returned by Mistral API.
|
|
||||||
/// </summary>
|
|
||||||
public record MistralChoice
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The generated text for this choice.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("text")]
|
|
||||||
public string Text { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// For chat completions, this contains the message object.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("message")]
|
|
||||||
public MistralChatMessage? Message { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The reason the model stopped generating tokens (e.g., "stop", "length", "error").
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("finish_reason")]
|
|
||||||
public string FinishReason { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The index of this choice in the list of choices.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("index")]
|
|
||||||
public int Index { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Log probability information for the generated tokens.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("logprobs")]
|
|
||||||
public object? LogProbs { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Message object returned in chat completion choices.
|
|
||||||
/// </summary>
|
|
||||||
public record MistralChatMessage
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The role of the message author.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("role")]
|
|
||||||
public string Role { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The content of the message.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("content")]
|
|
||||||
public string Content { get; init; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Token usage statistics from Mistral API.
|
|
||||||
/// </summary>
|
|
||||||
public record MistralUsage
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Number of tokens in the prompt.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("prompt_tokens")]
|
|
||||||
public int PromptTokens { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Number of tokens generated in the completion.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("completion_tokens")]
|
|
||||||
public int CompletionTokens { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Total number of tokens used (prompt + completion).
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("total_tokens")]
|
|
||||||
public int TotalTokens { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total cost estimate based on token usage.
|
|
||||||
/// Note: Actual pricing may vary based on Mistral's pricing model.
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public decimal EstimatedCost => TotalTokens * 0.0000025m; // Approx $0.0000025 per token for mistral-medium
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Error response from Mistral API.
|
|
||||||
/// </summary>
|
|
||||||
public record MistralErrorResponse
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The object type (always "error").
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("object")]
|
|
||||||
public string Object { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The error message.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("message")]
|
|
||||||
public string Message { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The type of error that occurred.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("type")]
|
|
||||||
public string Type { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The HTTP status code associated with the error.
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public int? StatusCode { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Additional error details.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("details")]
|
|
||||||
public object? Details { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maps the error type to an AiErrorCode.
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public AiErrorCode ErrorCode => Type switch
|
|
||||||
{
|
|
||||||
"rate_limit_exceeded" or "rate_limit" => AiErrorCode.RateLimited,
|
|
||||||
"invalid_request_error" => AiErrorCode.InvalidRequest,
|
|
||||||
"authentication_error" or "invalid_api_key" => AiErrorCode.AuthenticationError,
|
|
||||||
"server_error" or "internal_server_error" => AiErrorCode.Temporary,
|
|
||||||
"timeout" => AiErrorCode.Timeout,
|
|
||||||
_ => AiErrorCode.Unknown
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response wrapper for listing Mistral models.
|
|
||||||
/// </summary>
|
|
||||||
public record MistralListModelsResponse
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The object type (always "list").
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("object")]
|
|
||||||
public string Object { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// List of available models.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("data")]
|
|
||||||
public IReadOnlyList<MistralModel> Data { get; init; } = new List<MistralModel>();
|
|
||||||
}
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Application.Interfaces;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Service for admin operations.
|
|
||||||
/// Part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class AdminService : IAdminService
|
|
||||||
{
|
|
||||||
private readonly IRepository<User, int> _userRepository;
|
|
||||||
private readonly ILevelRepository _levelRepository;
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
private readonly IQuizRepository _quizRepository;
|
|
||||||
private readonly IStoryRepository _storyRepository;
|
|
||||||
private readonly IUserProgressRepository _userProgressRepository;
|
|
||||||
|
|
||||||
public AdminService(
|
|
||||||
IRepository<User, int> userRepository,
|
|
||||||
ILevelRepository levelRepository,
|
|
||||||
ILessonRepository lessonRepository,
|
|
||||||
IQuizRepository quizRepository,
|
|
||||||
IStoryRepository storyRepository,
|
|
||||||
IUserProgressRepository userProgressRepository)
|
|
||||||
{
|
|
||||||
_userRepository = userRepository;
|
|
||||||
_levelRepository = levelRepository;
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
_quizRepository = quizRepository;
|
|
||||||
_storyRepository = storyRepository;
|
|
||||||
_userProgressRepository = userProgressRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all users for admin view.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of all users</returns>
|
|
||||||
public async Task<IReadOnlyList<AdminUserListItemDto>> GetAllUsersAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var users = await _userRepository.GetAllAsync(cancellationToken);
|
|
||||||
|
|
||||||
return users.Select(u => new AdminUserListItemDto(
|
|
||||||
u.Id,
|
|
||||||
u.Username,
|
|
||||||
u.Email,
|
|
||||||
u.Role,
|
|
||||||
u.CurrentLevel,
|
|
||||||
u.TotalPoints,
|
|
||||||
u.Streak,
|
|
||||||
u.CreatedAt))
|
|
||||||
.OrderBy(u => u.Username)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a specific user by ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The user DTO</returns>
|
|
||||||
public async Task<AdminUserDto?> GetUserByIdAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
|
|
||||||
|
|
||||||
if (user == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return new AdminUserDto(
|
|
||||||
user.Id,
|
|
||||||
user.Username,
|
|
||||||
user.Email,
|
|
||||||
user.Role,
|
|
||||||
user.CurrentLevel,
|
|
||||||
user.Streak,
|
|
||||||
user.TotalPoints,
|
|
||||||
user.CreatedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates a user's role.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="newRole">The new role</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The updated user DTO</returns>
|
|
||||||
public async Task<AdminUserDto?> UpdateUserRoleAsync(int userId, string newRole, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
|
|
||||||
|
|
||||||
if (user == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// Validate role and update using domain method
|
|
||||||
user.SetRole(newRole);
|
|
||||||
|
|
||||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
|
||||||
|
|
||||||
return new AdminUserDto(
|
|
||||||
user.Id,
|
|
||||||
user.Username,
|
|
||||||
user.Email,
|
|
||||||
user.Role,
|
|
||||||
user.CurrentLevel,
|
|
||||||
user.Streak,
|
|
||||||
user.TotalPoints,
|
|
||||||
user.CreatedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a user (admin only, cannot delete self).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID to delete</param>
|
|
||||||
/// <param name="adminUserId">The admin user ID making the request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if successful</returns>
|
|
||||||
public async Task<bool> DeleteUserAsync(int userId, int adminUserId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Cannot delete self
|
|
||||||
if (userId == adminUserId)
|
|
||||||
throw new InvalidOperationException("Cannot delete your own user account.");
|
|
||||||
|
|
||||||
// Check if user exists
|
|
||||||
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
|
|
||||||
if (user == null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Prevent deleting the last admin
|
|
||||||
var allUsers = await _userRepository.GetAllAsync(cancellationToken);
|
|
||||||
var adminCount = allUsers.Count(u => u.IsAdmin());
|
|
||||||
if (user.IsAdmin() && adminCount <= 1)
|
|
||||||
throw new InvalidOperationException("Cannot delete the last admin user.");
|
|
||||||
|
|
||||||
await _userRepository.DeleteAsync(user, cancellationToken);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets admin dashboard statistics.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Dashboard statistics</returns>
|
|
||||||
public async Task<AdminDashboardStatsDto> GetDashboardStatsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var usersTask = _userRepository.GetAllAsync(cancellationToken);
|
|
||||||
var lessonsTask = _lessonRepository.GetAllAsync(cancellationToken);
|
|
||||||
var quizzesTask = _quizRepository.GetAllAsync(cancellationToken);
|
|
||||||
var userProgressTask = _userProgressRepository.GetAllAsync(cancellationToken);
|
|
||||||
|
|
||||||
await Task.WhenAll(usersTask, lessonsTask, quizzesTask, userProgressTask);
|
|
||||||
|
|
||||||
var users = await usersTask;
|
|
||||||
var lessons = await lessonsTask;
|
|
||||||
var quizzes = await quizzesTask;
|
|
||||||
var userProgress = await userProgressTask;
|
|
||||||
|
|
||||||
// Get all levels and count story segments
|
|
||||||
var levels = await _levelRepository.GetAllAsync(cancellationToken);
|
|
||||||
var storyCount = 0;
|
|
||||||
foreach (var level in levels)
|
|
||||||
{
|
|
||||||
var segments = await _storyRepository.GetByLevelAsync(level.Id, true, cancellationToken);
|
|
||||||
storyCount += segments.Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate active users this week
|
|
||||||
var oneWeekAgo = DateTime.UtcNow.AddDays(-7);
|
|
||||||
var activeUsersThisWeek = userProgress
|
|
||||||
.Where(up => up.LastAttemptDate >= oneWeekAgo)
|
|
||||||
.Select(up => up.UserId)
|
|
||||||
.Distinct()
|
|
||||||
.Count();
|
|
||||||
|
|
||||||
// Calculate average user progress
|
|
||||||
var completedLessons = userProgress.Count(up => up.IsCompleted);
|
|
||||||
var totalPossibleProgress = users.Count * lessons.Count;
|
|
||||||
var averageProgress = totalPossibleProgress > 0
|
|
||||||
? (double)completedLessons / totalPossibleProgress * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return new AdminDashboardStatsDto(
|
|
||||||
users.Count,
|
|
||||||
lessons.Count,
|
|
||||||
quizzes.Count,
|
|
||||||
storyCount,
|
|
||||||
activeUsersThisWeek,
|
|
||||||
averageProgress);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,394 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Service for handling AI service failures and providing fallback responses.
|
|
||||||
/// This is part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class AiFallbackService
|
|
||||||
{
|
|
||||||
private readonly IMistralService? _mistralService;
|
|
||||||
private readonly IVoskService? _voskService;
|
|
||||||
private readonly ITtsService? _ttsService;
|
|
||||||
private readonly ILogger<AiFallbackService> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new AiFallbackService.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="mistralService">The Mistral text generation service (optional)</param>
|
|
||||||
/// <param name="voskService">The Vosk speech recognition service (optional)</param>
|
|
||||||
/// <param name="ttsService">The Coqui TTS service (optional)</param>
|
|
||||||
/// <param name="logger">Logger for service operations</param>
|
|
||||||
public AiFallbackService(
|
|
||||||
IMistralService? mistralService,
|
|
||||||
IVoskService? voskService,
|
|
||||||
ITtsService? ttsService,
|
|
||||||
ILogger<AiFallbackService> logger)
|
|
||||||
{
|
|
||||||
_mistralService = mistralService;
|
|
||||||
_voskService = voskService;
|
|
||||||
_ttsService = ttsService;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempts to generate a story, with fallback if the primary service fails.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="level">The CEFR level</param>
|
|
||||||
/// <param name="topic">The story topic</param>
|
|
||||||
/// <param name="vocabularyWords">List of vocabulary words</param>
|
|
||||||
/// <param name="length">Approximate word count</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Generated story text, or fallback if AI service fails</returns>
|
|
||||||
public virtual async Task<string> GenerateStoryWithFallbackAsync(
|
|
||||||
string level,
|
|
||||||
string topic,
|
|
||||||
IReadOnlyList<string> vocabularyWords,
|
|
||||||
int length = 200,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_mistralService != null)
|
|
||||||
{
|
|
||||||
return await _mistralService.GenerateStoryAsync(
|
|
||||||
level,
|
|
||||||
topic,
|
|
||||||
vocabularyWords,
|
|
||||||
length,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Mistral story generation failed, using fallback");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Generate a simple story based on parameters
|
|
||||||
return GenerateFallbackStory(level, topic, vocabularyWords, length);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempts to provide writing feedback, with fallback if the primary service fails.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userText">The user's text</param>
|
|
||||||
/// <param name="level">The CEFR level</param>
|
|
||||||
/// <param name="customPrompt">Optional custom prompt</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Feedback text, or fallback if AI service fails</returns>
|
|
||||||
public virtual async Task<string> ProvideFeedbackWithFallbackAsync(
|
|
||||||
string userText,
|
|
||||||
string level,
|
|
||||||
string? customPrompt = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_mistralService != null)
|
|
||||||
{
|
|
||||||
return await _mistralService.GenerateWritingFeedbackAsync(
|
|
||||||
userText,
|
|
||||||
level,
|
|
||||||
customPrompt,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Mistral feedback generation failed, using fallback");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Provide simple feedback based on text length
|
|
||||||
return GenerateFallbackFeedback(userText, level);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempts to recognize speech, with fallback if the primary service fails.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="audioBytes">The audio data</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Recognized text, or empty string if service fails</returns>
|
|
||||||
public virtual async Task<string> RecognizeSpeechWithFallbackAsync(
|
|
||||||
byte[] audioBytes,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_voskService != null)
|
|
||||||
{
|
|
||||||
return await _voskService.RecognizeSpeechAsync(
|
|
||||||
audioBytes,
|
|
||||||
16000,
|
|
||||||
null,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Vosk speech recognition failed, using fallback");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Return empty string or transcription placeholder
|
|
||||||
return "[Speech transcription unavailable - please try again]";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempts to generate audio, with fallback if the primary service fails.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">The text to convert to speech</param>
|
|
||||||
/// <param name="language">The language code</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Audio data, or empty array if service fails</returns>
|
|
||||||
public virtual async Task<byte[]> GenerateAudioWithFallbackAsync(
|
|
||||||
string text,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_ttsService != null)
|
|
||||||
{
|
|
||||||
return await _ttsService.GenerateAudioAsync(
|
|
||||||
text,
|
|
||||||
null,
|
|
||||||
language,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "TTS audio generation failed, using fallback");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Return empty audio array
|
|
||||||
return new byte[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if AI services are healthy.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Dictionary mapping service names to health status</returns>
|
|
||||||
public virtual async Task<IDictionary<string, bool>> CheckServiceHealthAsync(
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var health = new Dictionary<string, bool>();
|
|
||||||
|
|
||||||
// Check Mistral service
|
|
||||||
if (_mistralService != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var isHealthy = await _mistralService.TestConnectionAsync(cancellationToken);
|
|
||||||
health["Mistral"] = isHealthy;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
health["Mistral"] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
health["Mistral"] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Vosk service
|
|
||||||
if (_voskService != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var isHealthy = await _voskService.TestModelAsync(cancellationToken);
|
|
||||||
health["Vosk"] = isHealthy;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
health["Vosk"] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
health["Vosk"] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Coqui TTS service
|
|
||||||
if (_ttsService != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var isHealthy = await _ttsService.TestModelAsync(cancellationToken);
|
|
||||||
health["CoquiTTS"] = isHealthy;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
health["CoquiTTS"] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
health["CoquiTTS"] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return health;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a human-readable status message for AI services.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Status message describing which services are available</returns>
|
|
||||||
public virtual async Task<string> GetServiceStatusMessageAsync(
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var health = await CheckServiceHealthAsync(cancellationToken);
|
|
||||||
var available = health.Where(kvp => kvp.Value).Select(kvp => kvp.Key).ToList();
|
|
||||||
var unavailable = health.Where(kvp => !kvp.Value).Select(kvp => kvp.Key).ToList();
|
|
||||||
|
|
||||||
var message = "AI Service Status: ";
|
|
||||||
|
|
||||||
if (available.Count > 0)
|
|
||||||
{
|
|
||||||
message += "Available: ";
|
|
||||||
message += string.Join(", ", available);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (unavailable.Count > 0)
|
|
||||||
{
|
|
||||||
if (available.Count > 0)
|
|
||||||
message += "; ";
|
|
||||||
message += "Unavailable: ";
|
|
||||||
message += string.Join(", ", unavailable);
|
|
||||||
}
|
|
||||||
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a fallback story when AI service is unavailable.
|
|
||||||
/// </summary>
|
|
||||||
private string GenerateFallbackStory(
|
|
||||||
string level,
|
|
||||||
string topic,
|
|
||||||
IReadOnlyList<string> vocabularyWords,
|
|
||||||
int length)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Generating fallback story for: Level={Level}, Topic={Topic}", level, topic);
|
|
||||||
|
|
||||||
// Create a simple story based on the topic and vocabulary
|
|
||||||
var story = $"Ein {GetLevelDescription(level)} Story über {topic}.";
|
|
||||||
|
|
||||||
if (vocabularyWords != null && vocabularyWords.Count > 0)
|
|
||||||
{
|
|
||||||
story += " Es enthält die Wörter: " + string.Join(", ", vocabularyWords.Take(5));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add some generic story content based on topic
|
|
||||||
story += " " + GetGenericStoryContent(topic, length);
|
|
||||||
|
|
||||||
return story;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates fallback feedback when AI service is unavailable.
|
|
||||||
/// </summary>
|
|
||||||
private string GenerateFallbackFeedback(string userText, string level)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Generating fallback feedback for: Level={Level}", level);
|
|
||||||
|
|
||||||
var feedback = $"Feedback for {level} level: ";
|
|
||||||
|
|
||||||
// Simple length-based feedback
|
|
||||||
if (string.IsNullOrWhiteSpace(userText))
|
|
||||||
{
|
|
||||||
feedback += "Please write some text to receive feedback.";
|
|
||||||
}
|
|
||||||
else if (userText.Length < 20)
|
|
||||||
{
|
|
||||||
feedback += "Your text is quite short. Try writing a few more sentences.";
|
|
||||||
}
|
|
||||||
else if (userText.Length < 100)
|
|
||||||
{
|
|
||||||
feedback += "Good start! Your text is clear. Keep practicing to improve.";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
feedback += "Great job! Your text is well-developed. Continue practicing to maintain your skills.";
|
|
||||||
}
|
|
||||||
|
|
||||||
return feedback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a description for the CEFR level.
|
|
||||||
/// </summary>
|
|
||||||
private string GetLevelDescription(string level)
|
|
||||||
{
|
|
||||||
return level.ToUpper() switch
|
|
||||||
{
|
|
||||||
"A1" => "einfacher",
|
|
||||||
"A2" => "leichter",
|
|
||||||
"B1" => "mittelschwerer",
|
|
||||||
"B2" => "fortgeschrittener",
|
|
||||||
"C1" => "komplexer",
|
|
||||||
_ => "einfacher"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets generic story content based on topic.
|
|
||||||
/// </summary>
|
|
||||||
private string GetGenericStoryContent(string topic, int length)
|
|
||||||
{
|
|
||||||
// Simple topic-based content
|
|
||||||
var topics = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["food"] = "Es geht um Essen und Trinken.",
|
|
||||||
["travel"] = "Es geht um Reisen und Abenteuer.",
|
|
||||||
["family"] = "Es geht um Familie und Beziehungen.",
|
|
||||||
["work"] = "Es geht um Arbeit und Beruf.",
|
|
||||||
["school"] = "Es geht um Schule und Lernen.",
|
|
||||||
["animals"] = "Es geht um Tiere und Natur.",
|
|
||||||
["sports"] = "Es geht um Sport und Bewegung.",
|
|
||||||
["holiday"] = "Es geht um Ferien und Feiertage.",
|
|
||||||
["weather"] = "Es geht um das Wetter.",
|
|
||||||
["shopping"] = "Es geht um Einkaufen."
|
|
||||||
};
|
|
||||||
|
|
||||||
if (topics.TryGetValue(topic, out var content))
|
|
||||||
{
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Es geht um ein interessantes Thema.";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the fallback service.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if service is working</returns>
|
|
||||||
public virtual async Task<bool> TestServiceAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Test fallback story generation
|
|
||||||
var story = GenerateFallbackStory("A1", "Test", new List<string> { "Test" }, 100);
|
|
||||||
if (string.IsNullOrWhiteSpace(story))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Test fallback feedback generation
|
|
||||||
var feedback = GenerateFallbackFeedback("Test", "A1");
|
|
||||||
if (string.IsNullOrWhiteSpace(feedback))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Test health check
|
|
||||||
var health = await CheckServiceHealthAsync(cancellationToken);
|
|
||||||
return health.Count > 0;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,363 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Application service for generating audio from text using Coqui TTS.
|
|
||||||
/// This is part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class AudioGenerationService
|
|
||||||
{
|
|
||||||
private readonly ITtsService _ttsService;
|
|
||||||
private readonly ILogger<AudioGenerationService> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new AudioGenerationService.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ttsService">The Coqui TTS service</param>
|
|
||||||
/// <param name="logger">Logger for service operations</param>
|
|
||||||
public AudioGenerationService(
|
|
||||||
ITtsService ttsService,
|
|
||||||
ILogger<AudioGenerationService> logger)
|
|
||||||
{
|
|
||||||
_ttsService = ttsService;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio from text.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">The text to convert to speech</param>
|
|
||||||
/// <param name="language">The language code (default: "de" for German)</param>
|
|
||||||
/// <param name="speaker">Optional speaker ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Audio data as byte array</returns>
|
|
||||||
public virtual async Task<byte[]> GenerateAudioAsync(
|
|
||||||
string text,
|
|
||||||
string language = "de",
|
|
||||||
string? speaker = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Generating audio: TextLength={Length}, Language={Language}, Speaker={Speaker}",
|
|
||||||
text?.Length ?? 0, language, speaker ?? "default");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var audio = await _ttsService.GenerateAudioAsync(
|
|
||||||
text,
|
|
||||||
speaker,
|
|
||||||
language,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
_logger.LogInformation("Audio generated successfully: {ByteCount} bytes", audio.Length);
|
|
||||||
return audio;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to generate audio");
|
|
||||||
throw new AiServiceException(
|
|
||||||
"Failed to generate audio: " + ex.Message,
|
|
||||||
AiErrorCode.Temporary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio and saves to a file.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">The text to convert to speech</param>
|
|
||||||
/// <param name="outputPath">Path to save the audio file</param>
|
|
||||||
/// <param name="language">The language code (default: "de")</param>
|
|
||||||
/// <param name="speaker">Optional speaker ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Path to the generated audio file</returns>
|
|
||||||
public virtual async Task<string> GenerateAudioToFileAsync(
|
|
||||||
string text,
|
|
||||||
string outputPath,
|
|
||||||
string language = "de",
|
|
||||||
string? speaker = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Generating audio to file: TextLength={Length}, Output={Output}",
|
|
||||||
text?.Length ?? 0, outputPath);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _ttsService.GenerateAudioToFileAsync(
|
|
||||||
text,
|
|
||||||
outputPath,
|
|
||||||
speaker,
|
|
||||||
language,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
_logger.LogInformation("Audio saved to: {OutputPath}", outputPath);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to generate audio to file");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio as a stream.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">The text to convert to speech</param>
|
|
||||||
/// <param name="language">The language code (default: "de")</param>
|
|
||||||
/// <param name="speaker">Optional speaker ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Stream containing audio data</returns>
|
|
||||||
public virtual async Task<System.IO.Stream> GenerateAudioStreamAsync(
|
|
||||||
string text,
|
|
||||||
string language = "de",
|
|
||||||
string? speaker = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Generating audio stream: TextLength={Length}", text?.Length ?? 0);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await _ttsService.GenerateAudioStreamAsync(
|
|
||||||
text,
|
|
||||||
speaker,
|
|
||||||
language,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to generate audio stream");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio for a vocabulary word.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="word">The vocabulary word</param>
|
|
||||||
/// <param name="language">The language code (default: "de")</param>
|
|
||||||
/// <param name="speaker">Optional speaker ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Audio data as byte array</returns>
|
|
||||||
public virtual async Task<byte[]> GenerateVocabularyAudioAsync(
|
|
||||||
string word,
|
|
||||||
string language = "de",
|
|
||||||
string? speaker = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Generating vocabulary audio: Word={Word}", word);
|
|
||||||
|
|
||||||
// For vocabulary, we might want to add a slight pause or emphasis
|
|
||||||
// For now, just generate the word directly
|
|
||||||
return await GenerateAudioAsync(word, language, speaker, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio for a complete lesson including vocabulary and example sentences.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonText">The lesson text to narrate</param>
|
|
||||||
/// <param name="vocabularyWords">Vocabulary words to include</param>
|
|
||||||
/// <param name="language">The language code (default: "de")</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Audio data as byte array</returns>
|
|
||||||
public virtual async Task<byte[]> GenerateLessonAudioAsync(
|
|
||||||
string lessonText,
|
|
||||||
IReadOnlyList<string> vocabularyWords,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Generating lesson audio: TextLength={Length}, VocabularyCount={Count}",
|
|
||||||
lessonText?.Length ?? 0, vocabularyWords?.Count ?? 0);
|
|
||||||
|
|
||||||
// Combine lesson text with vocabulary
|
|
||||||
var fullText = BuildLessonNarration(lessonText, vocabularyWords);
|
|
||||||
|
|
||||||
return await GenerateAudioAsync(fullText, language, null, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio for a story.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storyText">The story text to narrate</param>
|
|
||||||
/// <param name="language">The language code (default: "de")</param>
|
|
||||||
/// <param name="speaker">Optional speaker ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Audio data as byte array</returns>
|
|
||||||
public virtual async Task<byte[]> GenerateStoryAudioAsync(
|
|
||||||
string storyText,
|
|
||||||
string language = "de",
|
|
||||||
string? speaker = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Generating story audio: TextLength={Length}", storyText?.Length ?? 0);
|
|
||||||
|
|
||||||
// Stories might be long, so we use the TTS service's built-in text splitting
|
|
||||||
return await GenerateAudioAsync(storyText, language, speaker, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio for a quiz question.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="questionText">The quiz question text</param>
|
|
||||||
/// <param name="options">The answer options</param>
|
|
||||||
/// <param name="language">The language code (default: "de")</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Audio data as byte array</returns>
|
|
||||||
public virtual async Task<byte[]> GenerateQuizAudioAsync(
|
|
||||||
string questionText,
|
|
||||||
IReadOnlyList<string> options,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Generating quiz audio: QuestionLength={Length}, OptionCount={Count}",
|
|
||||||
questionText?.Length ?? 0, options?.Count ?? 0);
|
|
||||||
|
|
||||||
// Build the full quiz narration
|
|
||||||
var fullText = BuildQuizNarration(questionText, options);
|
|
||||||
|
|
||||||
return await GenerateAudioAsync(fullText, language, null, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates multiple audio files for a batch of texts.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="texts">Dictionary mapping IDs to texts</param>
|
|
||||||
/// <param name="outputDirectory">Directory to save audio files</param>
|
|
||||||
/// <param name="language">The language code (default: "de")</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Dictionary mapping IDs to output file paths</returns>
|
|
||||||
public virtual async Task<IDictionary<string, string>> GenerateBatchAudioAsync(
|
|
||||||
IDictionary<string, string> texts,
|
|
||||||
string outputDirectory,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Generating batch audio: Count={Count}", texts?.Count ?? 0);
|
|
||||||
|
|
||||||
var results = new Dictionary<string, string>();
|
|
||||||
|
|
||||||
// Ensure output directory exists
|
|
||||||
Directory.CreateDirectory(outputDirectory);
|
|
||||||
|
|
||||||
foreach (var kvp in texts)
|
|
||||||
{
|
|
||||||
var id = kvp.Key;
|
|
||||||
var text = kvp.Value;
|
|
||||||
var outputPath = Path.Combine(outputDirectory, $"{id}.wav");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await GenerateAudioToFileAsync(text, outputPath, language, null, cancellationToken);
|
|
||||||
results[id] = outputPath;
|
|
||||||
_logger.LogInformation("Generated audio for: {Id}", id);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to generate audio for: {Id}", id);
|
|
||||||
results[id] = string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets available voices for the current TTS model.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of available speaker IDs</returns>
|
|
||||||
public virtual async Task<IReadOnlyList<string>> GetAvailableVoicesAsync(
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await _ttsService.GetAvailableSpeakersAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to get available voices");
|
|
||||||
return new List<string> { "default" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets information about the current TTS model.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Model information tuple</returns>
|
|
||||||
public virtual async Task<(string ModelName, string? ModelPath)> GetModelInfoAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await _ttsService.GetModelInfoAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to get model info");
|
|
||||||
return ("unknown", null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the audio generation service.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if service is working, false otherwise</returns>
|
|
||||||
public virtual async Task<bool> TestServiceAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Test with a simple phrase
|
|
||||||
var audio = await GenerateAudioAsync("Hallo Welt", "de", null, cancellationToken);
|
|
||||||
return audio != null && audio.Length > 0;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Audio generation service test failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds narration text for a lesson including vocabulary words.
|
|
||||||
/// </summary>
|
|
||||||
private string BuildLessonNarration(string lessonText, IReadOnlyList<string> vocabularyWords)
|
|
||||||
{
|
|
||||||
// Start with the lesson text
|
|
||||||
var narration = lessonText;
|
|
||||||
|
|
||||||
// Append vocabulary words with pauses
|
|
||||||
if (vocabularyWords != null && vocabularyWords.Count > 0)
|
|
||||||
{
|
|
||||||
narration += "\n\n";
|
|
||||||
narration += "Vocabulary words: ";
|
|
||||||
narration += string.Join(", ", vocabularyWords);
|
|
||||||
}
|
|
||||||
|
|
||||||
return narration;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds narration text for a quiz question with options.
|
|
||||||
/// </summary>
|
|
||||||
private string BuildQuizNarration(string questionText, IReadOnlyList<string> options)
|
|
||||||
{
|
|
||||||
var narration = questionText;
|
|
||||||
|
|
||||||
if (options != null && options.Count > 0)
|
|
||||||
{
|
|
||||||
narration += "\n";
|
|
||||||
for (int i = 0; i < options.Count; i++)
|
|
||||||
{
|
|
||||||
narration += $"Option {i + 1}: {options[i]}";
|
|
||||||
if (i < options.Count - 1)
|
|
||||||
narration += ". ";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return narration;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,7 +22,7 @@ public class LessonService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all lessons.
|
/// Gets all lessons.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<IReadOnlyList<LessonDto>> GetAllLessonsAsync(CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<LessonDto>> GetAllLessonsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lessons = await _lessonRepository.GetAllAsync(cancellationToken);
|
var lessons = await _lessonRepository.GetAllAsync(cancellationToken);
|
||||||
return lessons.Select(l => l.ToDto()).ToList();
|
return lessons.Select(l => l.ToDto()).ToList();
|
||||||
|
|
@ -31,7 +31,7 @@ public class LessonService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all lessons ordered by level and lesson order.
|
/// Gets all lessons ordered by level and lesson order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<IReadOnlyList<LessonDto>> GetAllOrderedLessonsAsync(CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<LessonDto>> GetAllOrderedLessonsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lessons = await _lessonRepository.GetAllOrderedAsync(cancellationToken);
|
var lessons = await _lessonRepository.GetAllOrderedAsync(cancellationToken);
|
||||||
return lessons.Select(l => l.ToDto()).ToList();
|
return lessons.Select(l => l.ToDto()).ToList();
|
||||||
|
|
@ -40,7 +40,7 @@ public class LessonService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a lesson by its ID.
|
/// Gets a lesson by its ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<LessonDto?> GetLessonByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<LessonDto?> GetLessonByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lesson = await _lessonRepository.GetByIdAsync(id, cancellationToken);
|
var lesson = await _lessonRepository.GetByIdAsync(id, cancellationToken);
|
||||||
return lesson?.ToDto();
|
return lesson?.ToDto();
|
||||||
|
|
@ -49,7 +49,7 @@ public class LessonService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets lessons by level ID.
|
/// Gets lessons by level ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<IReadOnlyList<LessonDto>> GetLessonsByLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<LessonDto>> GetLessonsByLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lessons = await _lessonRepository.GetByLevelAsync(levelId, cancellationToken);
|
var lessons = await _lessonRepository.GetByLevelAsync(levelId, cancellationToken);
|
||||||
return lessons.Select(l => l.ToDto()).ToList();
|
return lessons.Select(l => l.ToDto()).ToList();
|
||||||
|
|
@ -58,7 +58,7 @@ public class LessonService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets beginner lessons (A1 and A2 - levels 1 and 2).
|
/// Gets beginner lessons (A1 and A2 - levels 1 and 2).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<IReadOnlyList<LessonDto>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<LessonDto>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level1Lessons = await _lessonRepository.GetByLevelAsync(1, cancellationToken);
|
var level1Lessons = await _lessonRepository.GetByLevelAsync(1, cancellationToken);
|
||||||
var level2Lessons = await _lessonRepository.GetByLevelAsync(2, cancellationToken);
|
var level2Lessons = await _lessonRepository.GetByLevelAsync(2, cancellationToken);
|
||||||
|
|
@ -72,7 +72,7 @@ public class LessonService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets advanced lessons (B2 and C1 - levels 4 and 5).
|
/// Gets advanced lessons (B2 and C1 - levels 4 and 5).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<IReadOnlyList<LessonDto>> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<LessonDto>> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level4Lessons = await _lessonRepository.GetByLevelAsync(4, cancellationToken);
|
var level4Lessons = await _lessonRepository.GetByLevelAsync(4, cancellationToken);
|
||||||
var level5Lessons = await _lessonRepository.GetByLevelAsync(5, cancellationToken);
|
var level5Lessons = await _lessonRepository.GetByLevelAsync(5, cancellationToken);
|
||||||
|
|
@ -86,7 +86,7 @@ public class LessonService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new lesson.
|
/// Creates a new lesson.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<LessonDto> CreateLessonAsync(CreateLessonDto dto, CancellationToken cancellationToken = default)
|
public async Task<LessonDto> CreateLessonAsync(CreateLessonDto dto, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Validate that the level exists
|
// Validate that the level exists
|
||||||
var level = await _levelRepository.GetByIdAsync(dto.LevelId, cancellationToken);
|
var level = await _levelRepository.GetByIdAsync(dto.LevelId, cancellationToken);
|
||||||
|
|
@ -101,7 +101,7 @@ public class LessonService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates an existing lesson.
|
/// Updates an existing lesson.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<LessonDto?> UpdateLessonAsync(int id, UpdateLessonDto dto, CancellationToken cancellationToken = default)
|
public async Task<LessonDto?> UpdateLessonAsync(int id, UpdateLessonDto dto, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lesson = await _lessonRepository.GetByIdAsync(id, cancellationToken);
|
var lesson = await _lessonRepository.GetByIdAsync(id, cancellationToken);
|
||||||
if (lesson == null)
|
if (lesson == null)
|
||||||
|
|
@ -120,7 +120,7 @@ public class LessonService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes a lesson by its ID.
|
/// Deletes a lesson by its ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<bool> DeleteLessonAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<bool> DeleteLessonAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lesson = await _lessonRepository.GetByIdAsync(id, cancellationToken);
|
var lesson = await _lessonRepository.GetByIdAsync(id, cancellationToken);
|
||||||
if (lesson == null)
|
if (lesson == null)
|
||||||
|
|
@ -133,7 +133,7 @@ public class LessonService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the first lesson in a level.
|
/// Gets the first lesson in a level.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<LessonDto?> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
public async Task<LessonDto?> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lesson = await _lessonRepository.GetFirstLessonInLevelAsync(levelId, cancellationToken);
|
var lesson = await _lessonRepository.GetFirstLessonInLevelAsync(levelId, cancellationToken);
|
||||||
return lesson?.ToDto();
|
return lesson?.ToDto();
|
||||||
|
|
@ -142,7 +142,7 @@ public class LessonService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the next lesson after the specified one.
|
/// Gets the next lesson after the specified one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<LessonDto?> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default)
|
public async Task<LessonDto?> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lesson = await _lessonRepository.GetNextLessonAsync(currentLessonId, cancellationToken);
|
var lesson = await _lessonRepository.GetNextLessonAsync(currentLessonId, cancellationToken);
|
||||||
return lesson?.ToDto();
|
return lesson?.ToDto();
|
||||||
|
|
@ -151,7 +151,7 @@ public class LessonService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets lessons that the user can access (based on completion of previous lessons).
|
/// Gets lessons that the user can access (based on completion of previous lessons).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<IReadOnlyList<LessonDto>> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<LessonDto>> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lessons = await _lessonRepository.GetAccessibleLessonsAsync(userId, cancellationToken);
|
var lessons = await _lessonRepository.GetAccessibleLessonsAsync(userId, cancellationToken);
|
||||||
return lessons.Select(l => l.ToDto()).ToList();
|
return lessons.Select(l => l.ToDto()).ToList();
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Domain service for managing lesson unlocking business logic.
|
|
||||||
/// This is part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class LessonUnlockService
|
|
||||||
{
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
private readonly IUserProgressRepository _userProgressRepository;
|
|
||||||
|
|
||||||
public LessonUnlockService(
|
|
||||||
ILessonRepository lessonRepository,
|
|
||||||
IUserProgressRepository userProgressRepository)
|
|
||||||
{
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
_userProgressRepository = userProgressRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if the next lesson in a level is unlocked for the user.
|
|
||||||
/// A lesson is unlocked if:
|
|
||||||
/// - It's the first lesson in the level, OR
|
|
||||||
/// - The previous lesson in the level has been completed
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="currentLessonId">The current lesson ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if the next lesson is unlocked, false otherwise</returns>
|
|
||||||
public virtual async Task<bool> IsNextLessonUnlockedAsync(
|
|
||||||
int userId,
|
|
||||||
int currentLessonId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var currentLesson = await _lessonRepository.GetByIdAsync(currentLessonId, cancellationToken);
|
|
||||||
if (currentLesson == null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// If this is the first lesson in the level, the next one is always unlocked
|
|
||||||
var firstLesson = await _lessonRepository.GetFirstLessonInLevelAsync(
|
|
||||||
currentLesson.LevelId,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
if (firstLesson?.Id == currentLessonId)
|
|
||||||
{
|
|
||||||
var nextLesson = await _lessonRepository.GetNextLessonAsync(currentLessonId, cancellationToken);
|
|
||||||
return nextLesson != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the user has completed the current lesson
|
|
||||||
var hasCompleted = await _userProgressRepository.HasUserCompletedLessonAsync(
|
|
||||||
userId,
|
|
||||||
currentLessonId,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return hasCompleted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the next lesson ID that should be unlocked for a user after completing a lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="completedLessonId">The ID of the lesson that was just completed</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The ID of the next lesson, or null if no more lessons in the level</returns>
|
|
||||||
public virtual async Task<int?> GetNextLessonIdAsync(
|
|
||||||
int userId,
|
|
||||||
int completedLessonId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var currentLesson = await _lessonRepository.GetByIdAsync(completedLessonId, cancellationToken);
|
|
||||||
if (currentLesson == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var nextLesson = await _lessonRepository.GetNextLessonAsync(completedLessonId, cancellationToken);
|
|
||||||
return nextLesson?.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all lessons that are accessible to a user (completed lessons + next unlocked lesson).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of accessible lesson IDs</returns>
|
|
||||||
public virtual async Task<IReadOnlyList<int>> GetAccessibleLessonIdsAsync(
|
|
||||||
int userId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lessons = await _lessonRepository.GetAccessibleLessonsAsync(userId, cancellationToken);
|
|
||||||
return lessons.Select(l => l.Id).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a specific lesson is accessible to a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="lessonId">The lesson ID to check</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if the lesson is accessible, false otherwise</returns>
|
|
||||||
public virtual async Task<bool> IsLessonAccessibleAsync(
|
|
||||||
int userId,
|
|
||||||
int lessonId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var accessibleLessons = await GetAccessibleLessonIdsAsync(userId, cancellationToken);
|
|
||||||
return accessibleLessons.Contains(lessonId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Service for calculating level completion metrics.
|
|
||||||
/// This is part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class LevelCompletionCalculator
|
|
||||||
{
|
|
||||||
private readonly ILevelRepository _levelRepository;
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
private readonly IUserProgressRepository _userProgressRepository;
|
|
||||||
|
|
||||||
public LevelCompletionCalculator(
|
|
||||||
ILevelRepository levelRepository,
|
|
||||||
ILessonRepository lessonRepository,
|
|
||||||
IUserProgressRepository userProgressRepository)
|
|
||||||
{
|
|
||||||
_levelRepository = levelRepository;
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
_userProgressRepository = userProgressRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates the completion percentage for a specific level for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Completion percentage (0-100)</returns>
|
|
||||||
public virtual async Task<double> CalculateLevelCompletionPercentageAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Get total lessons in the level
|
|
||||||
var lessons = await _lessonRepository.GetByLevelAsync(levelId, cancellationToken);
|
|
||||||
var totalLessons = lessons.Count;
|
|
||||||
|
|
||||||
if (totalLessons == 0)
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
// Get completed lessons count for user in this level
|
|
||||||
var completedCount = await _userProgressRepository.GetLevelCompletionPercentageAsync(
|
|
||||||
userId,
|
|
||||||
levelId,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// The repository method returns percentage, but we need the count
|
|
||||||
// Let's recalculate properly
|
|
||||||
var completedLessons = 0;
|
|
||||||
foreach (var lesson in lessons)
|
|
||||||
{
|
|
||||||
var hasCompleted = await _userProgressRepository.HasUserCompletedLessonAsync(
|
|
||||||
userId,
|
|
||||||
lesson.Id,
|
|
||||||
cancellationToken);
|
|
||||||
if (hasCompleted)
|
|
||||||
completedLessons++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (double)completedLessons / totalLessons * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets completion information for all levels for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of level completion DTOs</returns>
|
|
||||||
public virtual async Task<IReadOnlyList<LevelCompletionDto>> CalculateAllLevelCompletionsAsync(
|
|
||||||
int userId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var levels = await _levelRepository.GetAllOrderedAsync(cancellationToken);
|
|
||||||
var completions = new List<LevelCompletionDto>();
|
|
||||||
|
|
||||||
foreach (var level in levels)
|
|
||||||
{
|
|
||||||
var lessons = await _lessonRepository.GetByLevelAsync(level.Id, cancellationToken);
|
|
||||||
var totalLessons = lessons.Count;
|
|
||||||
|
|
||||||
var completedCount = 0;
|
|
||||||
foreach (var lesson in lessons)
|
|
||||||
{
|
|
||||||
var hasCompleted = await _userProgressRepository.HasUserCompletedLessonAsync(
|
|
||||||
userId,
|
|
||||||
lesson.Id,
|
|
||||||
cancellationToken);
|
|
||||||
if (hasCompleted)
|
|
||||||
completedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
var percentage = totalLessons > 0 ? (double)completedCount / totalLessons * 100 : 0;
|
|
||||||
|
|
||||||
completions.Add(new LevelCompletionDto(
|
|
||||||
level.Id,
|
|
||||||
level.Name,
|
|
||||||
level.Code,
|
|
||||||
totalLessons,
|
|
||||||
completedCount,
|
|
||||||
percentage
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return completions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a user has completed all lessons in a level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if all lessons are completed, false otherwise</returns>
|
|
||||||
public virtual async Task<bool> IsLevelFullyCompletedAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var percentage = await CalculateLevelCompletionPercentageAsync(userId, levelId, cancellationToken);
|
|
||||||
return percentage >= 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of completed lessons in a level for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Number of completed lessons</returns>
|
|
||||||
public virtual async Task<int> GetCompletedLessonCountAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lessons = await _lessonRepository.GetByLevelAsync(levelId, cancellationToken);
|
|
||||||
var completedCount = 0;
|
|
||||||
|
|
||||||
foreach (var lesson in lessons)
|
|
||||||
{
|
|
||||||
var hasCompleted = await _userProgressRepository.HasUserCompletedLessonAsync(
|
|
||||||
userId,
|
|
||||||
lesson.Id,
|
|
||||||
cancellationToken);
|
|
||||||
if (hasCompleted)
|
|
||||||
completedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return completedCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of total lessons in a level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Total number of lessons</returns>
|
|
||||||
public virtual async Task<int> GetTotalLessonCountAsync(
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lessons = await _lessonRepository.GetByLevelAsync(levelId, cancellationToken);
|
|
||||||
return lessons.Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the next level ID that should be unlocked for a user.
|
|
||||||
/// Returns the next level if the current level is fully completed.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="currentLevelId">The current level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The next level ID, or null if no more levels</returns>
|
|
||||||
public virtual async Task<int?> GetNextLevelIdAsync(
|
|
||||||
int userId,
|
|
||||||
int currentLevelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var isCompleted = await IsLevelFullyCompletedAsync(userId, currentLevelId, cancellationToken);
|
|
||||||
|
|
||||||
if (!isCompleted)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var nextLevel = await _levelRepository.GetNextLevelAsync(currentLevelId, cancellationToken);
|
|
||||||
return nextLevel?.Id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -20,7 +20,7 @@ public class LevelService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all CEFR levels ordered by their sort order.
|
/// Gets all CEFR levels ordered by their sort order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<IReadOnlyList<LevelDto>> GetAllLevelsAsync(CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<LevelDto>> GetAllLevelsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var levels = await _levelRepository.GetAllOrderedAsync(cancellationToken);
|
var levels = await _levelRepository.GetAllOrderedAsync(cancellationToken);
|
||||||
return levels.Select(l => l.ToDto()).ToList();
|
return levels.Select(l => l.ToDto()).ToList();
|
||||||
|
|
@ -29,7 +29,7 @@ public class LevelService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a level by its ID.
|
/// Gets a level by its ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<LevelDto?> GetLevelByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<LevelDto?> GetLevelByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level = await _levelRepository.GetByIdAsync(id, cancellationToken);
|
var level = await _levelRepository.GetByIdAsync(id, cancellationToken);
|
||||||
return level?.ToDto();
|
return level?.ToDto();
|
||||||
|
|
@ -38,7 +38,7 @@ public class LevelService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a level by its code (e.g., "A1", "B2").
|
/// Gets a level by its code (e.g., "A1", "B2").
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<LevelDto?> GetLevelByCodeAsync(string code, CancellationToken cancellationToken = default)
|
public async Task<LevelDto?> GetLevelByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level = await _levelRepository.GetByCodeAsync(code, cancellationToken);
|
var level = await _levelRepository.GetByCodeAsync(code, cancellationToken);
|
||||||
return level?.ToDto();
|
return level?.ToDto();
|
||||||
|
|
@ -47,7 +47,7 @@ public class LevelService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new CEFR level.
|
/// Creates a new CEFR level.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<LevelDto> CreateLevelAsync(CreateLevelDto dto, CancellationToken cancellationToken = default)
|
public async Task<LevelDto> CreateLevelAsync(CreateLevelDto dto, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level = dto.ToEntity();
|
var level = dto.ToEntity();
|
||||||
var createdLevel = await _levelRepository.AddAsync(level, cancellationToken);
|
var createdLevel = await _levelRepository.AddAsync(level, cancellationToken);
|
||||||
|
|
@ -57,7 +57,7 @@ public class LevelService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates an existing CEFR level.
|
/// Updates an existing CEFR level.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<LevelDto?> UpdateLevelAsync(int id, UpdateLevelDto dto, CancellationToken cancellationToken = default)
|
public async Task<LevelDto?> UpdateLevelAsync(int id, UpdateLevelDto dto, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level = await _levelRepository.GetByIdAsync(id, cancellationToken);
|
var level = await _levelRepository.GetByIdAsync(id, cancellationToken);
|
||||||
if (level == null)
|
if (level == null)
|
||||||
|
|
@ -71,7 +71,7 @@ public class LevelService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes a CEFR level by its ID.
|
/// Deletes a CEFR level by its ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<bool> DeleteLevelAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<bool> DeleteLevelAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level = await _levelRepository.GetByIdAsync(id, cancellationToken);
|
var level = await _levelRepository.GetByIdAsync(id, cancellationToken);
|
||||||
if (level == null)
|
if (level == null)
|
||||||
|
|
@ -84,7 +84,7 @@ public class LevelService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the first level (lowest order number) - typically A1.
|
/// Gets the first level (lowest order number) - typically A1.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<LevelDto?> GetFirstLevelAsync(CancellationToken cancellationToken = default)
|
public async Task<LevelDto?> GetFirstLevelAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level = await _levelRepository.GetFirstLevelAsync(cancellationToken);
|
var level = await _levelRepository.GetFirstLevelAsync(cancellationToken);
|
||||||
return level?.ToDto();
|
return level?.ToDto();
|
||||||
|
|
@ -93,7 +93,7 @@ public class LevelService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the next level after the specified one.
|
/// Gets the next level after the specified one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<LevelDto?> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
|
public async Task<LevelDto?> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level = await _levelRepository.GetNextLevelAsync(currentLevelId, cancellationToken);
|
var level = await _levelRepository.GetNextLevelAsync(currentLevelId, cancellationToken);
|
||||||
return level?.ToDto();
|
return level?.ToDto();
|
||||||
|
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
using GermanApp.Application.Models;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using GermanApp.Infrastructure.Configuration;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Application service for Mistral AI text generation.
|
|
||||||
/// This is part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class MistralService : IMistralService
|
|
||||||
{
|
|
||||||
private readonly IMistralConnector _connector;
|
|
||||||
private readonly MistralConfig _config;
|
|
||||||
|
|
||||||
public MistralService(
|
|
||||||
IMistralConnector connector,
|
|
||||||
IOptions<MistralConfig> config)
|
|
||||||
{
|
|
||||||
_connector = connector;
|
|
||||||
_config = config.Value ?? new MistralConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates text from a prompt using Mistral API.
|
|
||||||
/// Uses chat completion endpoint (completions endpoint is deprecated).
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> GenerateTextAsync(
|
|
||||||
string prompt,
|
|
||||||
string? model = null,
|
|
||||||
float temperature = 0.7f,
|
|
||||||
int? maxTokens = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
model ??= _config.DefaultModel;
|
|
||||||
|
|
||||||
// Use chat completion endpoint instead of completions (which is deprecated)
|
|
||||||
// Send minimal request matching the working curl: only model + messages
|
|
||||||
var messages = new List<MistralMessage>
|
|
||||||
{
|
|
||||||
new MistralMessage { Role = "user", Content = prompt }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build request - only include max_tokens and temperature if explicitly provided
|
|
||||||
var request = new MistralChatRequest
|
|
||||||
{
|
|
||||||
Model = model,
|
|
||||||
Messages = messages,
|
|
||||||
MaxTokens = maxTokens,
|
|
||||||
Temperature = temperature == 0.7f ? null : temperature
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await _connector.ChatAsync(request, cancellationToken);
|
|
||||||
|
|
||||||
if (response.Choices == null || response.Choices.Count == 0)
|
|
||||||
throw new InvalidOperationException("No choices returned from Mistral API");
|
|
||||||
|
|
||||||
return response.Choices[0].Message?.Content ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates text from chat messages using Mistral API.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> GenerateChatAsync(
|
|
||||||
IReadOnlyList<(string role, string content)> messages,
|
|
||||||
string? model = null,
|
|
||||||
float temperature = 0.7f,
|
|
||||||
int? maxTokens = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
model ??= _config.DefaultModel;
|
|
||||||
maxTokens ??= 500;
|
|
||||||
|
|
||||||
var chatMessages = messages
|
|
||||||
.Select(m => new MistralMessage { Role = m.role, Content = m.content })
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var request = new MistralChatRequest
|
|
||||||
{
|
|
||||||
Model = model,
|
|
||||||
Messages = chatMessages,
|
|
||||||
Temperature = temperature,
|
|
||||||
MaxTokens = maxTokens.Value
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await _connector.ChatAsync(request, cancellationToken);
|
|
||||||
|
|
||||||
if (response.Choices == null || response.Choices.Count == 0)
|
|
||||||
throw new InvalidOperationException("No choices returned from Mistral API");
|
|
||||||
|
|
||||||
return response.Choices[0].Message?.Content ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a story based on lesson context and vocabulary.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> GenerateStoryAsync(
|
|
||||||
string level,
|
|
||||||
string topic,
|
|
||||||
IReadOnlyList<string> vocabularyWords,
|
|
||||||
int length = 200,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var prompt = BuildStoryPrompt(level, topic, vocabularyWords, length);
|
|
||||||
|
|
||||||
return await GenerateTextAsync(
|
|
||||||
prompt,
|
|
||||||
model: _config.DefaultModel,
|
|
||||||
temperature: 0.8f,
|
|
||||||
maxTokens: 1000,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Provides feedback on user writing.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> GenerateWritingFeedbackAsync(
|
|
||||||
string userText,
|
|
||||||
string level,
|
|
||||||
string? prompt = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var messages = new List<(string role, string content)>
|
|
||||||
{
|
|
||||||
("system", BuildFeedbackSystemPrompt(level)),
|
|
||||||
("user", prompt ?? "Please provide feedback on the following German text:"),
|
|
||||||
("user", userText)
|
|
||||||
};
|
|
||||||
|
|
||||||
return await GenerateChatAsync(
|
|
||||||
messages,
|
|
||||||
model: _config.DefaultModel,
|
|
||||||
temperature: 0.3f, // Lower temperature for more deterministic feedback
|
|
||||||
maxTokens: 800,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the Mistral API connection.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Send a simple test prompt
|
|
||||||
var testPrompt = "Say 'test successful'";
|
|
||||||
var result = await GenerateTextAsync(
|
|
||||||
testPrompt,
|
|
||||||
maxTokens: 10,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
|
|
||||||
return result.Contains("test successful", System.StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds a prompt for story generation.
|
|
||||||
/// </summary>
|
|
||||||
private string BuildStoryPrompt(string level, string topic, IReadOnlyList<string> vocabularyWords, int length)
|
|
||||||
{
|
|
||||||
var vocabularyList = string.Join(", ", vocabularyWords);
|
|
||||||
|
|
||||||
return $"You are a helpful German language teacher. Create an engaging story for a {level} level learner.\n\nRequirements:\n- Topic: {topic}\n- Length: approximately {length} words\n- Use these German vocabulary words: {vocabularyList}\n- Write in German language\n- Appropriate for A1-A2 learners (simple sentences, common vocabulary)\n- Include dialogue\n- End with a question for the reader\n\nWrite only the story text, no additional explanation or formatting."
|
|
||||||
.Replace("\n\n", "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds a system prompt for writing feedback.
|
|
||||||
/// </summary>
|
|
||||||
private string BuildFeedbackSystemPrompt(string level)
|
|
||||||
{
|
|
||||||
return $"You are a helpful German language tutor. Provide constructive feedback on the user's German writing.\n\nGuidelines:\n- Respond in English\n- First, identify and correct any grammar mistakes\n- Then, provide suggestions for improvement\n- Finally, give encouragement\n- Be specific and helpful\n- Keep feedback concise (3-5 sentences)\n- Level: {level}"
|
|
||||||
.Replace("\n\n", "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -27,7 +27,7 @@ public class ProgressService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets user progress for a specific lesson.
|
/// Gets user progress for a specific lesson.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<UserProgressDto?> GetUserProgressAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
|
public async Task<UserProgressDto?> GetUserProgressAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, lessonId, cancellationToken);
|
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, lessonId, cancellationToken);
|
||||||
return progress?.ToDto();
|
return progress?.ToDto();
|
||||||
|
|
@ -36,7 +36,7 @@ public class ProgressService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all progress records for a user.
|
/// Gets all progress records for a user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<IReadOnlyList<UserProgressDto>> GetUserProgressAsync(int userId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<UserProgressDto>> GetUserProgressAsync(int userId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var progressRecords = await _userProgressRepository.GetByUserAsync(userId, cancellationToken);
|
var progressRecords = await _userProgressRepository.GetByUserAsync(userId, cancellationToken);
|
||||||
return progressRecords.Select(p => p.ToDto()).ToList();
|
return progressRecords.Select(p => p.ToDto()).ToList();
|
||||||
|
|
@ -45,7 +45,7 @@ public class ProgressService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets progress for all lessons in a specific level for a user.
|
/// Gets progress for all lessons in a specific level for a user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<IReadOnlyList<UserProgressDto>> GetUserProgressByLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<UserProgressDto>> GetUserProgressByLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var progressRecords = await _userProgressRepository.GetByUserAndLevelAsync(userId, levelId, cancellationToken);
|
var progressRecords = await _userProgressRepository.GetByUserAndLevelAsync(userId, levelId, cancellationToken);
|
||||||
return progressRecords.Select(p => p.ToDto()).ToList();
|
return progressRecords.Select(p => p.ToDto()).ToList();
|
||||||
|
|
@ -54,7 +54,7 @@ public class ProgressService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if a user has completed a lesson.
|
/// Checks if a user has completed a lesson.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<bool> HasUserCompletedLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
|
public async Task<bool> HasUserCompletedLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _userProgressRepository.HasUserCompletedLessonAsync(userId, lessonId, cancellationToken);
|
return await _userProgressRepository.HasUserCompletedLessonAsync(userId, lessonId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ public class ProgressService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Marks a lesson as completed for a user with a quiz score.
|
/// Marks a lesson as completed for a user with a quiz score.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<UserProgressDto> MarkLessonAsCompletedAsync(int userId, int lessonId, int quizScore, CancellationToken cancellationToken = default)
|
public async Task<UserProgressDto> MarkLessonAsCompletedAsync(int userId, int lessonId, int quizScore, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, lessonId, cancellationToken);
|
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, lessonId, cancellationToken);
|
||||||
|
|
||||||
|
|
@ -86,7 +86,7 @@ public class ProgressService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates user progress for a lesson without marking as completed.
|
/// Updates user progress for a lesson without marking as completed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<UserProgressDto> UpdateUserProgressAsync(int userId, UpdateUserProgressDto dto, CancellationToken cancellationToken = default)
|
public async Task<UserProgressDto> UpdateUserProgressAsync(int userId, UpdateUserProgressDto dto, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, dto.LessonId, cancellationToken);
|
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, dto.LessonId, cancellationToken);
|
||||||
|
|
||||||
|
|
@ -109,7 +109,7 @@ public class ProgressService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resets user progress for a lesson.
|
/// Resets user progress for a lesson.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<bool> ResetLessonProgressAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
|
public async Task<bool> ResetLessonProgressAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, lessonId, cancellationToken);
|
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, lessonId, cancellationToken);
|
||||||
if (progress == null)
|
if (progress == null)
|
||||||
|
|
@ -123,7 +123,7 @@ public class ProgressService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the user's average score for a level.
|
/// Gets the user's average score for a level.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<double> GetAverageScoreForLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default)
|
public async Task<double> GetAverageScoreForLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _userProgressRepository.GetAverageScoreForLevelAsync(userId, levelId, cancellationToken);
|
return await _userProgressRepository.GetAverageScoreForLevelAsync(userId, levelId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +131,7 @@ public class ProgressService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the percentage of lessons completed in a level.
|
/// Gets the percentage of lessons completed in a level.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default)
|
public async Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _userProgressRepository.GetLevelCompletionPercentageAsync(userId, levelId, cancellationToken);
|
return await _userProgressRepository.GetLevelCompletionPercentageAsync(userId, levelId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +139,7 @@ public class ProgressService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets level completion summaries for a user.
|
/// Gets level completion summaries for a user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<IReadOnlyList<LevelCompletionDto>> GetLevelCompletionsAsync(int userId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<LevelCompletionDto>> GetLevelCompletionsAsync(int userId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var levels = await _levelRepository.GetAllOrderedAsync(cancellationToken);
|
var levels = await _levelRepository.GetAllOrderedAsync(cancellationToken);
|
||||||
var completions = new List<LevelCompletionDto>();
|
var completions = new List<LevelCompletionDto>();
|
||||||
|
|
@ -176,7 +176,7 @@ public class ProgressService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets lessons that the user can access (completed previous lessons or first in level).
|
/// Gets lessons that the user can access (completed previous lessons or first in level).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<IReadOnlyList<LessonDto>> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<LessonDto>> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lessons = await _lessonRepository.GetAccessibleLessonsAsync(userId, cancellationToken);
|
var lessons = await _lessonRepository.GetAccessibleLessonsAsync(userId, cancellationToken);
|
||||||
return lessons.Select(l => l.ToDto()).ToList();
|
return lessons.Select(l => l.ToDto()).ToList();
|
||||||
|
|
@ -185,7 +185,7 @@ public class ProgressService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if the next lesson in a level is unlocked for the user.
|
/// Checks if the next lesson in a level is unlocked for the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual async Task<bool> IsNextLessonUnlockedAsync(int userId, int currentLessonId, CancellationToken cancellationToken = default)
|
public async Task<bool> IsNextLessonUnlockedAsync(int userId, int currentLessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var currentLesson = await _lessonRepository.GetByIdAsync(currentLessonId, cancellationToken);
|
var currentLesson = await _lessonRepository.GetByIdAsync(currentLessonId, cancellationToken);
|
||||||
if (currentLesson == null)
|
if (currentLesson == null)
|
||||||
|
|
|
||||||
|
|
@ -1,506 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Exceptions;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Application service for managing quiz questions.
|
|
||||||
/// This is part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class QuizQuestionService
|
|
||||||
{
|
|
||||||
private readonly IQuizQuestionRepository _quizQuestionRepository;
|
|
||||||
private readonly IQuizOptionRepository _quizOptionRepository;
|
|
||||||
private readonly IQuizRepository _quizRepository;
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
private readonly ProgressService _progressService;
|
|
||||||
|
|
||||||
public QuizQuestionService(
|
|
||||||
IQuizQuestionRepository quizQuestionRepository,
|
|
||||||
IQuizOptionRepository quizOptionRepository,
|
|
||||||
IQuizRepository quizRepository,
|
|
||||||
ILessonRepository lessonRepository,
|
|
||||||
ProgressService progressService)
|
|
||||||
{
|
|
||||||
_quizQuestionRepository = quizQuestionRepository;
|
|
||||||
_quizOptionRepository = quizOptionRepository;
|
|
||||||
_quizRepository = quizRepository;
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
_progressService = progressService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all quiz questions.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionRepository.GetAllOrderedAsync(cancellationToken);
|
|
||||||
return questions.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a quiz question by its ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizQuestionDto?> GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var question = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
return question?.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions by lesson ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionRepository.GetByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return questions.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quiz questions by lesson ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetActiveQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return questions.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a random set of quiz questions for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionRepository.GetRandomQuestionsForLessonAsync(lessonId, count, cancellationToken);
|
|
||||||
return questions.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions by difficulty level.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionRepository.GetByDifficultyAsync(difficulty, cancellationToken);
|
|
||||||
return questions.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions by type.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionRepository.GetByTypeAsync(type, cancellationToken);
|
|
||||||
return questions.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new quiz question with its options.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizQuestionDto> CreateQuizQuestionAsync(CreateQuizQuestionDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Validate that the quiz exists
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(dto.QuizId, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
throw new NotFoundException("Quiz does not exist");
|
|
||||||
|
|
||||||
// Validate question order uniqueness within the quiz
|
|
||||||
await ValidateQuestionOrderUniquenessAsync(0, dto.QuizId, dto.Order, cancellationToken);
|
|
||||||
|
|
||||||
// Create the question
|
|
||||||
var question = dto.ToEntity();
|
|
||||||
var createdQuestion = await _quizQuestionRepository.AddAsync(question, cancellationToken);
|
|
||||||
|
|
||||||
// Create options if provided
|
|
||||||
if (dto.Options != null && dto.Options.Any())
|
|
||||||
{
|
|
||||||
foreach (var optionDto in dto.Options)
|
|
||||||
{
|
|
||||||
var option = optionDto.ToEntity(createdQuestion.Id);
|
|
||||||
await _quizOptionRepository.AddAsync(option, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload the question with options
|
|
||||||
var questionWithOptions = await _quizQuestionRepository.GetByIdAsync(createdQuestion.Id, cancellationToken);
|
|
||||||
return questionWithOptions?.ToDto() ?? createdQuestion.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an existing quiz question and its options.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizQuestionDto?> UpdateQuizQuestionAsync(int id, UpdateQuizQuestionDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var question = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
if (question == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// Validate that the quiz exists
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(dto.QuizId, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
throw new NotFoundException("Quiz does not exist");
|
|
||||||
|
|
||||||
// Validate question order uniqueness within the quiz
|
|
||||||
await ValidateQuestionOrderUniquenessAsync(id, dto.QuizId, dto.Order, cancellationToken);
|
|
||||||
|
|
||||||
// Update the question
|
|
||||||
question.UpdateFromDto(dto);
|
|
||||||
await _quizQuestionRepository.UpdateAsync(question, cancellationToken);
|
|
||||||
|
|
||||||
// Update options if provided
|
|
||||||
if (dto.Options != null)
|
|
||||||
{
|
|
||||||
// Delete existing options
|
|
||||||
await _quizOptionRepository.DeleteByQuestionAsync(id, cancellationToken);
|
|
||||||
|
|
||||||
// Add new options
|
|
||||||
foreach (var optionDto in dto.Options)
|
|
||||||
{
|
|
||||||
var option = new CreateQuizOptionDto(optionDto.Text, optionDto.IsCorrect, optionDto.Order).ToEntity(id);
|
|
||||||
await _quizOptionRepository.AddAsync(option, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload the question with updated options
|
|
||||||
var updatedQuestion = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
return updatedQuestion?.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates that the question order is unique within its quiz.
|
|
||||||
/// </summary>
|
|
||||||
private async Task ValidateQuestionOrderUniquenessAsync(int excludeQuestionId, int quizId, int order, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var exists = await _quizQuestionRepository.OrderExistsInQuizAsync(quizId, order, excludeQuestionId, cancellationToken);
|
|
||||||
if (exists)
|
|
||||||
throw new ValidationException("A quiz question with this order already exists in the specified quiz");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a quiz question and its options by its ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<bool> DeleteQuizQuestionAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var question = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
if (question == null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Delete all options for this question
|
|
||||||
await _quizOptionRepository.DeleteByQuestionAsync(id, cancellationToken);
|
|
||||||
|
|
||||||
// Delete the question
|
|
||||||
await _quizQuestionRepository.DeleteAsync(question, cancellationToken);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz options for a specific question.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizOptionDto>> GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var options = await _quizOptionRepository.GetByQuestionAsync(questionId, cancellationToken);
|
|
||||||
return options.Select(o => o.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the correct option for a question.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizOptionDto?> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var option = await _quizOptionRepository.GetCorrectOptionAsync(questionId, cancellationToken);
|
|
||||||
return option?.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all correct options for a question (for MultipleSelect type).
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizOptionDto>> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var options = await _quizOptionRepository.GetCorrectOptionsAsync(questionId, cancellationToken);
|
|
||||||
return options.Select(o => o.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a quiz option for a question.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizOptionDto> CreateQuizOptionAsync(int questionId, CreateQuizOptionDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Validate that the question exists
|
|
||||||
var question = await _quizQuestionRepository.GetByIdAsync(questionId, cancellationToken);
|
|
||||||
if (question == null)
|
|
||||||
throw new ArgumentException("Quiz question does not exist");
|
|
||||||
|
|
||||||
// Validate option order uniqueness within the question
|
|
||||||
await ValidateOptionOrderUniquenessAsync(questionId, dto.Order, null, cancellationToken);
|
|
||||||
|
|
||||||
var option = dto.ToEntity(questionId);
|
|
||||||
var createdOption = await _quizOptionRepository.AddAsync(option, cancellationToken);
|
|
||||||
return createdOption.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an existing quiz option.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizOptionDto?> UpdateQuizOptionAsync(int id, UpdateQuizOptionDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var option = await _quizOptionRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
if (option == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// Validate that the question exists
|
|
||||||
var question = await _quizQuestionRepository.GetByIdAsync(dto.Id, cancellationToken);
|
|
||||||
if (question == null)
|
|
||||||
throw new ArgumentException("Quiz question does not exist");
|
|
||||||
|
|
||||||
// Validate option order uniqueness within the question
|
|
||||||
await ValidateOptionOrderUniquenessAsync(option.QuizQuestionId, dto.Order, id, cancellationToken);
|
|
||||||
|
|
||||||
option.UpdateFromDto(dto);
|
|
||||||
await _quizOptionRepository.UpdateAsync(option, cancellationToken);
|
|
||||||
return option.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates that the option order is unique within its question.
|
|
||||||
/// </summary>
|
|
||||||
private async Task ValidateOptionOrderUniquenessAsync(int questionId, int order, int? excludeOptionId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var exists = await _quizOptionRepository.OrderExistsForQuestionAsync(questionId, order, excludeOptionId, cancellationToken);
|
|
||||||
if (exists)
|
|
||||||
throw new ArgumentException("An option with this order already exists for the specified question");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a quiz option by its ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<bool> DeleteQuizOptionAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var option = await _quizOptionRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
if (option == null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
await _quizOptionRepository.DeleteAsync(option, cancellationToken);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Submits quiz answers for a specific quiz and returns the result.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizResultDto> SubmitQuizAnswersForQuizAsync(int userId, SubmitQuizDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetWithQuestionsAsync(dto.QuizId, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
throw new NotFoundException("Quiz does not exist");
|
|
||||||
|
|
||||||
var questions = quiz.Questions?.Where(q => q.IsActive).OrderBy(q => q.Order).ToList() ?? new List<QuizQuestion>();
|
|
||||||
var totalQuestions = questions.Count;
|
|
||||||
var totalPoints = questions.Sum(q => q.Points);
|
|
||||||
var scorePoints = 0;
|
|
||||||
var questionResults = new List<QuizQuestionResultDto>();
|
|
||||||
|
|
||||||
// Get user's answers (using QuizAnswerDto which has the same fields as SubmitAnswerDto)
|
|
||||||
var userAnswers = dto.Answers.ToDictionary(a => a.QuizQuestionId);
|
|
||||||
|
|
||||||
foreach (var question in questions)
|
|
||||||
{
|
|
||||||
var isCorrect = false;
|
|
||||||
var userAnswerText = string.Empty;
|
|
||||||
var correctAnswer = question.CorrectAnswer ?? string.Empty;
|
|
||||||
|
|
||||||
if (userAnswers.TryGetValue(question.Id, out var userAnswer))
|
|
||||||
{
|
|
||||||
userAnswerText = userAnswer.AnswerText ?? string.Empty;
|
|
||||||
|
|
||||||
switch (question.Type)
|
|
||||||
{
|
|
||||||
case QuestionType.MultipleChoice:
|
|
||||||
// Check if the selected option is correct
|
|
||||||
if (userAnswer.SelectedOptionIds != null && userAnswer.SelectedOptionIds.Length == 1)
|
|
||||||
{
|
|
||||||
var selectedOptionId = userAnswer.SelectedOptionIds[0];
|
|
||||||
var isOptionCorrect = await _quizOptionRepository.GetByIdAsync(selectedOptionId, cancellationToken);
|
|
||||||
isCorrect = isOptionCorrect?.IsCorrect == true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case QuestionType.MultipleSelect:
|
|
||||||
// Check if all selected options are correct and no incorrect ones are selected
|
|
||||||
if (userAnswer.SelectedOptionIds != null && userAnswer.SelectedOptionIds.Length > 0)
|
|
||||||
{
|
|
||||||
var correctOptions = await _quizOptionRepository.GetCorrectOptionsAsync(question.Id, cancellationToken);
|
|
||||||
var selectedOptions = await _quizOptionRepository.GetByQuestionAsync(question.Id, cancellationToken);
|
|
||||||
|
|
||||||
var correctIds = new HashSet<int>(correctOptions.Select(o => o.Id));
|
|
||||||
var selectedIds = new HashSet<int>(userAnswer.SelectedOptionIds);
|
|
||||||
|
|
||||||
// All selected must be correct, and all correct must be selected
|
|
||||||
isCorrect = selectedIds.SetEquals(correctIds);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case QuestionType.TrueFalse:
|
|
||||||
// Check if the answer matches
|
|
||||||
if (userAnswer.AnswerText != null)
|
|
||||||
{
|
|
||||||
isCorrect = userAnswer.AnswerText.Equals(correctAnswer, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case QuestionType.FillInTheBlank:
|
|
||||||
case QuestionType.ShortAnswer:
|
|
||||||
// Case-insensitive comparison with trimming
|
|
||||||
if (!string.IsNullOrEmpty(userAnswerText) && !string.IsNullOrEmpty(correctAnswer))
|
|
||||||
{
|
|
||||||
isCorrect = userAnswerText.Trim().Equals(correctAnswer.Trim(), StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case QuestionType.Matching:
|
|
||||||
// For matching, we would need more complex logic
|
|
||||||
// This is a simplified implementation
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCorrect)
|
|
||||||
scorePoints += question.Points;
|
|
||||||
|
|
||||||
questionResults.Add(new QuizQuestionResultDto(
|
|
||||||
question.Id,
|
|
||||||
question.QuestionText,
|
|
||||||
question.Type,
|
|
||||||
question.Points,
|
|
||||||
isCorrect,
|
|
||||||
correctAnswer,
|
|
||||||
userAnswerText));
|
|
||||||
}
|
|
||||||
|
|
||||||
var scorePercentage = totalPoints > 0 ? (double)scorePoints / totalPoints * 100 : 0;
|
|
||||||
var passed = quiz.IsPassed(scorePercentage);
|
|
||||||
|
|
||||||
// Update user progress if quiz is passed
|
|
||||||
if (passed && userId > 0)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _progressService.MarkLessonAsCompletedAsync(
|
|
||||||
userId,
|
|
||||||
quiz.LessonId,
|
|
||||||
(int)scorePercentage,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Log error but don't fail the quiz submission
|
|
||||||
// In production, you would use a logger here
|
|
||||||
// For now, we'll just swallow the exception to keep the quiz submission working
|
|
||||||
// Consider adding logging: _logger.LogError(ex, "Failed to update progress for user {UserId} lesson {LessonId}", userId, quiz.LessonId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new QuizResultDto(
|
|
||||||
quiz.Id,
|
|
||||||
quiz.LessonId,
|
|
||||||
quiz.Title,
|
|
||||||
totalQuestions,
|
|
||||||
totalPoints,
|
|
||||||
scorePoints,
|
|
||||||
scorePercentage,
|
|
||||||
passed,
|
|
||||||
TimeSpan.Zero, // Would be calculated based on actual time taken
|
|
||||||
questionResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Submits quiz answers and returns the result (legacy - converts SubmitQuizAnswersDto to SubmitQuizDto).
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizResultDto> SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Convert legacy DTO to new format
|
|
||||||
// SubmitQuizAnswersDto has QuizId and Answers (IReadOnlyList<QuizAnswerDto>)
|
|
||||||
// SubmitQuizDto has QuizId and Answers (IReadOnlyList<QuizAnswerDto>)
|
|
||||||
// They're the same, so just create a new SubmitQuizDto
|
|
||||||
var newDto = new SubmitQuizDto(dto.QuizId, dto.Answers);
|
|
||||||
return await SubmitQuizAnswersForQuizAsync(userId, newDto, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of quiz questions for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<int> GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return questions.Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the difficulty distribution for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IDictionary<int, int>> GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
|
||||||
var distribution = new Dictionary<int, int>();
|
|
||||||
|
|
||||||
for (int i = 1; i <= 5; i++)
|
|
||||||
{
|
|
||||||
distribution[i] = questions.Count(q => q.Difficulty == i);
|
|
||||||
}
|
|
||||||
|
|
||||||
return distribution;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the type distribution for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IDictionary<string, int>> GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
|
||||||
var distribution = new Dictionary<string, int>();
|
|
||||||
|
|
||||||
foreach (var type in Enum.GetValues(typeof(QuestionType)).Cast<QuestionType>())
|
|
||||||
{
|
|
||||||
distribution[type.ToString()] = questions.Count(q => q.Type == type);
|
|
||||||
}
|
|
||||||
|
|
||||||
return distribution;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Quiz-based methods (QuizId)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions by quiz ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionRepository.GetByQuizAsync(quizId, cancellationToken);
|
|
||||||
return questions.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quiz questions by quiz ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetActiveQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionRepository.GetActiveByQuizAsync(quizId, cancellationToken);
|
|
||||||
return questions.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a random set of quiz questions for a quiz.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionRepository.GetRandomQuestionsForQuizAsync(quizId, count, cancellationToken);
|
|
||||||
return questions.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total points for a quiz.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<int> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _quizQuestionRepository.GetTotalPointsForQuizAsync(quizId, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,273 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Exceptions;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Application service for managing quizzes.
|
|
||||||
/// This is part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class QuizService
|
|
||||||
{
|
|
||||||
private readonly IQuizRepository _quizRepository;
|
|
||||||
private readonly IQuizQuestionRepository _quizQuestionRepository;
|
|
||||||
private readonly IQuizOptionRepository _quizOptionRepository;
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
|
|
||||||
public QuizService(
|
|
||||||
IQuizRepository quizRepository,
|
|
||||||
IQuizQuestionRepository quizQuestionRepository,
|
|
||||||
IQuizOptionRepository quizOptionRepository,
|
|
||||||
ILessonRepository lessonRepository)
|
|
||||||
{
|
|
||||||
_quizRepository = quizRepository;
|
|
||||||
_quizQuestionRepository = quizQuestionRepository;
|
|
||||||
_quizOptionRepository = quizOptionRepository;
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all quizzes.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizDto>> GetAllQuizzesAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetAllOrderedAsync(cancellationToken);
|
|
||||||
return quizzes.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all quizzes as list items (summary).
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizListItemDto>> GetAllQuizListItemsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetAllOrderedAsync(cancellationToken);
|
|
||||||
return quizzes.Select(q => q.ToListItemDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a quiz by its ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizDto?> GetQuizByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
return quiz?.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a quiz with its questions by ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizWithQuestionsDto?> GetQuizWithQuestionsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetWithQuestionsAsync(id, cancellationToken);
|
|
||||||
return quiz?.ToQuizWithQuestionsDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quizzes by lesson ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizDto>> GetQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return quizzes.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quizzes by lesson ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizDto>> GetActiveQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return quizzes.Select(q => q.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the first quiz for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizDto?> GetFirstQuizByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetFirstByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return quiz?.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new quiz.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizDto> CreateQuizAsync(CreateQuizDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Validate that the lesson exists
|
|
||||||
var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken);
|
|
||||||
if (lesson == null)
|
|
||||||
throw new NotFoundException("Lesson does not exist");
|
|
||||||
|
|
||||||
// Validate business rules
|
|
||||||
if (dto.PassingScore < 0 || dto.PassingScore > 100)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Passing score must be between 0 and 100");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.TimeLimitMinutes < 0)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Time limit cannot be negative");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert DTO to domain entity
|
|
||||||
var quiz = dto.ToEntity();
|
|
||||||
|
|
||||||
// Add to repository
|
|
||||||
var createdQuiz = await _quizRepository.AddAsync(quiz, cancellationToken);
|
|
||||||
|
|
||||||
// Return DTO representation
|
|
||||||
return createdQuiz.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an existing quiz.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizDto?> UpdateQuizAsync(int id, UpdateQuizDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// Validate that the lesson exists
|
|
||||||
var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken);
|
|
||||||
if (lesson == null)
|
|
||||||
throw new NotFoundException("Lesson does not exist");
|
|
||||||
|
|
||||||
// Validate business rules
|
|
||||||
if (dto.PassingScore < 0 || dto.PassingScore > 100)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Passing score must be between 0 and 100");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.TimeLimitMinutes < 0)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Time limit cannot be negative");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the quiz
|
|
||||||
quiz.UpdateFromDto(dto);
|
|
||||||
await _quizRepository.UpdateAsync(quiz, cancellationToken);
|
|
||||||
|
|
||||||
// Reload and return
|
|
||||||
var updatedQuiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
return updatedQuiz?.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a quiz by its ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<bool> DeleteQuizAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// First, delete all options for all questions in this quiz
|
|
||||||
var questions = await _quizQuestionRepository.GetByQuizAsync(id, cancellationToken);
|
|
||||||
foreach (var question in questions)
|
|
||||||
{
|
|
||||||
await _quizOptionRepository.DeleteByQuestionAsync(question.Id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then delete all questions for this quiz
|
|
||||||
foreach (var question in questions)
|
|
||||||
{
|
|
||||||
await _quizQuestionRepository.DeleteAsync(question, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finally, delete the quiz itself
|
|
||||||
await _quizRepository.DeleteAsync(quiz, cancellationToken);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a quiz exists for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<bool> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _quizRepository.ExistsByLessonAsync(lessonId, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a quiz exists by ID.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _quizRepository.ExistsAsync(id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total number of quizzes.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<int> GetTotalQuizCountAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetAllAsync(cancellationToken);
|
|
||||||
return quizzes.Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total number of quizzes for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<int> GetQuizCountByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return quizzes.Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quizzes by passing score range.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizDto>> GetQuizzesByPassingScoreRangeAsync(int minScore, int maxScore, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetAllAsync(cancellationToken);
|
|
||||||
return quizzes.Where(q => q.PassingScore >= minScore && q.PassingScore <= maxScore)
|
|
||||||
.Select(q => q.ToDto())
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quizzes.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<IReadOnlyList<QuizDto>> GetActiveQuizzesAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizRepository.GetAllAsync(cancellationToken);
|
|
||||||
return quizzes.Where(q => q.IsActive)
|
|
||||||
.Select(q => q.ToDto())
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Activates a quiz.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizDto?> ActivateQuizAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
quiz.Activate();
|
|
||||||
await _quizRepository.UpdateAsync(quiz, cancellationToken);
|
|
||||||
|
|
||||||
var updatedQuiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
return updatedQuiz?.ToDto();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deactivates a quiz.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<QuizDto?> DeactivateQuizAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
quiz.Deactivate();
|
|
||||||
await _quizRepository.UpdateAsync(quiz, cancellationToken);
|
|
||||||
|
|
||||||
var updatedQuiz = await _quizRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
return updatedQuiz?.ToDto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,428 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Application service for managing speech exercises using Vosk speech recognition.
|
|
||||||
/// This is part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class SpeechExerciseService
|
|
||||||
{
|
|
||||||
private readonly IVoskService _voskService;
|
|
||||||
private readonly ILogger<SpeechExerciseService> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new SpeechExerciseService.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="voskService">The Vosk speech recognition service</param>
|
|
||||||
/// <param name="logger">Logger for service operations</param>
|
|
||||||
public SpeechExerciseService(
|
|
||||||
IVoskService voskService,
|
|
||||||
ILogger<SpeechExerciseService> logger)
|
|
||||||
{
|
|
||||||
_voskService = voskService;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from audio bytes and returns the transcribed text.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="audioBytes">The audio data in bytes</param>
|
|
||||||
/// <param name="expectedText">Optional expected text for verification</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Result containing recognized text and accuracy</returns>
|
|
||||||
public virtual async Task<SpeechRecognitionResult> RecognizeSpeechAsync(
|
|
||||||
byte[] audioBytes,
|
|
||||||
string? expectedText = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Recognizing speech from audio: {ByteCount} bytes", audioBytes?.Length ?? 0);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var recognizedText = await _voskService.RecognizeSpeechAsync(
|
|
||||||
audioBytes,
|
|
||||||
_voskService.GetType().Name == "VoskService" ? 16000 : 16000,
|
|
||||||
null,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// Calculate accuracy if expected text is provided
|
|
||||||
var accuracy = expectedText != null
|
|
||||||
? CalculateAccuracy(recognizedText, expectedText)
|
|
||||||
: 0.0;
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Speech recognized: Text={Text}, Accuracy={Accuracy:P0}",
|
|
||||||
recognizedText, accuracy);
|
|
||||||
|
|
||||||
return new SpeechRecognitionResult
|
|
||||||
{
|
|
||||||
RecognizedText = recognizedText,
|
|
||||||
ExpectedText = expectedText,
|
|
||||||
Accuracy = accuracy,
|
|
||||||
IsCorrect = accuracy >= 0.8
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to recognize speech");
|
|
||||||
throw new AiServiceException(
|
|
||||||
"Failed to recognize speech: " + ex.Message,
|
|
||||||
AiErrorCode.Temporary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from an audio file.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="audioFilePath">Path to the audio file</param>
|
|
||||||
/// <param name="expectedText">Optional expected text for verification</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Result containing recognized text and accuracy</returns>
|
|
||||||
public virtual async Task<SpeechRecognitionResult> RecognizeSpeechFromFileAsync(
|
|
||||||
string audioFilePath,
|
|
||||||
string? expectedText = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Recognizing speech from file: {FilePath}", audioFilePath);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var recognizedText = await _voskService.RecognizeSpeechFromFileAsync(
|
|
||||||
audioFilePath,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
var accuracy = expectedText != null
|
|
||||||
? CalculateAccuracy(recognizedText, expectedText)
|
|
||||||
: 0.0;
|
|
||||||
|
|
||||||
return new SpeechRecognitionResult
|
|
||||||
{
|
|
||||||
RecognizedText = recognizedText,
|
|
||||||
ExpectedText = expectedText,
|
|
||||||
Accuracy = accuracy,
|
|
||||||
IsCorrect = accuracy >= 0.8
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to recognize speech from file");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies if spoken audio matches expected text.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="audioBytes">The audio data in bytes</param>
|
|
||||||
/// <param name="expectedText">The expected text</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Result with accuracy and correctness</returns>
|
|
||||||
public virtual async Task<SpeechRecognitionResult> VerifySpeechAsync(
|
|
||||||
byte[] audioBytes,
|
|
||||||
string expectedText,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Verifying speech: Expected={Expected}", expectedText);
|
|
||||||
|
|
||||||
var result = await RecognizeSpeechAsync(audioBytes, expectedText, cancellationToken);
|
|
||||||
|
|
||||||
// Additional validation for speech exercises
|
|
||||||
result.PronunciationScore = CalculatePronunciationScore(result.RecognizedText, expectedText);
|
|
||||||
result.FluencyScore = CalculateFluencyScore(audioBytes);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a speech exercise for practicing a specific phrase.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="phrase">The phrase to practice</param>
|
|
||||||
/// <param name="difficulty">Exercise difficulty level</param>
|
|
||||||
/// <param name="hints">Optional pronunciation hints</param>
|
|
||||||
/// <returns>Speech exercise with metadata</returns>
|
|
||||||
public virtual SpeechExercise CreateExercise(
|
|
||||||
string phrase,
|
|
||||||
string difficulty = "medium",
|
|
||||||
IReadOnlyList<string>? hints = null)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Creating speech exercise: Phrase={Phrase}, Difficulty={Difficulty}", phrase, difficulty);
|
|
||||||
|
|
||||||
return new SpeechExercise
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
Phrase = phrase,
|
|
||||||
Difficulty = difficulty,
|
|
||||||
Hints = hints ?? new List<string>(),
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Evaluates a user's attempt at a speech exercise.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="exerciseId">The exercise ID</param>
|
|
||||||
/// <param name="audioBytes">The user's audio attempt</param>
|
|
||||||
/// <param name="expectedPhrase">The expected phrase (from exercise)</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Evaluation result with scores</returns>
|
|
||||||
public virtual async Task<SpeechExerciseEvaluation> EvaluateExerciseAttemptAsync(
|
|
||||||
string exerciseId,
|
|
||||||
byte[] audioBytes,
|
|
||||||
string expectedPhrase,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Evaluating exercise attempt: ExerciseId={ExerciseId}", exerciseId);
|
|
||||||
|
|
||||||
var result = await VerifySpeechAsync(audioBytes, expectedPhrase, cancellationToken);
|
|
||||||
|
|
||||||
return new SpeechExerciseEvaluation
|
|
||||||
{
|
|
||||||
ExerciseId = exerciseId,
|
|
||||||
ExpectedPhrase = expectedPhrase,
|
|
||||||
RecognizedText = result.RecognizedText,
|
|
||||||
Accuracy = result.Accuracy,
|
|
||||||
PronunciationScore = result.PronunciationScore,
|
|
||||||
FluencyScore = result.FluencyScore,
|
|
||||||
IsPassed = result.IsCorrect && result.Accuracy >= 0.8,
|
|
||||||
AttemptedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the speech exercise service.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if service is working, false otherwise</returns>
|
|
||||||
public virtual async Task<bool> TestServiceAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// We can't test without actual audio, so just verify the service is initialized
|
|
||||||
var modelInfo = await _voskService.GetModelInfoAsync();
|
|
||||||
return modelInfo.ModelName != null;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Speech exercise service test failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates text matching accuracy between recognized and expected text.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="recognized">The recognized text</param>
|
|
||||||
/// <param name="expected">The expected text</param>
|
|
||||||
/// <returns>Accuracy score (0.0 to 1.0)</returns>
|
|
||||||
private double CalculateAccuracy(string recognized, string expected)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(expected))
|
|
||||||
return 1.0;
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(recognized))
|
|
||||||
return 0.0;
|
|
||||||
|
|
||||||
// Normalize both strings
|
|
||||||
var recognizedNormalized = recognized.Trim().ToLower();
|
|
||||||
var expectedNormalized = expected.Trim().ToLower();
|
|
||||||
|
|
||||||
// Simple exact match
|
|
||||||
if (recognizedNormalized == expectedNormalized)
|
|
||||||
return 1.0;
|
|
||||||
|
|
||||||
// Calculate Levenshtein distance for similarity
|
|
||||||
var distance = LevenshteinDistance(recognizedNormalized, expectedNormalized);
|
|
||||||
var maxLength = Math.Max(recognizedNormalized.Length, expectedNormalized.Length);
|
|
||||||
|
|
||||||
if (maxLength == 0)
|
|
||||||
return 1.0;
|
|
||||||
|
|
||||||
var similarity = 1.0 - (distance / (double)maxLength);
|
|
||||||
return Math.Max(0.0, Math.Min(1.0, similarity));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates pronunciation score based on text matching.
|
|
||||||
/// </summary>
|
|
||||||
private double CalculatePronunciationScore(string recognized, string expected)
|
|
||||||
{
|
|
||||||
// For now, use accuracy as pronunciation score
|
|
||||||
// In a real implementation, you might use audio analysis
|
|
||||||
return CalculateAccuracy(recognized, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates fluency score based on audio characteristics.
|
|
||||||
/// </summary>
|
|
||||||
private double CalculateFluencyScore(byte[] audioBytes)
|
|
||||||
{
|
|
||||||
// Simple heuristic: longer audio with consistent speaking rate
|
|
||||||
// In a real implementation, analyze audio features
|
|
||||||
if (audioBytes == null || audioBytes.Length == 0)
|
|
||||||
return 0.0;
|
|
||||||
|
|
||||||
// Assume 16kHz sample rate, 2 bytes per sample (16-bit)
|
|
||||||
var durationSeconds = audioBytes.Length / (16000.0 * 2);
|
|
||||||
|
|
||||||
// Give higher score for longer, consistent speech
|
|
||||||
if (durationSeconds >= 3.0)
|
|
||||||
return 1.0;
|
|
||||||
if (durationSeconds >= 1.5)
|
|
||||||
return 0.8;
|
|
||||||
if (durationSeconds >= 0.5)
|
|
||||||
return 0.6;
|
|
||||||
|
|
||||||
return 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates Levenshtein distance between two strings.
|
|
||||||
/// </summary>
|
|
||||||
private static int LevenshteinDistance(string s, string t)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(s))
|
|
||||||
{
|
|
||||||
return string.IsNullOrEmpty(t) ? 0 : t.Length;
|
|
||||||
}
|
|
||||||
if (string.IsNullOrEmpty(t))
|
|
||||||
{
|
|
||||||
return s.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
var n = s.Length;
|
|
||||||
var m = t.Length;
|
|
||||||
var d = new int[n + 1, m + 1];
|
|
||||||
|
|
||||||
for (int i = 0; i <= n; d[i, 0] = i++)
|
|
||||||
;
|
|
||||||
for (int j = 1; j <= m; d[0, j] = j++)
|
|
||||||
;
|
|
||||||
|
|
||||||
for (int i = 1; i <= n; i++)
|
|
||||||
{
|
|
||||||
for (int j = 1; j <= m; j++)
|
|
||||||
{
|
|
||||||
var cost = (t[j - 1] == s[i - 1]) ? 0 : 1;
|
|
||||||
d[i, j] = Math.Min(
|
|
||||||
Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1),
|
|
||||||
d[i - 1, j - 1] + cost);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return d[n, m];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Result of speech recognition.
|
|
||||||
/// </summary>
|
|
||||||
public class SpeechRecognitionResult
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The recognized text.
|
|
||||||
/// </summary>
|
|
||||||
public string RecognizedText { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The expected text (if provided for verification).
|
|
||||||
/// </summary>
|
|
||||||
public string? ExpectedText { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Accuracy score (0.0 to 1.0).
|
|
||||||
/// </summary>
|
|
||||||
public double Accuracy { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pronunciation score (0.0 to 1.0).
|
|
||||||
/// </summary>
|
|
||||||
public double PronunciationScore { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fluency score (0.0 to 1.0).
|
|
||||||
/// </summary>
|
|
||||||
public double FluencyScore { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the recognition is considered correct.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsCorrect { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a speech exercise.
|
|
||||||
/// </summary>
|
|
||||||
public class SpeechExercise
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Unique identifier for the exercise.
|
|
||||||
/// </summary>
|
|
||||||
public string Id { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The phrase to practice.
|
|
||||||
/// </summary>
|
|
||||||
public string Phrase { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Difficulty level (easy, medium, hard).
|
|
||||||
/// </summary>
|
|
||||||
public string Difficulty { get; set; } = "medium";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pronunciation hints.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<string> Hints { get; set; } = new List<string>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// When the exercise was created.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Result of evaluating a speech exercise attempt.
|
|
||||||
/// </summary>
|
|
||||||
public class SpeechExerciseEvaluation
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The exercise ID.
|
|
||||||
/// </summary>
|
|
||||||
public string ExerciseId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The expected phrase.
|
|
||||||
/// </summary>
|
|
||||||
public string ExpectedPhrase { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The recognized text.
|
|
||||||
/// </summary>
|
|
||||||
public string RecognizedText { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Accuracy score (0.0 to 1.0).
|
|
||||||
/// </summary>
|
|
||||||
public double Accuracy { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pronunciation score (0.0 to 1.0).
|
|
||||||
/// </summary>
|
|
||||||
public double PronunciationScore { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fluency score (0.0 to 1.0).
|
|
||||||
/// </summary>
|
|
||||||
public double FluencyScore { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the attempt passed.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsPassed { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// When the attempt was made.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime AttemptedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
@ -1,566 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Application service for generating stories using AI.
|
|
||||||
/// This is part of the Application layer and uses MistralService for text generation.
|
|
||||||
/// </summary>
|
|
||||||
public class StoryGenerationService
|
|
||||||
{
|
|
||||||
private readonly IMistralService _mistralService;
|
|
||||||
private readonly IStoryRepository _storyRepository;
|
|
||||||
private readonly ITtsService _ttsService;
|
|
||||||
private readonly ILogger<StoryGenerationService> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new StoryGenerationService.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="mistralService">Mistral service for text generation</param>
|
|
||||||
/// <param name="storyRepository">Repository for story segments</param>
|
|
||||||
/// <param name="ttsService">TTS service for audio generation</param>
|
|
||||||
/// <param name="logger">Logger for service operations</param>
|
|
||||||
public StoryGenerationService(
|
|
||||||
IMistralService mistralService,
|
|
||||||
IStoryRepository storyRepository,
|
|
||||||
ITtsService ttsService,
|
|
||||||
ILogger<StoryGenerationService> logger)
|
|
||||||
{
|
|
||||||
_mistralService = mistralService;
|
|
||||||
_storyRepository = storyRepository;
|
|
||||||
_ttsService = ttsService;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a complete story for a level and divides it into segments.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="theme">The theme for the story</param>
|
|
||||||
/// <param name="lessons">List of lessons in the level with their vocabulary</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Response DTO with the full story and segments</returns>
|
|
||||||
public virtual async Task<StoryGenerationResponseDto> GenerateStoryAsync(
|
|
||||||
int levelId,
|
|
||||||
string theme,
|
|
||||||
IReadOnlyList<Lesson> lessons,
|
|
||||||
int segmentCount = 5,
|
|
||||||
string? customPrompt = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Generating story for level {LevelId} with theme '{Theme}'",
|
|
||||||
levelId, theme);
|
|
||||||
|
|
||||||
// Extract vocabulary from all lessons
|
|
||||||
var allVocabulary = ExtractVocabularyFromLessons(lessons);
|
|
||||||
|
|
||||||
if (!allVocabulary.Any())
|
|
||||||
{
|
|
||||||
_logger.LogWarning("No vocabulary found for level {LevelId}", levelId);
|
|
||||||
throw new InvalidOperationException("Cannot generate story: No vocabulary available");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the prompt for Mistral
|
|
||||||
var prompt = BuildStoryPrompt(levelId, theme, allVocabulary, segmentCount, customPrompt);
|
|
||||||
|
|
||||||
_logger.LogDebug("Story generation prompt: {Prompt}", prompt);
|
|
||||||
|
|
||||||
// Generate the full story
|
|
||||||
var fullStory = await _mistralService.GenerateStoryAsync(
|
|
||||||
GetLevelCode(levelId),
|
|
||||||
theme,
|
|
||||||
allVocabulary,
|
|
||||||
500, // Approximate word count
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(fullStory))
|
|
||||||
{
|
|
||||||
_logger.LogError("Failed to generate story: Empty response from AI service");
|
|
||||||
throw new InvalidOperationException("Failed to generate story: Empty response");
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Generated story text (length: {Length} chars)", fullStory.Length);
|
|
||||||
|
|
||||||
// Split the story into segments (one per lesson)
|
|
||||||
var segments = SplitStoryIntoSegments(fullStory, lessons.Count);
|
|
||||||
|
|
||||||
if (segments.Count != lessons.Count)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(
|
|
||||||
"Story split into {SegmentCount} segments but expected {LessonCount}",
|
|
||||||
segments.Count, lessons.Count);
|
|
||||||
// Adjust: if we have fewer segments, duplicate the last one
|
|
||||||
// If we have more, combine some
|
|
||||||
segments = AdjustSegmentCount(segments, lessons.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create story segment entities
|
|
||||||
var createdSegments = new List<StorySegment>();
|
|
||||||
for (int i = 0; i < segments.Count; i++)
|
|
||||||
{
|
|
||||||
var segment = StorySegment.Create(
|
|
||||||
levelId,
|
|
||||||
lessons[i].Id,
|
|
||||||
segments[i],
|
|
||||||
i + 1, // Order starts at 1
|
|
||||||
$"{theme} - Part {i + 1}",
|
|
||||||
theme,
|
|
||||||
2); // Estimated reading minutes
|
|
||||||
|
|
||||||
createdSegments.Add(segment);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save all segments
|
|
||||||
foreach (var segment in createdSegments)
|
|
||||||
{
|
|
||||||
await _storyRepository.AddAsync(segment, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Created {Count} story segments for level {LevelId}",
|
|
||||||
createdSegments.Count, levelId);
|
|
||||||
|
|
||||||
// Convert to DTOs
|
|
||||||
var segmentDtos = createdSegments.Select(StorySegmentDto.FromEntity).ToList();
|
|
||||||
|
|
||||||
return new StoryGenerationResponseDto(
|
|
||||||
levelId,
|
|
||||||
theme,
|
|
||||||
segments.Count,
|
|
||||||
fullStory,
|
|
||||||
segmentDtos);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a story segment for a specific lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <param name="theme">The theme for the story</param>
|
|
||||||
/// <param name="vocabulary">Vocabulary words to include</param>
|
|
||||||
/// <param name="order">The order of this segment</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The created story segment DTO</returns>
|
|
||||||
public virtual async Task<StorySegmentDto> GenerateSegmentAsync(
|
|
||||||
int levelId,
|
|
||||||
int lessonId,
|
|
||||||
string theme,
|
|
||||||
IReadOnlyList<string> vocabulary,
|
|
||||||
int order,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Generating story segment for level {LevelId}, lesson {LessonId}, order {Order}",
|
|
||||||
levelId, lessonId, order);
|
|
||||||
|
|
||||||
if (!vocabulary.Any())
|
|
||||||
{
|
|
||||||
_logger.LogWarning("No vocabulary provided for segment generation");
|
|
||||||
throw new InvalidOperationException("Cannot generate segment: No vocabulary available");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build prompt for a single segment
|
|
||||||
var prompt = BuildSegmentPrompt(GetLevelCode(levelId), theme, vocabulary);
|
|
||||||
|
|
||||||
_logger.LogDebug("Segment generation prompt: {Prompt}", prompt);
|
|
||||||
|
|
||||||
// Generate the segment
|
|
||||||
var content = await _mistralService.GenerateStoryAsync(
|
|
||||||
GetLevelCode(levelId),
|
|
||||||
theme,
|
|
||||||
vocabulary,
|
|
||||||
100, // Approximate word count for a segment
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
|
||||||
{
|
|
||||||
_logger.LogError("Failed to generate story segment: Empty response from AI service");
|
|
||||||
throw new InvalidOperationException("Failed to generate segment: Empty response");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and save the segment
|
|
||||||
var segment = StorySegment.Create(
|
|
||||||
levelId,
|
|
||||||
lessonId,
|
|
||||||
content,
|
|
||||||
order,
|
|
||||||
$"{theme} - Part {order}",
|
|
||||||
theme,
|
|
||||||
2);
|
|
||||||
|
|
||||||
var created = await _storyRepository.AddAsync(segment, cancellationToken);
|
|
||||||
|
|
||||||
_logger.LogInformation("Created story segment with ID: {Id}", created.Id);
|
|
||||||
|
|
||||||
return StorySegmentDto.FromEntity(created);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio for a story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="segmentId">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The updated segment DTO with audio URL</returns>
|
|
||||||
public virtual async Task<StorySegmentDto?> GenerateAudioAsync(
|
|
||||||
int segmentId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Generating audio for story segment: {SegmentId}", segmentId);
|
|
||||||
|
|
||||||
var segment = await _storyRepository.GetByIdAsync(segmentId, cancellationToken);
|
|
||||||
|
|
||||||
if (segment == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Segment not found: {SegmentId}", segmentId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(segment.AudioUrl))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Segment already has audio: {SegmentId}", segmentId);
|
|
||||||
return StorySegmentDto.FromEntity(segment);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate file system path for audio file (relative to current directory)
|
|
||||||
var audioDirectory = Path.Combine("wwwroot", "audio", "story");
|
|
||||||
var filename = $"level{segment.LevelId}-segment{segment.Order}.wav";
|
|
||||||
var audioFilePath = Path.Combine(audioDirectory, filename);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Ensure directory exists
|
|
||||||
Directory.CreateDirectory(audioDirectory);
|
|
||||||
|
|
||||||
// Generate audio using the TTS service
|
|
||||||
await _ttsService.GenerateAudioToFileAsync(
|
|
||||||
segment.Content,
|
|
||||||
audioFilePath,
|
|
||||||
null,
|
|
||||||
"de",
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// Convert to URL path for storage
|
|
||||||
var audioUrl = $"/audio/story/{filename}";
|
|
||||||
segment.UpdateAudioUrl(audioUrl);
|
|
||||||
await _storyRepository.UpdateAsync(segment, cancellationToken);
|
|
||||||
|
|
||||||
_logger.LogInformation("Generated audio for segment {SegmentId}: {AudioUrl}",
|
|
||||||
segmentId, audioUrl);
|
|
||||||
|
|
||||||
return StorySegmentDto.FromEntity(segment);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to generate audio for segment {SegmentId}", segmentId);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio for all segments that don't have audio yet.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">Optional level ID to limit generation to</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of segments that had audio generated</returns>
|
|
||||||
public virtual async Task<IReadOnlyList<StorySegmentDto>> GenerateAudioForAllSegmentsAsync(
|
|
||||||
int? levelId = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Generating audio for all segments needing audio");
|
|
||||||
|
|
||||||
var segments = await _storyRepository.GetSegmentsNeedingAudioAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (levelId.HasValue)
|
|
||||||
{
|
|
||||||
segments = segments.Where(s => s.LevelId == levelId.Value).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Found {Count} segments needing audio", segments.Count);
|
|
||||||
|
|
||||||
var results = new List<StorySegmentDto>();
|
|
||||||
foreach (var segment in segments)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await GenerateAudioAsync(segment.Id, cancellationToken);
|
|
||||||
if (result != null)
|
|
||||||
{
|
|
||||||
results.Add(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to generate audio for segment {SegmentId}", segment.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Generated audio for {Count} segments", results.Count);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extracts vocabulary words from lessons.
|
|
||||||
/// This is a placeholder - in a real implementation, vocabulary would be stored in the lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessons">List of lessons</param>
|
|
||||||
/// <returns>List of vocabulary words</returns>
|
|
||||||
private IReadOnlyList<string> ExtractVocabularyFromLessons(IReadOnlyList<Lesson> lessons)
|
|
||||||
{
|
|
||||||
// For now, extract from lesson titles and topics
|
|
||||||
// In a real implementation, lessons would have a VocabularyWords collection
|
|
||||||
var vocabulary = new List<string>();
|
|
||||||
|
|
||||||
foreach (var lesson in lessons)
|
|
||||||
{
|
|
||||||
// Extract words from title (simple word splitting)
|
|
||||||
var titleWords = lesson.Title.Split(new[] { ' ', ',', '.', '!', '?' },
|
|
||||||
StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
vocabulary.AddRange(titleWords);
|
|
||||||
|
|
||||||
// Extract words from topic
|
|
||||||
var topicWords = lesson.Topic.Split(new[] { ' ', ',', '.', '!', '?' },
|
|
||||||
StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
vocabulary.AddRange(topicWords);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove duplicates and filter
|
|
||||||
return vocabulary
|
|
||||||
.Where(w => !string.IsNullOrWhiteSpace(w))
|
|
||||||
.Where(w => w.Length > 2) // Skip very short words
|
|
||||||
.Distinct()
|
|
||||||
.OrderBy(w => w)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds a prompt for full story generation.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="theme">The story theme</param>
|
|
||||||
/// <param name="vocabulary">List of vocabulary words</param>
|
|
||||||
/// <param name="segmentCount">Number of segments to generate</param>
|
|
||||||
/// <returns>The prompt string</returns>
|
|
||||||
private string BuildStoryPrompt(int levelId, string theme, IReadOnlyList<string> vocabulary, int segmentCount, string? customPrompt = null)
|
|
||||||
{
|
|
||||||
var levelCode = GetLevelCode(levelId);
|
|
||||||
var vocabularyString = string.Join(", ", vocabulary.Take(20));
|
|
||||||
|
|
||||||
if (vocabulary.Count > 20)
|
|
||||||
{
|
|
||||||
vocabularyString += ", ...";
|
|
||||||
}
|
|
||||||
|
|
||||||
var requirements = GetStoryRequirements(levelCode);
|
|
||||||
var prompt = $@"Write a {segmentCount}-part continuous story for a German {levelCode} learner.
|
|
||||||
The story theme is: {theme}.
|
|
||||||
Include these German words and phrases: {vocabularyString}.
|
|
||||||
|
|
||||||
REQUIREMENTS:
|
|
||||||
{requirements}";
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(customPrompt))
|
|
||||||
{
|
|
||||||
prompt += $"\n\nCUSTOM PROMPT:\n{customPrompt}";
|
|
||||||
}
|
|
||||||
|
|
||||||
return prompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetStoryRequirements(string levelCode) => levelCode switch
|
|
||||||
{
|
|
||||||
"A1" => "- Use ONLY A1-level vocabulary and grammar\n" +
|
|
||||||
"- Use simple present tense (ich bin, ich habe, ich gehe)\n" +
|
|
||||||
"- Basic sentence structure: Subject-Verb-Object\n" +
|
|
||||||
"- Vocabulary: max 500 words, sentence length: max 10 words\n" +
|
|
||||||
"- Each part: 2-3 short sentences\n" +
|
|
||||||
"- Do NOT use: subjunctive, genitive, complex prepositions",
|
|
||||||
"A2" => "- Use ONLY A2-level vocabulary and grammar\n" +
|
|
||||||
"- Use present, past (Perfekt), future tense\n" +
|
|
||||||
"- Can use subordinate clauses with weil, dass, wenn\n" +
|
|
||||||
"- Vocabulary: 500-1000 words, sentence length: max 15 words\n" +
|
|
||||||
"- Each part: 3-4 sentences\n" +
|
|
||||||
"- Do NOT use: Konjunktiv II, Passiv, complex noun compounds",
|
|
||||||
"B1" => "- Use ONLY B1-level vocabulary and grammar\n" +
|
|
||||||
"- Use all tenses: present, past (Praeteritum/Perfekt), future\n" +
|
|
||||||
"- Use modal verbs: koennen, muessen, duerfen, sollen, wollen, moegen\n" +
|
|
||||||
"- Sentence structure: complex sentences with subordinate clauses\n" +
|
|
||||||
"- Vocabulary: 1000-2000 words, sentence length: max 20 words\n" +
|
|
||||||
"- Each part: 4-5 sentences",
|
|
||||||
"B2" => "- Use ONLY B2-level vocabulary and grammar\n" +
|
|
||||||
"- Use nuanced tenses: Present, Praeteritum, Perfekt, Plusquamperfekt, Future I/II\n" +
|
|
||||||
"- Use all cases: Nominativ, Akkusativ, Dativ, Genitiv\n" +
|
|
||||||
"- Use Konjunktiv I (indirect speech) and Konjunktiv II\n" +
|
|
||||||
"- Use Passiv where appropriate\n" +
|
|
||||||
"- Vocabulary: 2000-3000 words, sentence length: max 25 words\n" +
|
|
||||||
"- Each part: 5-6 sentences",
|
|
||||||
"C1" => "- Use C1-level vocabulary and grammar with precision\n" +
|
|
||||||
"- Use all tenses and moods: Indikativ, Konjunktiv I/II, Passiv in all forms\n" +
|
|
||||||
"- Sentence structure: highly complex with nested relative clauses\n" +
|
|
||||||
"- Use sophisticated vocabulary including idiomatic expressions\n" +
|
|
||||||
"- Vocabulary: 3000-5000 words, sentence length: can exceed 25 words\n" +
|
|
||||||
"- Each part: 6-8 sentences",
|
|
||||||
_ => "- Use only " + levelCode + " level vocabulary and grammar"
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds a prompt for a single story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelCode">The level code (A1, A2, etc.)</param>
|
|
||||||
/// <param name="theme">The story theme</param>
|
|
||||||
/// <param name="vocabulary">List of vocabulary words</param>
|
|
||||||
/// <returns>The prompt string</returns>
|
|
||||||
private string BuildSegmentPrompt(string levelCode, string theme, IReadOnlyList<string> vocabulary)
|
|
||||||
{
|
|
||||||
var vocabularyString = string.Join(", ", vocabulary.Take(15));
|
|
||||||
|
|
||||||
if (vocabulary.Count > 15)
|
|
||||||
{
|
|
||||||
vocabularyString += ", ...";
|
|
||||||
}
|
|
||||||
|
|
||||||
var sentenceCount = levelCode switch { "A1" => "3-5", "A2" => "4-6", "B1" => "5-7", "B2" => "6-8", "C1" => "7-10", _ => "5-8" };
|
|
||||||
var requirements = GetSegmentRequirements(levelCode);
|
|
||||||
return $@"Write a short story segment ({sentenceCount} sentences) for a German {levelCode} learner.
|
|
||||||
The theme is: {theme}.
|
|
||||||
Include these German words: {vocabularyString}.
|
|
||||||
|
|
||||||
REQUIREMENTS:
|
|
||||||
{requirements}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetSegmentRequirements(string levelCode) => levelCode switch
|
|
||||||
{
|
|
||||||
"A1" => "- Use ONLY A1-level vocabulary and grammar\n" +
|
|
||||||
"- Use simple present tense only (ich bin, ich habe, ich gehe)\n" +
|
|
||||||
"- Basic sentence structure: Subject-Verb-Object\n" +
|
|
||||||
"- Sentence length: max 8 words\n" +
|
|
||||||
"- Do NOT use: subjunctive, genitive, complex prepositions",
|
|
||||||
"A2" => "- Use ONLY A2-level vocabulary and grammar\n" +
|
|
||||||
"- Use present, past (Perfekt), future tense\n" +
|
|
||||||
"- Can use simple subordinate clauses with weil, dass, wenn\n" +
|
|
||||||
"- Sentence length: max 12 words",
|
|
||||||
"B1" => "- Use ONLY B1-level vocabulary and grammar\n" +
|
|
||||||
"- Use present, past (Praeteritum/Perfekt), future tense\n" +
|
|
||||||
"- Use modal verbs: koennen, muessen, duerfen, sollen, wollen\n" +
|
|
||||||
"- Sentence structure: complex sentences with subordinate clauses\n" +
|
|
||||||
"- Sentence length: max 20 words",
|
|
||||||
"B2" => "- Use ONLY B2-level vocabulary and grammar\n" +
|
|
||||||
"- Use nuanced tenses including Plusquamperfekt and Konjunktiv II\n" +
|
|
||||||
"- Use all cases: Nominativ, Akkusativ, Dativ, Genitiv\n" +
|
|
||||||
"- Use Passiv where appropriate\n" +
|
|
||||||
"- Sentence length: max 25 words",
|
|
||||||
"C1" => "- Use C1-level vocabulary and grammar with precision\n" +
|
|
||||||
"- Use all tenses, moods, and cases correctly\n" +
|
|
||||||
"- Use sophisticated vocabulary including idiomatic expressions\n" +
|
|
||||||
"- Sentence structure: complex nested sentences with multiple clauses",
|
|
||||||
_ => "- Use only " + levelCode + " level vocabulary and grammar"
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Splits a full story text into segments.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="story">The full story text</param>
|
|
||||||
/// <param name="segmentCount">Number of segments to create</param>
|
|
||||||
/// <returns>List of story segments</returns>
|
|
||||||
private List<string> SplitStoryIntoSegments(string story, int segmentCount)
|
|
||||||
{
|
|
||||||
var segments = new List<string>();
|
|
||||||
|
|
||||||
// Split by double newlines (paragraphs)
|
|
||||||
var paragraphs = story.Split(new[] { "\n\n", "\n\r\n", "\r\n\r\n" },
|
|
||||||
StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
|
|
||||||
if (paragraphs.Length >= segmentCount)
|
|
||||||
{
|
|
||||||
// Take the first segmentCount paragraphs
|
|
||||||
for (int i = 0; i < segmentCount; i++)
|
|
||||||
{
|
|
||||||
segments.Add(paragraphs[i].Trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Need to split paragraphs further
|
|
||||||
// Calculate how many paragraphs per segment we need
|
|
||||||
var paragraphsPerSegment = Math.Max(1, paragraphs.Length / segmentCount);
|
|
||||||
|
|
||||||
for (int i = 0; i < segmentCount; i++)
|
|
||||||
{
|
|
||||||
var start = i * paragraphsPerSegment;
|
|
||||||
var end = Math.Min((i + 1) * paragraphsPerSegment, paragraphs.Length);
|
|
||||||
|
|
||||||
var segmentText = string.Join(" ", paragraphs[start..end]).Trim();
|
|
||||||
segments.Add(segmentText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adjusts the segment count to match the expected number.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="segments">Current list of segments</param>
|
|
||||||
/// <param name="targetCount">Target number of segments</param>
|
|
||||||
/// <returns>Adjusted list of segments</returns>
|
|
||||||
private List<string> AdjustSegmentCount(List<string> segments, int targetCount)
|
|
||||||
{
|
|
||||||
if (segments.Count == targetCount)
|
|
||||||
return segments;
|
|
||||||
|
|
||||||
if (segments.Count < targetCount)
|
|
||||||
{
|
|
||||||
// Duplicate the last segment to fill
|
|
||||||
while (segments.Count < targetCount)
|
|
||||||
{
|
|
||||||
segments.Add(segments[^1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Combine segments to reduce count
|
|
||||||
var result = new List<string>();
|
|
||||||
var combineCount = (int)Math.Ceiling((double)segments.Count / targetCount);
|
|
||||||
|
|
||||||
for (int i = 0; i < segments.Count; i += combineCount)
|
|
||||||
{
|
|
||||||
var end = Math.Min(i + combineCount, segments.Count);
|
|
||||||
var combined = string.Join(" ", segments[i..end]).Trim();
|
|
||||||
result.Add(combined);
|
|
||||||
}
|
|
||||||
|
|
||||||
segments = result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the level code (A1, A2, etc.) from the level ID.
|
|
||||||
/// This is a temporary implementation - in a real app, we would look this up.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <returns>The level code</returns>
|
|
||||||
private string GetLevelCode(int levelId)
|
|
||||||
{
|
|
||||||
// This is a placeholder - the actual mapping should come from the database
|
|
||||||
return levelId switch
|
|
||||||
{
|
|
||||||
1 => "A1",
|
|
||||||
2 => "A2",
|
|
||||||
3 => "B1",
|
|
||||||
4 => "B2",
|
|
||||||
5 => "C1",
|
|
||||||
_ => "A1"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,487 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Application service for managing story segments.
|
|
||||||
/// This is part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class StoryService
|
|
||||||
{
|
|
||||||
private readonly IStoryRepository _storyRepository;
|
|
||||||
private readonly IStoryProgressRepository _progressRepository;
|
|
||||||
private readonly ILogger<StoryService> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new StoryService.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storyRepository">Repository for story segments</param>
|
|
||||||
/// <param name="progressRepository">Repository for story progress</param>
|
|
||||||
/// <param name="logger">Logger for service operations</param>
|
|
||||||
public StoryService(
|
|
||||||
IStoryRepository storyRepository,
|
|
||||||
IStoryProgressRepository progressRepository,
|
|
||||||
ILogger<StoryService> logger)
|
|
||||||
{
|
|
||||||
_storyRepository = storyRepository;
|
|
||||||
_progressRepository = progressRepository;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a story segment by its ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The story segment DTO, or null if not found</returns>
|
|
||||||
public virtual async Task<StorySegmentDto?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Getting story segment by ID: {Id}", id);
|
|
||||||
|
|
||||||
var segment = await _storyRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
|
|
||||||
if (segment == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Story segment not found: {Id}", id);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return StorySegmentDto.FromEntity(segment);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all story segments for a specific level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="includeInactive">Whether to include inactive segments</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of story segment DTOs</returns>
|
|
||||||
public virtual async Task<IReadOnlyList<StorySegmentDto>> GetByLevelAsync(
|
|
||||||
int levelId,
|
|
||||||
bool includeInactive = false,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Getting story segments for level: {LevelId}", levelId);
|
|
||||||
|
|
||||||
var segments = await _storyRepository.GetByLevelAsync(levelId, includeInactive, cancellationToken);
|
|
||||||
|
|
||||||
return segments.Select(StorySegmentDto.FromEntity).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all story segments for a specific lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of story segment DTOs</returns>
|
|
||||||
public virtual async Task<IReadOnlyList<StorySegmentDto>> GetByLessonAsync(
|
|
||||||
int lessonId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Getting story segments for lesson: {LessonId}", lessonId);
|
|
||||||
|
|
||||||
var segments = await _storyRepository.GetByLessonAsync(lessonId, cancellationToken);
|
|
||||||
|
|
||||||
return segments.Select(StorySegmentDto.FromEntity).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="dto">The DTO containing segment data</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The created story segment DTO</returns>
|
|
||||||
public virtual async Task<StorySegmentDto> CreateAsync(
|
|
||||||
CreateStorySegmentDto dto,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Creating story segment: LevelId={LevelId}, Order={Order}, Title={Title}",
|
|
||||||
dto.LevelId, dto.Order, dto.Title);
|
|
||||||
|
|
||||||
// Check if a segment with the same level and order already exists
|
|
||||||
var existing = await _storyRepository.GetByOrderRangeAsync(
|
|
||||||
dto.LevelId, dto.Order, dto.Order, cancellationToken);
|
|
||||||
|
|
||||||
if (existing.Any())
|
|
||||||
{
|
|
||||||
_logger.LogError(
|
|
||||||
"Story segment with LevelId={LevelId} and Order={Order} already exists",
|
|
||||||
dto.LevelId, dto.Order);
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"A story segment with Order={dto.Order} already exists for level {dto.LevelId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the entity
|
|
||||||
var segment = StorySegment.Create(
|
|
||||||
dto.LevelId,
|
|
||||||
dto.LessonId,
|
|
||||||
dto.Content,
|
|
||||||
dto.Order,
|
|
||||||
dto.Title,
|
|
||||||
dto.Theme,
|
|
||||||
dto.EstimatedReadingMinutes);
|
|
||||||
|
|
||||||
// Save to repository
|
|
||||||
var created = await _storyRepository.AddAsync(segment, cancellationToken);
|
|
||||||
|
|
||||||
_logger.LogInformation("Created story segment with ID: {Id}", created.Id);
|
|
||||||
|
|
||||||
return StorySegmentDto.FromEntity(created);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an existing story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The segment ID</param>
|
|
||||||
/// <param name="dto">The DTO containing updated data</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The updated story segment DTO, or null if not found</returns>
|
|
||||||
public virtual async Task<StorySegmentDto?> UpdateAsync(
|
|
||||||
int id,
|
|
||||||
UpdateStorySegmentDto dto,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Updating story segment: {Id}", id);
|
|
||||||
|
|
||||||
var segment = await _storyRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
|
|
||||||
if (segment == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Story segment not found for update: {Id}", id);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply updates
|
|
||||||
if (dto.Content != null)
|
|
||||||
segment.UpdateContent(dto.Content);
|
|
||||||
|
|
||||||
if (dto.Title != null)
|
|
||||||
segment.UpdateTitle(dto.Title);
|
|
||||||
|
|
||||||
if (dto.Theme != null)
|
|
||||||
segment.UpdateTheme(dto.Theme);
|
|
||||||
|
|
||||||
if (dto.Order != null)
|
|
||||||
segment.UpdateOrder(dto.Order.Value);
|
|
||||||
|
|
||||||
if (dto.EstimatedReadingMinutes != null)
|
|
||||||
segment.UpdateEstimatedReadingMinutes(dto.EstimatedReadingMinutes.Value);
|
|
||||||
|
|
||||||
if (dto.LessonId != null)
|
|
||||||
segment.UpdateLesson(dto.LessonId);
|
|
||||||
|
|
||||||
if (dto.IsActive != null)
|
|
||||||
{
|
|
||||||
if (dto.IsActive.Value)
|
|
||||||
segment.Activate();
|
|
||||||
else
|
|
||||||
segment.Deactivate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save changes
|
|
||||||
await _storyRepository.UpdateAsync(segment, cancellationToken);
|
|
||||||
|
|
||||||
_logger.LogInformation("Updated story segment: {Id}", id);
|
|
||||||
|
|
||||||
return StorySegmentDto.FromEntity(segment);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a story segment by its ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if the segment was deleted, false if not found</returns>
|
|
||||||
public virtual async Task<bool> DeleteAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Deleting story segment: {Id}", id);
|
|
||||||
|
|
||||||
var exists = await _storyRepository.ExistsAsync(id, cancellationToken);
|
|
||||||
|
|
||||||
if (!exists)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Story segment not found for deletion: {Id}", id);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _storyRepository.DeleteAsync(id, cancellationToken);
|
|
||||||
|
|
||||||
_logger.LogInformation("Deleted story segment: {Id}", id);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the next segment to unlock for a user after completing a lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="completedLessonOrder">The order of the completed lesson</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The next story segment DTO, or null if none</returns>
|
|
||||||
public virtual async Task<StorySegmentDto?> GetNextSegmentToUnlockAsync(
|
|
||||||
int levelId,
|
|
||||||
int completedLessonOrder,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Getting next segment to unlock for level {LevelId} after lesson {LessonOrder}",
|
|
||||||
levelId, completedLessonOrder);
|
|
||||||
|
|
||||||
var segment = await _storyRepository.GetNextSegmentToUnlockAsync(
|
|
||||||
levelId, completedLessonOrder, cancellationToken);
|
|
||||||
|
|
||||||
if (segment == null)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("No segment to unlock for level {LevelId} after lesson {LessonOrder}",
|
|
||||||
levelId, completedLessonOrder);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return StorySegmentDto.FromEntity(segment);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a user has unlocked a specific story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="segmentId">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if the segment is unlocked</returns>
|
|
||||||
public virtual async Task<bool> IsSegmentUnlockedAsync(
|
|
||||||
int userId,
|
|
||||||
int segmentId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _progressRepository.IsSegmentUnlockedAsync(userId, segmentId, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a user has completed (read/listened to) a specific story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="segmentId">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if the segment is completed</returns>
|
|
||||||
public virtual async Task<bool> IsSegmentCompletedAsync(
|
|
||||||
int userId,
|
|
||||||
int segmentId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _progressRepository.IsSegmentCompletedAsync(userId, segmentId, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all segments that need audio generation.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of story segment DTOs needing audio</returns>
|
|
||||||
public virtual async Task<IReadOnlyList<StorySegmentDto>> GetSegmentsNeedingAudioAsync(
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Getting story segments needing audio generation");
|
|
||||||
|
|
||||||
var segments = await _storyRepository.GetSegmentsNeedingAudioAsync(cancellationToken);
|
|
||||||
|
|
||||||
return segments.Select(StorySegmentDto.FromEntity).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the audio URL for a story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The segment ID</param>
|
|
||||||
/// <param name="audioUrl">The audio URL</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The updated story segment DTO, or null if not found</returns>
|
|
||||||
public virtual async Task<StorySegmentDto?> UpdateAudioUrlAsync(
|
|
||||||
int id,
|
|
||||||
string audioUrl,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Updating audio URL for story segment: {Id}", id);
|
|
||||||
|
|
||||||
var segment = await _storyRepository.GetByIdAsync(id, cancellationToken);
|
|
||||||
|
|
||||||
if (segment == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Story segment not found for audio update: {Id}", id);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
segment.UpdateAudioUrl(audioUrl);
|
|
||||||
await _storyRepository.UpdateAsync(segment, cancellationToken);
|
|
||||||
|
|
||||||
_logger.LogInformation("Updated audio URL for story segment: {Id}", id);
|
|
||||||
|
|
||||||
return StorySegmentDto.FromEntity(segment);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a user's progress through a story.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Story progress DTO</returns>
|
|
||||||
public virtual async Task<StoryProgressDto> GetUserProgressAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Getting story progress for user {UserId} in level {LevelId}",
|
|
||||||
userId, levelId);
|
|
||||||
|
|
||||||
// Get all segments for the level
|
|
||||||
var segments = await _storyRepository.GetByLevelAsync(levelId, false, cancellationToken);
|
|
||||||
|
|
||||||
if (!segments.Any())
|
|
||||||
{
|
|
||||||
_logger.LogWarning("No story segments found for level: {LevelId}", levelId);
|
|
||||||
return new StoryProgressDto(
|
|
||||||
levelId,
|
|
||||||
"Unknown",
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
Array.Empty<StorySegmentProgressDto>());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user's progress for this level
|
|
||||||
var userProgress = await _progressRepository.GetByUserAndLevelAsync(userId, levelId, cancellationToken);
|
|
||||||
|
|
||||||
var totalSegments = segments.Count;
|
|
||||||
var unlockedSegments = userProgress.Count;
|
|
||||||
var highestUnlocked = await _progressRepository.GetHighestUnlockedOrderAsync(
|
|
||||||
userId, levelId, cancellationToken);
|
|
||||||
|
|
||||||
// Build segment progress list
|
|
||||||
var segmentProgress = new List<StorySegmentProgressDto>();
|
|
||||||
foreach (var segment in segments)
|
|
||||||
{
|
|
||||||
var isUnlocked = await _progressRepository.IsSegmentUnlockedAsync(
|
|
||||||
userId, segment.Id, cancellationToken);
|
|
||||||
var isCompleted = await _progressRepository.IsSegmentCompletedAsync(
|
|
||||||
userId, segment.Id, cancellationToken);
|
|
||||||
|
|
||||||
segmentProgress.Add(new StorySegmentProgressDto(
|
|
||||||
segment.Id,
|
|
||||||
segment.Order,
|
|
||||||
segment.Title,
|
|
||||||
isUnlocked,
|
|
||||||
isCompleted));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get level name from first segment (or use code as fallback)
|
|
||||||
var levelName = segments.First().Level?.Name ?? segments.First().Level?.Code ?? "Unknown";
|
|
||||||
|
|
||||||
return new StoryProgressDto(
|
|
||||||
levelId,
|
|
||||||
levelName,
|
|
||||||
totalSegments,
|
|
||||||
unlockedSegments,
|
|
||||||
highestUnlocked,
|
|
||||||
segmentProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unlocks the next story segment for a user after completing a lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="completedLessonOrder">The order of the completed lesson</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The unlocked story segment DTO, or null if none to unlock</returns>
|
|
||||||
public virtual async Task<StorySegmentDto?> UnlockNextSegmentAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
int completedLessonOrder,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Unlocking next story segment for user {UserId} in level {LevelId} after lesson {LessonOrder}",
|
|
||||||
userId, levelId, completedLessonOrder);
|
|
||||||
|
|
||||||
// Get the next segment to unlock
|
|
||||||
var segment = await _storyRepository.GetNextSegmentToUnlockAsync(
|
|
||||||
levelId, completedLessonOrder, cancellationToken);
|
|
||||||
|
|
||||||
if (segment == null)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"No segment to unlock for user {UserId} in level {LevelId} after lesson {LessonOrder}",
|
|
||||||
userId, levelId, completedLessonOrder);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user already has this segment unlocked
|
|
||||||
var alreadyUnlocked = await _progressRepository.IsSegmentUnlockedAsync(
|
|
||||||
userId, segment.Id, cancellationToken);
|
|
||||||
|
|
||||||
if (alreadyUnlocked)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Segment {SegmentId} already unlocked for user {UserId}",
|
|
||||||
segment.Id, userId);
|
|
||||||
return StorySegmentDto.FromEntity(segment);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create progress record
|
|
||||||
var progress = StoryProgress.Create(userId, levelId, segment.Id);
|
|
||||||
await _progressRepository.AddAsync(progress, cancellationToken);
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Unlocked segment {SegmentId} for user {UserId} in level {LevelId}",
|
|
||||||
segment.Id, userId, levelId);
|
|
||||||
|
|
||||||
return StorySegmentDto.FromEntity(segment);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Marks a story segment as completed (read/listened to) for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="segmentId">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if the segment was marked as completed, false if not found or already completed</returns>
|
|
||||||
public virtual async Task<bool> MarkSegmentAsCompletedAsync(
|
|
||||||
int userId,
|
|
||||||
int segmentId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Marking segment {SegmentId} as completed for user {UserId}",
|
|
||||||
segmentId, userId);
|
|
||||||
|
|
||||||
var progress = await _progressRepository.GetByUserAndSegmentAsync(
|
|
||||||
userId, segmentId, cancellationToken);
|
|
||||||
|
|
||||||
if (progress == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(
|
|
||||||
"Progress record not found for user {UserId} and segment {SegmentId}",
|
|
||||||
userId, segmentId);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progress.IsCompleted)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Segment already completed for user {UserId}", userId);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.MarkAsCompleted();
|
|
||||||
await _progressRepository.UpdateAsync(progress, cancellationToken);
|
|
||||||
|
|
||||||
_logger.LogInformation("Marked segment {SegmentId} as completed for user {UserId}",
|
|
||||||
segmentId, userId);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Application service for managing story segment unlocking.
|
|
||||||
/// This service is called when a user completes a lesson to unlock the next story segment.
|
|
||||||
/// This is part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class StoryUnlockService
|
|
||||||
{
|
|
||||||
private readonly StoryService _storyService;
|
|
||||||
private readonly IUserProgressRepository _userProgressRepository;
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
private readonly ILogger<StoryUnlockService> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new StoryUnlockService.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storyService">Service for story segment operations</param>
|
|
||||||
/// <param name="userProgressRepository">Repository for user progress</param>
|
|
||||||
/// <param name="lessonRepository">Repository for lessons</param>
|
|
||||||
/// <param name="logger">Logger for service operations</param>
|
|
||||||
public StoryUnlockService(
|
|
||||||
StoryService storyService,
|
|
||||||
IUserProgressRepository userProgressRepository,
|
|
||||||
ILessonRepository lessonRepository,
|
|
||||||
ILogger<StoryUnlockService> logger)
|
|
||||||
{
|
|
||||||
_storyService = storyService;
|
|
||||||
_userProgressRepository = userProgressRepository;
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Called when a user completes a lesson.
|
|
||||||
/// Checks if the user should unlock a new story segment and does so.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="lessonId">The lesson ID that was completed</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if a new segment was unlocked, false otherwise</returns>
|
|
||||||
public virtual async Task<bool> HandleLessonCompletionAsync(
|
|
||||||
int userId,
|
|
||||||
int lessonId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Handling lesson completion for user {UserId}, lesson {LessonId}",
|
|
||||||
userId, lessonId);
|
|
||||||
|
|
||||||
// Get the lesson to find its level and order
|
|
||||||
var lesson = await _lessonRepository.GetByIdAsync(lessonId, cancellationToken);
|
|
||||||
|
|
||||||
if (lesson == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Lesson not found: {LessonId}", lessonId);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this lesson completion qualifies for unlocking a story segment
|
|
||||||
// We need to check if the user has actually completed this lesson
|
|
||||||
var isCompleted = await _userProgressRepository.HasUserCompletedLessonAsync(
|
|
||||||
userId, lessonId, cancellationToken);
|
|
||||||
|
|
||||||
if (!isCompleted)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Lesson {LessonId} not marked as completed for user {UserId}",
|
|
||||||
lessonId, userId);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the next story segment to unlock
|
|
||||||
var nextSegment = await _storyService.GetNextSegmentToUnlockAsync(
|
|
||||||
lesson.LevelId,
|
|
||||||
lesson.Order,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
if (nextSegment == null)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"No story segment to unlock for user {UserId} after lesson {LessonId}",
|
|
||||||
userId, lessonId);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user already has this segment unlocked
|
|
||||||
var alreadyUnlocked = await _storyService.IsSegmentUnlockedAsync(
|
|
||||||
userId, nextSegment.Id, cancellationToken);
|
|
||||||
|
|
||||||
if (alreadyUnlocked)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Segment {SegmentId} already unlocked for user {UserId}",
|
|
||||||
nextSegment.Id, userId);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlock the segment
|
|
||||||
var unlockedSegment = await _storyService.UnlockNextSegmentAsync(
|
|
||||||
userId,
|
|
||||||
lesson.LevelId,
|
|
||||||
lesson.Order,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
if (unlockedSegment != null)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Unlocked story segment {SegmentId} for user {UserId} after completing lesson {LessonId}",
|
|
||||||
unlockedSegment.Id, userId, lessonId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a user has unlocked a specific story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="segmentId">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if the segment is unlocked</returns>
|
|
||||||
public virtual async Task<bool> IsSegmentUnlockedAsync(
|
|
||||||
int userId,
|
|
||||||
int segmentId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _storyService.IsSegmentUnlockedAsync(userId, segmentId, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a user has completed (read/listened to) a specific story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="segmentId">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if the segment is completed</returns>
|
|
||||||
public virtual async Task<bool> IsSegmentCompletedAsync(
|
|
||||||
int userId,
|
|
||||||
int segmentId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _storyService.IsSegmentCompletedAsync(userId, segmentId, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Marks a story segment as completed for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="segmentId">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if the segment was marked as completed</returns>
|
|
||||||
public virtual async Task<bool> MarkSegmentAsCompletedAsync(
|
|
||||||
int userId,
|
|
||||||
int segmentId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _storyService.MarkSegmentAsCompletedAsync(userId, segmentId, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a user's story progress for a level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Story progress DTO</returns>
|
|
||||||
public virtual async Task<StoryProgressDto> GetUserProgressAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _storyService.GetUserProgressAsync(userId, levelId, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Application.Interfaces;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Service for generating user progress reports.
|
|
||||||
/// Part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class UserReportService : IUserReportService
|
|
||||||
{
|
|
||||||
private readonly IRepository<User, int> _userRepository;
|
|
||||||
private readonly IUserProgressRepository _userProgressRepository;
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
private readonly ILevelRepository _levelRepository;
|
|
||||||
private readonly IQuizRepository _quizRepository;
|
|
||||||
private readonly IStoryProgressRepository _storyProgressRepository;
|
|
||||||
|
|
||||||
public UserReportService(
|
|
||||||
IRepository<User, int> userRepository,
|
|
||||||
IUserProgressRepository userProgressRepository,
|
|
||||||
ILessonRepository lessonRepository,
|
|
||||||
ILevelRepository levelRepository,
|
|
||||||
IQuizRepository quizRepository,
|
|
||||||
IStoryProgressRepository storyProgressRepository)
|
|
||||||
{
|
|
||||||
_userRepository = userRepository;
|
|
||||||
_userProgressRepository = userProgressRepository;
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
_levelRepository = levelRepository;
|
|
||||||
_quizRepository = quizRepository;
|
|
||||||
_storyProgressRepository = storyProgressRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a progress report for a specific user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>User progress report</returns>
|
|
||||||
public async Task<UserProgressReportDto> GenerateUserReportAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
|
|
||||||
if (user == null)
|
|
||||||
throw new ArgumentException("User not found", nameof(userId));
|
|
||||||
|
|
||||||
var userProgress = await _userProgressRepository.GetByUserAsync(userId, cancellationToken);
|
|
||||||
var allLessons = await _lessonRepository.GetAllAsync(cancellationToken);
|
|
||||||
|
|
||||||
var totalLessonsCompleted = userProgress.Count(up => up.IsCompleted);
|
|
||||||
var totalQuizzesCompleted = userProgress.Count(up => up.IsCompleted && up.QuizScore > 0);
|
|
||||||
var averageQuizScore = userProgress.Where(up => up.QuizScore > 0).Select(up => up.QuizScore).DefaultIfEmpty().Average();
|
|
||||||
|
|
||||||
// Find last activity date
|
|
||||||
var lastActivityDate = userProgress
|
|
||||||
.OrderByDescending(up => up.LastAttemptDate)
|
|
||||||
.FirstOrDefault()?.LastAttemptDate ?? user.CreatedAt;
|
|
||||||
|
|
||||||
return new UserProgressReportDto(
|
|
||||||
user.Id,
|
|
||||||
user.Username,
|
|
||||||
user.Email,
|
|
||||||
user.CurrentLevel,
|
|
||||||
totalLessonsCompleted,
|
|
||||||
totalQuizzesCompleted,
|
|
||||||
averageQuizScore,
|
|
||||||
user.TotalPoints,
|
|
||||||
user.Streak,
|
|
||||||
lastActivityDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates progress reports for all users.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of all user progress reports</returns>
|
|
||||||
public async Task<IReadOnlyList<UserProgressReportDto>> GenerateAllUserReportsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var users = await _userRepository.GetAllAsync(cancellationToken);
|
|
||||||
var reports = new List<UserProgressReportDto>();
|
|
||||||
|
|
||||||
foreach (var user in users)
|
|
||||||
{
|
|
||||||
var report = await GenerateUserReportAsync(user.Id, cancellationToken);
|
|
||||||
reports.Add(report);
|
|
||||||
}
|
|
||||||
|
|
||||||
return reports;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets lesson completion data for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of lesson completion data</returns>
|
|
||||||
public async Task<IReadOnlyList<LessonCompletionDto>> GetUserLessonProgressAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var userProgress = await _userProgressRepository.GetByUserAsync(userId, cancellationToken);
|
|
||||||
var allLessons = await _lessonRepository.GetAllAsync(cancellationToken);
|
|
||||||
var allLevels = await _levelRepository.GetAllAsync(cancellationToken);
|
|
||||||
|
|
||||||
var levelDict = allLevels.ToDictionary(l => l.Id, l => l.Code);
|
|
||||||
|
|
||||||
return userProgress
|
|
||||||
.Select(up => new LessonCompletionDto(
|
|
||||||
up.LessonId,
|
|
||||||
up.Lesson?.Title ?? "Unknown",
|
|
||||||
up.Lesson?.LevelId != null && levelDict.TryGetValue(up.Lesson.LevelId, out var levelCode) ? levelCode : "Unknown",
|
|
||||||
up.IsCompleted,
|
|
||||||
up.IsCompleted ? up.LastAttemptDate : null))
|
|
||||||
.OrderBy(l => l.LevelCode)
|
|
||||||
.ThenBy(l => l.LessonTitle)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz results for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of quiz results</returns>
|
|
||||||
public async Task<IReadOnlyList<UserQuizResultDto>> GetUserQuizResultsAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var userProgress = await _userProgressRepository.GetByUserAsync(userId, cancellationToken);
|
|
||||||
|
|
||||||
return userProgress
|
|
||||||
.Where(up => up.QuizScore > 0)
|
|
||||||
.Select(up => new UserQuizResultDto(
|
|
||||||
up.Lesson?.Id ?? 0, // Using LessonId as QuizId proxy (simplified)
|
|
||||||
up.Lesson?.Title ?? "Unknown Quiz",
|
|
||||||
up.QuizScore,
|
|
||||||
80, // Default passing score
|
|
||||||
up.QuizScore >= 80,
|
|
||||||
up.LastAttemptDate))
|
|
||||||
.OrderByDescending(r => r.AttemptDate)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exports user reports as CSV.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>CSV content as string</returns>
|
|
||||||
public async Task<string> ExportReportsAsCsvAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var reports = await GenerateAllUserReportsAsync(cancellationToken);
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
|
|
||||||
// Header row
|
|
||||||
sb.AppendLine("User ID,Username,Email,Current Level,Lessons Completed,Quizzes Completed,Average Score,Total Points,Streak,Last Activity");
|
|
||||||
|
|
||||||
// Data rows
|
|
||||||
foreach (var report in reports)
|
|
||||||
{
|
|
||||||
sb.AppendLine($"{EscapeCsv(report.UserId.ToString())},{EscapeCsv(report.Username)},{EscapeCsv(report.Email)},{EscapeCsv(report.CurrentLevel)},{report.TotalLessonsCompleted},{report.TotalQuizzesCompleted},{report.AverageQuizScore:F2},{report.TotalPoints},{report.CurrentStreak},{EscapeCsv(report.LastActivityDate.ToString("yyyy-MM-dd HH:mm:ss"))}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Escapes a value for CSV output.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="value">The value to escape</param>
|
|
||||||
/// <returns>Escaped value</returns>
|
|
||||||
private static string EscapeCsv(string value)
|
|
||||||
{
|
|
||||||
if (value == null)
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
// If the value contains commas, quotes, or newlines, wrap it in quotes and escape quotes
|
|
||||||
if (value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r"))
|
|
||||||
{
|
|
||||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,358 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Application service for providing feedback on user writing using Mistral AI.
|
|
||||||
/// This is part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public class WritingFeedbackService
|
|
||||||
{
|
|
||||||
private readonly IMistralService _mistralService;
|
|
||||||
private readonly ILogger<WritingFeedbackService> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new WritingFeedbackService.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="mistralService">The Mistral text generation service</param>
|
|
||||||
/// <param name="logger">Logger for service operations</param>
|
|
||||||
public WritingFeedbackService(
|
|
||||||
IMistralService mistralService,
|
|
||||||
ILogger<WritingFeedbackService> logger)
|
|
||||||
{
|
|
||||||
_mistralService = mistralService;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Provides feedback on user's German writing.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userText">The user's German text to evaluate</param>
|
|
||||||
/// <param name="level">The CEFR level of the user</param>
|
|
||||||
/// <param name="customPrompt">Optional custom prompt for specific feedback requests</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Feedback text in English</returns>
|
|
||||||
public virtual async Task<string> ProvideFeedbackAsync(
|
|
||||||
string userText,
|
|
||||||
string level,
|
|
||||||
string? customPrompt = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Providing writing feedback: Level={Level}, TextLength={Length}",
|
|
||||||
level, userText?.Length ?? 0);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var feedback = await _mistralService.GenerateWritingFeedbackAsync(
|
|
||||||
userText,
|
|
||||||
level,
|
|
||||||
customPrompt,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// Validate the feedback
|
|
||||||
ValidateFeedback(feedback);
|
|
||||||
|
|
||||||
_logger.LogInformation("Writing feedback generated successfully");
|
|
||||||
return feedback;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to generate writing feedback");
|
|
||||||
throw new AiServiceException(
|
|
||||||
"Failed to generate writing feedback: " + ex.Message,
|
|
||||||
AiErrorCode.Temporary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Provides structured feedback with specific categories.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userText">The user's German text to evaluate</param>
|
|
||||||
/// <param name="level">The CEFR level of the user</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Structured feedback with grammar, suggestions, and encouragement</returns>
|
|
||||||
public virtual async Task<WritingFeedback> ProvideStructuredFeedbackAsync(
|
|
||||||
string userText,
|
|
||||||
string level,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Providing structured writing feedback: Level={Level}, TextLength={Length}",
|
|
||||||
level, userText?.Length ?? 0);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Generate feedback using the service
|
|
||||||
var feedbackText = await ProvideFeedbackAsync(userText, level, null, cancellationToken);
|
|
||||||
|
|
||||||
// Parse the feedback into a structured format
|
|
||||||
return ParseFeedback(feedbackText, userText);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to generate structured writing feedback");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks grammar in user's text.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userText">The user's German text</param>
|
|
||||||
/// <param name="level">The CEFR level</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of grammar corrections</returns>
|
|
||||||
public virtual async Task<IReadOnlyList<GrammarCorrection>> CheckGrammarAsync(
|
|
||||||
string userText,
|
|
||||||
string level,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Checking grammar: Level={Level}, TextLength={Length}",
|
|
||||||
level, userText?.Length ?? 0);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var feedback = await ProvideFeedbackAsync(userText, level, null, cancellationToken);
|
|
||||||
return ExtractGrammarCorrections(feedback);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to check grammar");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Suggests improvements for user's writing.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userText">The user's German text</param>
|
|
||||||
/// <param name="level">The CEFR level</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of improvement suggestions</returns>
|
|
||||||
public virtual async Task<IReadOnlyList<string>> SuggestImprovementsAsync(
|
|
||||||
string userText,
|
|
||||||
string level,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Suggesting improvements: Level={Level}, TextLength={Length}",
|
|
||||||
level, userText?.Length ?? 0);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var feedback = await ProvideFeedbackAsync(userText, level, null, cancellationToken);
|
|
||||||
return ExtractImprovementSuggestions(feedback);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to suggest improvements");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates the generated feedback.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="feedback">The feedback text</param>
|
|
||||||
/// <exception cref="InvalidOperationException">Thrown when feedback is invalid</exception>
|
|
||||||
private void ValidateFeedback(string feedback)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(feedback))
|
|
||||||
{
|
|
||||||
_logger.LogError("Generated feedback is empty");
|
|
||||||
throw new InvalidOperationException("Generated feedback is empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check minimum feedback length
|
|
||||||
const int minFeedbackLength = 50;
|
|
||||||
if (feedback.Length < minFeedbackLength)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(
|
|
||||||
"Generated feedback is too short: {Length} characters (min: {Min})",
|
|
||||||
feedback.Length, minFeedbackLength);
|
|
||||||
// Don't throw - just log warning
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses feedback text into a structured format.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="feedbackText">The raw feedback text</param>
|
|
||||||
/// <param name="originalText">The original user text</param>
|
|
||||||
/// <returns>Structured feedback object</returns>
|
|
||||||
private WritingFeedback ParseFeedback(string feedbackText, string originalText)
|
|
||||||
{
|
|
||||||
// This is a simple parser that extracts information from the feedback
|
|
||||||
// In a real implementation, you might use more sophisticated parsing
|
|
||||||
// or ask the AI to return structured data (JSON)
|
|
||||||
|
|
||||||
var feedback = new WritingFeedback
|
|
||||||
{
|
|
||||||
OriginalText = originalText,
|
|
||||||
FeedbackText = feedbackText
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try to extract grammar corrections
|
|
||||||
feedback.GrammarCorrections = ExtractGrammarCorrections(feedbackText);
|
|
||||||
|
|
||||||
// Try to extract improvement suggestions
|
|
||||||
feedback.ImprovementSuggestions = ExtractImprovementSuggestions(feedbackText);
|
|
||||||
|
|
||||||
// Try to extract encouragement
|
|
||||||
feedback.Encouragement = ExtractEncouragement(feedbackText);
|
|
||||||
|
|
||||||
return feedback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extracts grammar corrections from feedback text.
|
|
||||||
/// </summary>
|
|
||||||
private IReadOnlyList<GrammarCorrection> ExtractGrammarCorrections(string feedbackText)
|
|
||||||
{
|
|
||||||
var corrections = new List<GrammarCorrection>();
|
|
||||||
var lines = feedbackText.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
|
|
||||||
foreach (var line in lines)
|
|
||||||
{
|
|
||||||
// Look for patterns like "Incorrect: X -> Correct: Y"
|
|
||||||
if (line.Contains("->") || line.Contains("→") || line.Contains("Incorrect") || line.Contains("Correction"))
|
|
||||||
{
|
|
||||||
// This is a simplified extraction - in production, use proper parsing
|
|
||||||
corrections.Add(new GrammarCorrection
|
|
||||||
{
|
|
||||||
Issue = "Grammar issue found",
|
|
||||||
Original = "[text]",
|
|
||||||
Corrected = "[corrected]",
|
|
||||||
Explanation = line
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return corrections;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extracts improvement suggestions from feedback text.
|
|
||||||
/// </summary>
|
|
||||||
private IReadOnlyList<string> ExtractImprovementSuggestions(string feedbackText)
|
|
||||||
{
|
|
||||||
var suggestions = new List<string>();
|
|
||||||
var lines = feedbackText.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
|
|
||||||
foreach (var line in lines)
|
|
||||||
{
|
|
||||||
if (line.Contains("suggest") || line.Contains("Suggestion") ||
|
|
||||||
line.Contains("improve") || line.Contains("better"))
|
|
||||||
{
|
|
||||||
suggestions.Add(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return suggestions.Count > 0 ? suggestions : new List<string> { "No specific suggestions extracted" };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extracts encouragement from feedback text.
|
|
||||||
/// </summary>
|
|
||||||
private string ExtractEncouragement(string feedbackText)
|
|
||||||
{
|
|
||||||
var lines = feedbackText.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
|
|
||||||
foreach (var line in lines)
|
|
||||||
{
|
|
||||||
if (line.Contains("good") || line.Contains("great") ||
|
|
||||||
line.Contains("excellent") || line.Contains("keep") ||
|
|
||||||
line.Contains("Encouragement"))
|
|
||||||
{
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Keep practicing!";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the writing feedback service.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if service is working, false otherwise</returns>
|
|
||||||
public virtual async Task<bool> TestServiceAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Test with simple text
|
|
||||||
var feedback = await ProvideFeedbackAsync(
|
|
||||||
"Ich heisse Anna.",
|
|
||||||
"A1",
|
|
||||||
null,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return !string.IsNullOrWhiteSpace(feedback);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Writing feedback service test failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Structured feedback for writing.
|
|
||||||
/// </summary>
|
|
||||||
public class WritingFeedback
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The original user text.
|
|
||||||
/// </summary>
|
|
||||||
public string OriginalText { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The full feedback text.
|
|
||||||
/// </summary>
|
|
||||||
public string FeedbackText { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// List of grammar corrections.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<GrammarCorrection> GrammarCorrections { get; set; } = new List<GrammarCorrection>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// List of improvement suggestions.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<string> ImprovementSuggestions { get; set; } = new List<string>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Encouragement message.
|
|
||||||
/// </summary>
|
|
||||||
public string Encouragement { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a single grammar correction.
|
|
||||||
/// </summary>
|
|
||||||
public class GrammarCorrection
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The type of grammar issue.
|
|
||||||
/// </summary>
|
|
||||||
public string Issue { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The original text with the issue.
|
|
||||||
/// </summary>
|
|
||||||
public string Original { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The corrected text.
|
|
||||||
/// </summary>
|
|
||||||
public string Corrected { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Explanation of the correction.
|
|
||||||
/// </summary>
|
|
||||||
public string Explanation { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Exceptions;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.UseCases.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command for creating a new quiz.
|
|
||||||
/// This follows the CQRS pattern - commands represent write operations.
|
|
||||||
/// </summary>
|
|
||||||
public record CreateQuizCommand(CreateQuizDto QuizData) : ICommand<QuizDto>;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handler for the CreateQuizCommand.
|
|
||||||
/// </summary>
|
|
||||||
public class CreateQuizCommandHandler : ICommandHandler<CreateQuizCommand, QuizDto>
|
|
||||||
{
|
|
||||||
private readonly IQuizRepository _quizRepository;
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
|
|
||||||
public CreateQuizCommandHandler(
|
|
||||||
IQuizRepository quizRepository,
|
|
||||||
ILessonRepository lessonRepository)
|
|
||||||
{
|
|
||||||
_quizRepository = quizRepository;
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<QuizDto> Handle(CreateQuizCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// Validate that the lesson exists
|
|
||||||
var lesson = await _lessonRepository.GetByIdAsync(command.QuizData.LessonId, cancellationToken);
|
|
||||||
if (lesson == null)
|
|
||||||
throw new NotFoundException("Lesson does not exist");
|
|
||||||
|
|
||||||
// Validate business rules
|
|
||||||
if (command.QuizData.PassingScore < 0 || command.QuizData.PassingScore > 100)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Passing score must be between 0 and 100");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.QuizData.TimeLimitMinutes < 0)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Time limit cannot be negative");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert DTO to domain entity
|
|
||||||
var quiz = command.QuizData.ToEntity();
|
|
||||||
|
|
||||||
// Add to repository
|
|
||||||
var createdQuiz = await _quizRepository.AddAsync(quiz, cancellationToken);
|
|
||||||
|
|
||||||
// Return DTO representation
|
|
||||||
return createdQuiz.ToDto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command for updating an existing quiz.
|
|
||||||
/// </summary>
|
|
||||||
public record UpdateQuizCommand(int Id, UpdateQuizDto QuizData) : ICommand<QuizDto?>;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handler for the UpdateQuizCommand.
|
|
||||||
/// </summary>
|
|
||||||
public class UpdateQuizCommandHandler : ICommandHandler<UpdateQuizCommand, QuizDto?>
|
|
||||||
{
|
|
||||||
private readonly IQuizRepository _quizRepository;
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
|
|
||||||
public UpdateQuizCommandHandler(
|
|
||||||
IQuizRepository quizRepository,
|
|
||||||
ILessonRepository lessonRepository)
|
|
||||||
{
|
|
||||||
_quizRepository = quizRepository;
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<QuizDto?> Handle(UpdateQuizCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(command.Id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// Validate that the lesson exists
|
|
||||||
var lesson = await _lessonRepository.GetByIdAsync(command.QuizData.LessonId, cancellationToken);
|
|
||||||
if (lesson == null)
|
|
||||||
throw new NotFoundException("Lesson does not exist");
|
|
||||||
|
|
||||||
// Validate business rules
|
|
||||||
if (command.QuizData.PassingScore < 0 || command.QuizData.PassingScore > 100)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Passing score must be between 0 and 100");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.QuizData.TimeLimitMinutes < 0)
|
|
||||||
{
|
|
||||||
throw new ValidationException("Time limit cannot be negative");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the quiz
|
|
||||||
quiz.UpdateFromDto(command.QuizData);
|
|
||||||
await _quizRepository.UpdateAsync(quiz, cancellationToken);
|
|
||||||
|
|
||||||
// Reload and return
|
|
||||||
var updatedQuiz = await _quizRepository.GetByIdAsync(command.Id, cancellationToken);
|
|
||||||
return updatedQuiz?.ToDto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command for deleting a quiz.
|
|
||||||
/// </summary>
|
|
||||||
public record DeleteQuizCommand(int Id) : ICommand<bool>;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handler for the DeleteQuizCommand.
|
|
||||||
/// </summary>
|
|
||||||
public class DeleteQuizCommandHandler : ICommandHandler<DeleteQuizCommand, bool>
|
|
||||||
{
|
|
||||||
private readonly IQuizRepository _quizRepository;
|
|
||||||
|
|
||||||
public DeleteQuizCommandHandler(IQuizRepository quizRepository)
|
|
||||||
{
|
|
||||||
_quizRepository = quizRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> Handle(DeleteQuizCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetByIdAsync(command.Id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
await _quizRepository.DeleteAsync(quiz, cancellationToken);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command for getting a quiz with its questions.
|
|
||||||
/// </summary>
|
|
||||||
public record GetQuizWithQuestionsCommand(int Id) : ICommand<QuizWithQuestionsDto?>;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handler for the GetQuizWithQuestionsCommand.
|
|
||||||
/// </summary>
|
|
||||||
public class GetQuizWithQuestionsCommandHandler : ICommandHandler<GetQuizWithQuestionsCommand, QuizWithQuestionsDto?>
|
|
||||||
{
|
|
||||||
private readonly IQuizRepository _quizRepository;
|
|
||||||
|
|
||||||
public GetQuizWithQuestionsCommandHandler(IQuizRepository quizRepository)
|
|
||||||
{
|
|
||||||
_quizRepository = quizRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<QuizWithQuestionsDto?> Handle(GetQuizWithQuestionsCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var quiz = await _quizRepository.GetWithQuestionsAsync(command.Id, cancellationToken);
|
|
||||||
return quiz?.ToQuizWithQuestionsDto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -107,7 +107,4 @@ public class Lesson
|
||||||
IsActive = false;
|
IsActive = false;
|
||||||
UpdatedAt = DateTime.UtcNow;
|
UpdatedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation properties
|
|
||||||
public virtual ICollection<StorySegment> StorySegments { get; private set; } = new List<StorySegment>();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,4 @@ public class Level
|
||||||
{
|
{
|
||||||
Order = newOrder;
|
Order = newOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation properties
|
|
||||||
public virtual ICollection<Lesson> Lessons { get; private set; } = new List<Lesson>();
|
|
||||||
public virtual ICollection<StorySegment> StorySegments { get; private set; } = new List<StorySegment>();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
namespace GermanApp.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a quiz that can be associated with a lesson.
|
|
||||||
/// A quiz contains multiple questions and has a passing score threshold.
|
|
||||||
/// </summary>
|
|
||||||
public class Quiz
|
|
||||||
{
|
|
||||||
public int Id { get; private set; }
|
|
||||||
public int LessonId { get; private set; }
|
|
||||||
public string Title { get; private set; } = string.Empty;
|
|
||||||
public string? Description { get; private set; }
|
|
||||||
public int PassingScore { get; private set; } = 80; // Default 80%
|
|
||||||
public int TimeLimitMinutes { get; private set; } = 0; // 0 = no limit
|
|
||||||
public bool IsActive { get; private set; } = true;
|
|
||||||
public bool ShuffleQuestions { get; private set; } = true;
|
|
||||||
public DateTime CreatedAt { get; private set; }
|
|
||||||
public DateTime? UpdatedAt { get; private set; }
|
|
||||||
|
|
||||||
// Navigation property
|
|
||||||
public virtual Lesson? Lesson { get; private set; }
|
|
||||||
public virtual ICollection<QuizQuestion> Questions { get; private set; } = new List<QuizQuestion>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Constructor for EF Core deserialization.
|
|
||||||
/// </summary>
|
|
||||||
private Quiz() { }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Factory method to create a new quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">ID of the associated lesson</param>
|
|
||||||
/// <param name="title">Title of the quiz</param>
|
|
||||||
/// <param name="description">Optional description</param>
|
|
||||||
/// <param name="passingScore">Passing score percentage (default 80)</param>
|
|
||||||
/// <param name="timeLimitMinutes">Time limit in minutes (0 for no limit)</param>
|
|
||||||
public static Quiz Create(
|
|
||||||
int lessonId,
|
|
||||||
string title,
|
|
||||||
string? description = null,
|
|
||||||
int passingScore = 80,
|
|
||||||
int timeLimitMinutes = 0)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(title))
|
|
||||||
throw new ArgumentException("Quiz title cannot be empty", nameof(title));
|
|
||||||
|
|
||||||
if (passingScore < 0 || passingScore > 100)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(passingScore), "Passing score must be between 0 and 100");
|
|
||||||
|
|
||||||
if (timeLimitMinutes < 0)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(timeLimitMinutes), "Time limit cannot be negative");
|
|
||||||
|
|
||||||
return new Quiz
|
|
||||||
{
|
|
||||||
LessonId = lessonId,
|
|
||||||
Title = title,
|
|
||||||
Description = description,
|
|
||||||
PassingScore = passingScore,
|
|
||||||
TimeLimitMinutes = timeLimitMinutes,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the quiz title.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateTitle(string newTitle)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(newTitle))
|
|
||||||
throw new ArgumentException("Quiz title cannot be empty", nameof(newTitle));
|
|
||||||
|
|
||||||
Title = newTitle;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the quiz description.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateDescription(string? newDescription)
|
|
||||||
{
|
|
||||||
Description = newDescription;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the passing score.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdatePassingScore(int newPassingScore)
|
|
||||||
{
|
|
||||||
if (newPassingScore < 0 || newPassingScore > 100)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(newPassingScore), "Passing score must be between 0 and 100");
|
|
||||||
|
|
||||||
PassingScore = newPassingScore;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the time limit.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateTimeLimit(int newTimeLimitMinutes)
|
|
||||||
{
|
|
||||||
if (newTimeLimitMinutes < 0)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(newTimeLimitMinutes), "Time limit cannot be negative");
|
|
||||||
|
|
||||||
TimeLimitMinutes = newTimeLimitMinutes;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the associated lesson.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateLesson(int newLessonId)
|
|
||||||
{
|
|
||||||
LessonId = newLessonId;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the shuffle questions setting.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateShuffleQuestions(bool newShuffleQuestions)
|
|
||||||
{
|
|
||||||
ShuffleQuestions = newShuffleQuestions;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Activates the quiz.
|
|
||||||
/// </summary>
|
|
||||||
public void Activate()
|
|
||||||
{
|
|
||||||
IsActive = true;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deactivates the quiz.
|
|
||||||
/// </summary>
|
|
||||||
public void Deactivate()
|
|
||||||
{
|
|
||||||
IsActive = false;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds a question to this quiz.
|
|
||||||
/// </summary>
|
|
||||||
public void AddQuestion(QuizQuestion question)
|
|
||||||
{
|
|
||||||
if (question == null)
|
|
||||||
throw new ArgumentNullException(nameof(question));
|
|
||||||
|
|
||||||
Questions.Add(question);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes a question from this quiz.
|
|
||||||
/// </summary>
|
|
||||||
public void RemoveQuestion(QuizQuestion question)
|
|
||||||
{
|
|
||||||
if (question == null)
|
|
||||||
throw new ArgumentNullException(nameof(question));
|
|
||||||
|
|
||||||
Questions.Remove(question);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if the quiz is passed based on a score percentage.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsPassed(double scorePercentage) => scorePercentage >= PassingScore;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates the score percentage based on correct answers.
|
|
||||||
/// </summary>
|
|
||||||
public double CalculateScore(int correctAnswers, int totalQuestions)
|
|
||||||
{
|
|
||||||
if (totalQuestions == 0)
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
return (double)correctAnswers / totalQuestions * 100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,285 +0,0 @@
|
||||||
namespace GermanApp.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a quiz question for a quiz.
|
|
||||||
/// Each question has a type (multiple choice, true/false, fill-in-the-blank, etc.)
|
|
||||||
/// and is associated with a specific quiz.
|
|
||||||
/// </summary>
|
|
||||||
public class QuizQuestion
|
|
||||||
{
|
|
||||||
public int Id { get; private set; }
|
|
||||||
public int QuizId { get; private set; }
|
|
||||||
public string QuestionText { get; private set; } = string.Empty;
|
|
||||||
public QuestionType Type { get; private set; }
|
|
||||||
public string? CorrectAnswer { get; private set; }
|
|
||||||
public int Points { get; private set; } = 1; // Points for this question
|
|
||||||
public int Difficulty { get; private set; } // 1-5 scale
|
|
||||||
public int Order { get; private set; }
|
|
||||||
public bool IsActive { get; private set; } = true;
|
|
||||||
public DateTime CreatedAt { get; private set; }
|
|
||||||
public DateTime? UpdatedAt { get; private set; }
|
|
||||||
|
|
||||||
// Navigation property
|
|
||||||
public virtual Quiz? Quiz { get; private set; }
|
|
||||||
public virtual ICollection<QuizOption> Options { get; private set; } = new List<QuizOption>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Constructor for EF Core deserialization.
|
|
||||||
/// </summary>
|
|
||||||
private QuizQuestion() { }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Factory method to create a new quiz question.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quizId">ID of the parent quiz</param>
|
|
||||||
/// <param name="questionText">The question text</param>
|
|
||||||
/// <param name="type">The question type</param>
|
|
||||||
/// <param name="correctAnswer">The correct answer</param>
|
|
||||||
/// <param name="points">Points for this question (default 1)</param>
|
|
||||||
/// <param name="difficulty">Difficulty level (1-5, default 3)</param>
|
|
||||||
/// <param name="order">Sort order within the quiz</param>
|
|
||||||
public static QuizQuestion Create(
|
|
||||||
int quizId,
|
|
||||||
string questionText,
|
|
||||||
QuestionType type,
|
|
||||||
string correctAnswer,
|
|
||||||
int points = 1,
|
|
||||||
int difficulty = 3,
|
|
||||||
int order = 1)
|
|
||||||
{
|
|
||||||
if (difficulty < 1 || difficulty > 5)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(difficulty), "Difficulty must be between 1 and 5");
|
|
||||||
|
|
||||||
if (points < 0)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(points), "Points must be positive");
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(questionText))
|
|
||||||
throw new ArgumentException("Question text cannot be empty", nameof(questionText));
|
|
||||||
|
|
||||||
return new QuizQuestion
|
|
||||||
{
|
|
||||||
QuizId = quizId,
|
|
||||||
QuestionText = questionText,
|
|
||||||
Type = type,
|
|
||||||
CorrectAnswer = correctAnswer,
|
|
||||||
Points = points,
|
|
||||||
Difficulty = difficulty,
|
|
||||||
Order = order,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the question text.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateQuestionText(string newQuestionText)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(newQuestionText))
|
|
||||||
throw new ArgumentException("Question text cannot be empty", nameof(newQuestionText));
|
|
||||||
|
|
||||||
QuestionText = newQuestionText;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the correct answer.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateCorrectAnswer(string newCorrectAnswer)
|
|
||||||
{
|
|
||||||
CorrectAnswer = newCorrectAnswer;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the points for this question.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdatePoints(int newPoints)
|
|
||||||
{
|
|
||||||
if (newPoints < 0)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(newPoints), "Points must be positive");
|
|
||||||
|
|
||||||
Points = newPoints;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the difficulty level.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateDifficulty(int newDifficulty)
|
|
||||||
{
|
|
||||||
if (newDifficulty < 1 || newDifficulty > 5)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(newDifficulty), "Difficulty must be between 1 and 5");
|
|
||||||
|
|
||||||
Difficulty = newDifficulty;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the sort order.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateOrder(int newOrder)
|
|
||||||
{
|
|
||||||
Order = newOrder;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the question type.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateType(QuestionType newType)
|
|
||||||
{
|
|
||||||
Type = newType;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the associated quiz.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateQuiz(int newQuizId)
|
|
||||||
{
|
|
||||||
QuizId = newQuizId;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Activates the quiz question.
|
|
||||||
/// </summary>
|
|
||||||
public void Activate()
|
|
||||||
{
|
|
||||||
IsActive = true;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deactivates the quiz question.
|
|
||||||
/// </summary>
|
|
||||||
public void Deactivate()
|
|
||||||
{
|
|
||||||
IsActive = false;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds an option to this question.
|
|
||||||
/// </summary>
|
|
||||||
public void AddOption(QuizOption option)
|
|
||||||
{
|
|
||||||
Options.Add(option);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes an option from this question.
|
|
||||||
/// </summary>
|
|
||||||
public void RemoveOption(QuizOption option)
|
|
||||||
{
|
|
||||||
Options.Remove(option);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents an option for a multiple-choice quiz question.
|
|
||||||
/// </summary>
|
|
||||||
public class QuizOption
|
|
||||||
{
|
|
||||||
public int Id { get; private set; }
|
|
||||||
public int QuizQuestionId { get; private set; }
|
|
||||||
public string Text { get; private set; } = string.Empty;
|
|
||||||
public bool IsCorrect { get; private set; }
|
|
||||||
public int Order { get; private set; }
|
|
||||||
|
|
||||||
// Navigation property
|
|
||||||
public virtual QuizQuestion? QuizQuestion { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Constructor for EF Core deserialization.
|
|
||||||
/// </summary>
|
|
||||||
private QuizOption() { }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Factory method to create a new quiz option.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quizQuestionId">ID of the parent quiz question</param>
|
|
||||||
/// <param name="text">The option text</param>
|
|
||||||
/// <param name="isCorrect">Whether this is the correct answer</param>
|
|
||||||
/// <param name="order">Sort order within the question's options</param>
|
|
||||||
public static QuizOption Create(
|
|
||||||
int quizQuestionId,
|
|
||||||
string text,
|
|
||||||
bool isCorrect = false,
|
|
||||||
int order = 1)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
|
||||||
throw new ArgumentException("Option text cannot be empty", nameof(text));
|
|
||||||
|
|
||||||
return new QuizOption
|
|
||||||
{
|
|
||||||
QuizQuestionId = quizQuestionId,
|
|
||||||
Text = text,
|
|
||||||
IsCorrect = isCorrect,
|
|
||||||
Order = order
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the option text.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateText(string newText)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(newText))
|
|
||||||
throw new ArgumentException("Option text cannot be empty", nameof(newText));
|
|
||||||
|
|
||||||
Text = newText;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates whether this option is correct.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateIsCorrect(bool newIsCorrect)
|
|
||||||
{
|
|
||||||
IsCorrect = newIsCorrect;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the sort order.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateOrder(int newOrder)
|
|
||||||
{
|
|
||||||
Order = newOrder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Enum representing the types of quiz questions.
|
|
||||||
/// </summary>
|
|
||||||
public enum QuestionType
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Multiple choice with single correct answer
|
|
||||||
/// </summary>
|
|
||||||
MultipleChoice = 0,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Multiple choice with multiple correct answers
|
|
||||||
/// </summary>
|
|
||||||
MultipleSelect = 1,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True or false question
|
|
||||||
/// </summary>
|
|
||||||
TrueFalse = 2,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fill-in-the-blank question
|
|
||||||
/// </summary>
|
|
||||||
FillInTheBlank = 3,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Matching question
|
|
||||||
/// </summary>
|
|
||||||
Matching = 4,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Short answer question
|
|
||||||
/// </summary>
|
|
||||||
ShortAnswer = 5
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
namespace GermanApp.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tracks a user's progress through story segments.
|
|
||||||
/// A user unlocks story segments by completing lessons.
|
|
||||||
/// </summary>
|
|
||||||
public class StoryProgress
|
|
||||||
{
|
|
||||||
public int Id { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user ID who owns this progress.
|
|
||||||
/// </summary>
|
|
||||||
public int UserId { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The level ID this progress is for.
|
|
||||||
/// </summary>
|
|
||||||
public int LevelId { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The story segment ID that was unlocked.
|
|
||||||
/// </summary>
|
|
||||||
public int StorySegmentId { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the user has read/listened to this segment.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsCompleted { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// When the segment was unlocked.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime UnlockedAt { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// When the segment was completed (read/listened to).
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? CompletedAt { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Timestamp when the progress record was created.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Timestamp when the progress record was last updated.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? UpdatedAt { get; private set; }
|
|
||||||
|
|
||||||
// Navigation properties
|
|
||||||
public virtual User? User { get; private set; }
|
|
||||||
public virtual Level? Level { get; private set; }
|
|
||||||
public virtual StorySegment? StorySegment { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Constructor for EF Core deserialization.
|
|
||||||
/// </summary>
|
|
||||||
private StoryProgress() { }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Factory method to create a new story progress record.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="storySegmentId">The story segment ID</param>
|
|
||||||
/// <returns>New StoryProgress instance</returns>
|
|
||||||
public static StoryProgress Create(int userId, int levelId, int storySegmentId)
|
|
||||||
{
|
|
||||||
return new StoryProgress
|
|
||||||
{
|
|
||||||
UserId = userId,
|
|
||||||
LevelId = levelId,
|
|
||||||
StorySegmentId = storySegmentId,
|
|
||||||
IsCompleted = false,
|
|
||||||
UnlockedAt = DateTime.UtcNow,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Marks this story segment as completed (read/listened to).
|
|
||||||
/// </summary>
|
|
||||||
public void MarkAsCompleted()
|
|
||||||
{
|
|
||||||
if (!IsCompleted)
|
|
||||||
{
|
|
||||||
IsCompleted = true;
|
|
||||||
CompletedAt = DateTime.UtcNow;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Marks this story segment as not completed.
|
|
||||||
/// </summary>
|
|
||||||
public void MarkAsIncomplete()
|
|
||||||
{
|
|
||||||
if (IsCompleted)
|
|
||||||
{
|
|
||||||
IsCompleted = false;
|
|
||||||
CompletedAt = null;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace GermanApp.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a segment of a continuous story for a specific level and lesson.
|
|
||||||
/// Story segments are unlocked sequentially as users complete lessons.
|
|
||||||
/// </summary>
|
|
||||||
public class StorySegment
|
|
||||||
{
|
|
||||||
public int Id { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The CEFR level this story segment belongs to.
|
|
||||||
/// </summary>
|
|
||||||
public int LevelId { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The lesson this story segment is associated with.
|
|
||||||
/// Can be null if the segment is an introduction or conclusion.
|
|
||||||
/// </summary>
|
|
||||||
public int? LessonId { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The text content of the story segment.
|
|
||||||
/// </summary>
|
|
||||||
public string Content { get; private set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// URL path to the audio file for this story segment.
|
|
||||||
/// </summary>
|
|
||||||
public string? AudioUrl { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The order of this segment within the level's story.
|
|
||||||
/// Segments are displayed in ascending order.
|
|
||||||
/// </summary>
|
|
||||||
public int Order { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Title or brief description of this story segment.
|
|
||||||
/// </summary>
|
|
||||||
public string Title { get; private set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Theme or topic of this story segment.
|
|
||||||
/// </summary>
|
|
||||||
public string Theme { get; private set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Estimated reading time in minutes.
|
|
||||||
/// </summary>
|
|
||||||
public int EstimatedReadingMinutes { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether this segment is active and visible to users.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsActive { get; private set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Timestamp when the segment was created.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Timestamp when the segment was last updated.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? UpdatedAt { get; private set; }
|
|
||||||
|
|
||||||
// Navigation properties (EF Core will handle these)
|
|
||||||
public virtual Level? Level { get; private set; }
|
|
||||||
public virtual Lesson? Lesson { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Constructor for EF Core deserialization.
|
|
||||||
/// </summary>
|
|
||||||
private StorySegment() { }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Factory method to create a new story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">ID of the level this segment belongs to</param>
|
|
||||||
/// <param name="lessonId">Optional ID of the associated lesson</param>
|
|
||||||
/// <param name="content">The story text content</param>
|
|
||||||
/// <param name="order">The order within the level's story</param>
|
|
||||||
/// <param name="title">Title of the segment</param>
|
|
||||||
/// <param name="theme">Theme or topic of the segment</param>
|
|
||||||
/// <param name="estimatedReadingMinutes">Estimated reading time in minutes</param>
|
|
||||||
/// <returns>New StorySegment instance</returns>
|
|
||||||
public static StorySegment Create(
|
|
||||||
int levelId,
|
|
||||||
int? lessonId,
|
|
||||||
string content,
|
|
||||||
int order,
|
|
||||||
string title,
|
|
||||||
string theme,
|
|
||||||
int estimatedReadingMinutes = 2)
|
|
||||||
{
|
|
||||||
return new StorySegment
|
|
||||||
{
|
|
||||||
LevelId = levelId,
|
|
||||||
LessonId = lessonId,
|
|
||||||
Content = content,
|
|
||||||
Order = order,
|
|
||||||
Title = title,
|
|
||||||
Theme = theme,
|
|
||||||
EstimatedReadingMinutes = estimatedReadingMinutes,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the content of the story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="newContent">New content text</param>
|
|
||||||
public void UpdateContent(string newContent)
|
|
||||||
{
|
|
||||||
Content = newContent;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the audio URL for this segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="audioUrl">URL path to the audio file</param>
|
|
||||||
public void UpdateAudioUrl(string audioUrl)
|
|
||||||
{
|
|
||||||
AudioUrl = audioUrl;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the title of the story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="newTitle">New title</param>
|
|
||||||
public void UpdateTitle(string newTitle)
|
|
||||||
{
|
|
||||||
Title = newTitle;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the theme of the story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="newTheme">New theme</param>
|
|
||||||
public void UpdateTheme(string newTheme)
|
|
||||||
{
|
|
||||||
Theme = newTheme;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the estimated reading time.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="minutes">Estimated reading time in minutes</param>
|
|
||||||
public void UpdateEstimatedReadingMinutes(int minutes)
|
|
||||||
{
|
|
||||||
EstimatedReadingMinutes = minutes;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the order of this segment within the level's story.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="newOrder">New order value</param>
|
|
||||||
public void UpdateOrder(int newOrder)
|
|
||||||
{
|
|
||||||
Order = newOrder;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the associated lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="newLessonId">New lesson ID (can be null)</param>
|
|
||||||
public void UpdateLesson(int? newLessonId)
|
|
||||||
{
|
|
||||||
LessonId = newLessonId;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Activates this story segment.
|
|
||||||
/// </summary>
|
|
||||||
public void Activate()
|
|
||||||
{
|
|
||||||
IsActive = true;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deactivates this story segment.
|
|
||||||
/// </summary>
|
|
||||||
public void Deactivate()
|
|
||||||
{
|
|
||||||
IsActive = false;
|
|
||||||
UpdatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,6 @@ public class User
|
||||||
public string Email { get; private set; } = string.Empty;
|
public string Email { get; private set; } = string.Empty;
|
||||||
public string PasswordHash { get; private set; } = string.Empty;
|
public string PasswordHash { get; private set; } = string.Empty;
|
||||||
public string CurrentLevel { get; private set; } = "A1";
|
public string CurrentLevel { get; private set; } = "A1";
|
||||||
public string Role { get; private set; } = "User"; // Default role is "User", "Admin" for administrators
|
|
||||||
public int Streak { get; private set; }
|
public int Streak { get; private set; }
|
||||||
public int TotalPoints { get; private set; }
|
public int TotalPoints { get; private set; }
|
||||||
public DateTime CreatedAt { get; private set; }
|
public DateTime CreatedAt { get; private set; }
|
||||||
|
|
@ -31,7 +30,6 @@ public class User
|
||||||
Email = email.ToLowerInvariant(),
|
Email = email.ToLowerInvariant(),
|
||||||
PasswordHash = passwordHash,
|
PasswordHash = passwordHash,
|
||||||
CurrentLevel = "A1",
|
CurrentLevel = "A1",
|
||||||
Role = "User", // Explicitly set default role
|
|
||||||
Streak = 0,
|
Streak = 0,
|
||||||
TotalPoints = 0,
|
TotalPoints = 0,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
|
|
@ -77,31 +75,4 @@ public class User
|
||||||
{
|
{
|
||||||
PasswordHash = newPasswordHash;
|
PasswordHash = newPasswordHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Assigns admin role to user (bootstrap only).
|
|
||||||
/// </summary>
|
|
||||||
public void AssignAdminRole()
|
|
||||||
{
|
|
||||||
Role = "Admin";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sets the user's role.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="role">The role to assign (User or Admin)</param>
|
|
||||||
public void SetRole(string role)
|
|
||||||
{
|
|
||||||
if (role != "Admin" && role != "User")
|
|
||||||
throw new ArgumentException("Role must be 'Admin' or 'User'.", nameof(role));
|
|
||||||
Role = role;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if user has admin role.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsAdmin() => Role == "Admin";
|
|
||||||
|
|
||||||
// Navigation properties
|
|
||||||
public virtual ICollection<StoryProgress> StoryProgress { get; private set; } = new List<StoryProgress>();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
using GermanApp.Application.Models;
|
|
||||||
|
|
||||||
namespace GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Interface for Mistral API connector.
|
|
||||||
/// This is part of the Domain layer - defines the contract for communicating with Mistral API.
|
|
||||||
/// </summary>
|
|
||||||
public interface IMistralConnector
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Sends a completion request to Mistral API.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The completion request containing prompt and parameters</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Mistral API response containing generated text</returns>
|
|
||||||
/// <exception cref="AiServiceException">Thrown when Mistral API request fails</exception>
|
|
||||||
Task<MistralResponse> CompleteAsync(MistralRequest request,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sends a chat completion request to Mistral API.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The chat completion request containing messages and parameters</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Mistral API chat response containing generated message</returns>
|
|
||||||
/// <exception cref="AiServiceException">Thrown when Mistral API request fails</exception>
|
|
||||||
Task<MistralResponse> ChatAsync(MistralChatRequest request,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lists available models from Mistral API.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of available Mistral models</returns>
|
|
||||||
/// <exception cref="AiServiceException">Thrown when Mistral API request fails</exception>
|
|
||||||
Task<IReadOnlyList<MistralModel>> ListModelsAsync(
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets information about a specific model.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="modelId">The model identifier</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Information about the specified model</returns>
|
|
||||||
/// <exception cref="AiServiceException">Thrown when Mistral API request fails</exception>
|
|
||||||
Task<MistralModel> GetModelAsync(string modelId,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Custom exception for AI service errors.
|
|
||||||
/// </summary>
|
|
||||||
public class AiServiceException : Exception
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Error code for categorizing AI service errors.
|
|
||||||
/// </summary>
|
|
||||||
public AiErrorCode ErrorCode { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new AI service exception.
|
|
||||||
/// </summary>
|
|
||||||
public AiServiceException(string message, AiErrorCode errorCode = AiErrorCode.Unknown)
|
|
||||||
: base(message)
|
|
||||||
{
|
|
||||||
ErrorCode = errorCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new AI service exception with inner exception.
|
|
||||||
/// </summary>
|
|
||||||
public AiServiceException(string message, Exception innerException,
|
|
||||||
AiErrorCode errorCode = AiErrorCode.Unknown)
|
|
||||||
: base(message, innerException)
|
|
||||||
{
|
|
||||||
ErrorCode = errorCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Error codes for AI service exceptions.
|
|
||||||
/// </summary>
|
|
||||||
public enum AiErrorCode
|
|
||||||
{
|
|
||||||
/// <summary>Unknown or unspecified error</summary>
|
|
||||||
Unknown = 0,
|
|
||||||
/// <summary>API rate limit exceeded</summary>
|
|
||||||
RateLimited = 1,
|
|
||||||
/// <summary>Temporary API failure, may succeed on retry</summary>
|
|
||||||
Temporary = 2,
|
|
||||||
/// <summary>Request timeout</summary>
|
|
||||||
Timeout = 3,
|
|
||||||
/// <summary>Invalid API key or authentication error</summary>
|
|
||||||
AuthenticationError = 4,
|
|
||||||
/// <summary>Invalid request parameters</summary>
|
|
||||||
InvalidRequest = 5,
|
|
||||||
/// <summary>API returned invalid/empty response</summary>
|
|
||||||
InvalidResponse = 6,
|
|
||||||
/// <summary>Service is temporarily unavailable (circuit breaker open)</summary>
|
|
||||||
ServiceUnavailable = 7
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
namespace GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Domain interface for Mistral AI text generation service.
|
|
||||||
/// This is part of the Domain layer.
|
|
||||||
/// </summary>
|
|
||||||
public interface IMistralService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Generates text from a prompt using Mistral API.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="prompt">The input prompt</param>
|
|
||||||
/// <param name="model">The model to use (defaults to configured default)</param>
|
|
||||||
/// <param name="temperature">Creativity level (0-1)</param>
|
|
||||||
/// <param name="maxTokens">Maximum tokens to generate</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Generated text</returns>
|
|
||||||
Task<string> GenerateTextAsync(
|
|
||||||
string prompt,
|
|
||||||
string? model = null,
|
|
||||||
float temperature = 0.7f,
|
|
||||||
int? maxTokens = null,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates text from chat messages using Mistral API.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="messages">List of chat messages (role, content)</param>
|
|
||||||
/// <param name="model">The model to use</param>
|
|
||||||
/// <param name="temperature">Creativity level (0-1)</param>
|
|
||||||
/// <param name="maxTokens">Maximum tokens to generate</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Generated text response</returns>
|
|
||||||
Task<string> GenerateChatAsync(
|
|
||||||
IReadOnlyList<(string role, string content)> messages,
|
|
||||||
string? model = null,
|
|
||||||
float temperature = 0.7f,
|
|
||||||
int? maxTokens = null,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a story based on lesson context and vocabulary.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="level">CEFR level (A1, A2, B1, B2, C1)</param>
|
|
||||||
/// <param name="topic">Story topic</param>
|
|
||||||
/// <param name="vocabularyWords">List of vocabulary words to include</param>
|
|
||||||
/// <param name="length">Approximate story length in words</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Generated story text</returns>
|
|
||||||
Task<string> GenerateStoryAsync(
|
|
||||||
string level,
|
|
||||||
string topic,
|
|
||||||
IReadOnlyList<string> vocabularyWords,
|
|
||||||
int length = 200,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Provides feedback on user writing.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userText">The text written by the user</param>
|
|
||||||
/// <param name="level">CEFR level for appropriate feedback</param>
|
|
||||||
/// <param name="prompt">Original prompt/exercise</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Feedback with corrections and suggestions</returns>
|
|
||||||
Task<string> GenerateWritingFeedbackAsync(
|
|
||||||
string userText,
|
|
||||||
string level,
|
|
||||||
string? prompt = null,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the Mistral API connection.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if connection is successful</returns>
|
|
||||||
Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
@ -133,145 +133,3 @@ public interface IUserProgressRepository : IRepository<UserProgress, int>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default);
|
Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Repository interface for QuizQuestion entities.
|
|
||||||
/// </summary>
|
|
||||||
public interface IQuizQuestionRepository : IRepository<QuizQuestion, int>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions by lesson ID.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizQuestion>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all quiz questions ordered by lesson and question order.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizQuestion>> GetAllOrderedAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quiz questions by lesson ID.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizQuestion>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a random set of quiz questions for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions by difficulty level.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizQuestion>> GetByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions by type.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizQuestion>> GetByTypeAsync(QuestionType type, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a quiz question with the given order exists in a lesson.
|
|
||||||
/// </summary>
|
|
||||||
Task<bool> OrderExistsInLessonAsync(int lessonId, int order, int? excludeId = null, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Quiz-based methods (QuizId)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions by quiz ID.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizQuestion>> GetByQuizAsync(int quizId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quiz questions by quiz ID.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizQuestion>> GetActiveByQuizAsync(int quizId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions by quiz with options loaded.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizQuestion>> GetByQuizWithOptionsAsync(int quizId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a random set of quiz questions for a quiz.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total points for a quiz.
|
|
||||||
/// </summary>
|
|
||||||
Task<int> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a quiz question with the given order exists in a quiz.
|
|
||||||
/// </summary>
|
|
||||||
Task<bool> OrderExistsInQuizAsync(int quizId, int order, int? excludeId = null, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Repository interface for Quiz entities.
|
|
||||||
/// </summary>
|
|
||||||
public interface IQuizRepository : IRepository<Quiz, int>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quizzes by lesson ID.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<Quiz>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all quizzes ordered by lesson and quiz order.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<Quiz>> GetAllOrderedAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quizzes by lesson ID.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<Quiz>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a quiz with its questions and options.
|
|
||||||
/// </summary>
|
|
||||||
Task<Quiz?> GetWithQuestionsAsync(int id, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the first quiz for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
Task<Quiz?> GetFirstByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a quiz exists for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
Task<bool> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Repository interface for QuizOption entities.
|
|
||||||
/// </summary>
|
|
||||||
public interface IQuizOptionRepository : IRepository<QuizOption, int>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz options by question ID.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizOption>> GetByQuestionAsync(int questionId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the correct option for a question.
|
|
||||||
/// </summary>
|
|
||||||
Task<QuizOption?> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all correct options for a question (for MultipleSelect type).
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<QuizOption>> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes all options for a question.
|
|
||||||
/// </summary>
|
|
||||||
Task DeleteByQuestionAsync(int questionId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if the order exists for a question.
|
|
||||||
/// </summary>
|
|
||||||
Task<bool> OrderExistsForQuestionAsync(int questionId, int order, int? excludeId = null, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
|
|
||||||
namespace GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Repository interface for managing StoryProgress entities.
|
|
||||||
/// This is part of the Domain layer.
|
|
||||||
/// </summary>
|
|
||||||
public interface IStoryProgressRepository
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets story progress for a specific user and level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of story progress records for the user and level</returns>
|
|
||||||
Task<IReadOnlyList<StoryProgress>> GetByUserAndLevelAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets story progress for a specific user and segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="storySegmentId">The story segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The story progress record, or null if not found</returns>
|
|
||||||
Task<StoryProgress?> GetByUserAndSegmentAsync(
|
|
||||||
int userId,
|
|
||||||
int storySegmentId,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the highest unlocked segment order for a user in a level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The highest order of unlocked segments, or 0 if none</returns>
|
|
||||||
Task<int> GetHighestUnlockedOrderAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a user has unlocked a specific story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="storySegmentId">The story segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if the segment is unlocked</returns>
|
|
||||||
Task<bool> IsSegmentUnlockedAsync(
|
|
||||||
int userId,
|
|
||||||
int storySegmentId,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a user has completed (read/listened to) a specific story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="storySegmentId">The story segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if the segment is completed</returns>
|
|
||||||
Task<bool> IsSegmentCompletedAsync(
|
|
||||||
int userId,
|
|
||||||
int storySegmentId,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds a new story progress record.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="progress">The story progress to add</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The added progress with generated ID</returns>
|
|
||||||
Task<StoryProgress> AddAsync(StoryProgress progress, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an existing story progress record.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="progress">The story progress to update</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
Task UpdateAsync(StoryProgress progress, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total count of story segments for a level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The total number of segments in the level</returns>
|
|
||||||
Task<int> GetTotalSegmentsForLevelAsync(int levelId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the count of unlocked segments for a user in a level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The number of unlocked segments</returns>
|
|
||||||
Task<int> GetUnlockedCountAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the count of completed segments for a user in a level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The number of completed segments</returns>
|
|
||||||
Task<int> GetCompletedCountAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
|
|
||||||
namespace GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Repository interface for managing StorySegment entities.
|
|
||||||
/// This is part of the Domain layer.
|
|
||||||
/// </summary>
|
|
||||||
public interface IStoryRepository
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a story segment by its ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The story segment or null if not found</returns>
|
|
||||||
Task<StorySegment?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all story segments for a specific level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="includeInactive">Whether to include inactive segments</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of story segments ordered by Order</returns>
|
|
||||||
Task<IReadOnlyList<StorySegment>> GetByLevelAsync(
|
|
||||||
int levelId,
|
|
||||||
bool includeInactive = false,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all story segments for a specific lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of story segments associated with the lesson</returns>
|
|
||||||
Task<IReadOnlyList<StorySegment>> GetByLessonAsync(
|
|
||||||
int lessonId,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the next segment to unlock for a user after completing a lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="completedLessonOrder">The order of the completed lesson</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The next story segment to unlock, or null if none</returns>
|
|
||||||
Task<StorySegment?> GetNextSegmentToUnlockAsync(
|
|
||||||
int levelId,
|
|
||||||
int completedLessonOrder,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets story segments by their order range.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="startOrder">Starting order (inclusive)</param>
|
|
||||||
/// <param name="endOrder">Ending order (inclusive)</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of story segments in the specified order range</returns>
|
|
||||||
Task<IReadOnlyList<StorySegment>> GetByOrderRangeAsync(
|
|
||||||
int levelId,
|
|
||||||
int startOrder,
|
|
||||||
int endOrder,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds a new story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="segment">The story segment to add</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The added segment with generated ID</returns>
|
|
||||||
Task<StorySegment> AddAsync(StorySegment segment, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an existing story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="segment">The story segment to update</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
Task UpdateAsync(StorySegment segment, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a story segment by its ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the highest order value for segments in a level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The highest order value, or 0 if no segments exist</returns>
|
|
||||||
Task<int> GetMaxOrderAsync(int levelId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a story segment exists for the given ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if the segment exists</returns>
|
|
||||||
Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all story segments that need audio generation.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of segments with null or empty AudioUrl</returns>
|
|
||||||
Task<IReadOnlyList<StorySegment>> GetSegmentsNeedingAudioAsync(
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
using System.IO;
|
|
||||||
|
|
||||||
namespace GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Domain interface for Coqui TTS text-to-speech service.
|
|
||||||
/// This is part of the Domain layer.
|
|
||||||
/// </summary>
|
|
||||||
public interface ITtsService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio from text.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">Text to convert to speech</param>
|
|
||||||
/// <param name="speaker">Speaker ID/voice (optional)</param>
|
|
||||||
/// <param name="language">Language code (default: de)</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Audio data as bytes</returns>
|
|
||||||
Task<byte[]> GenerateAudioAsync(
|
|
||||||
string text,
|
|
||||||
string? speaker = null,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio and saves to a file.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">Text to convert to speech</param>
|
|
||||||
/// <param name="outputPath">Path to save the audio file</param>
|
|
||||||
/// <param name="speaker">Speaker ID/voice (optional)</param>
|
|
||||||
/// <param name="language">Language code (default: de)</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Path to the generated audio file</returns>
|
|
||||||
Task<string> GenerateAudioToFileAsync(
|
|
||||||
string text,
|
|
||||||
string outputPath,
|
|
||||||
string? speaker = null,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio as a stream.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">Text to convert to speech</param>
|
|
||||||
/// <param name="speaker">Speaker ID/voice (optional)</param>
|
|
||||||
/// <param name="language">Language code (default: de)</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Audio stream</returns>
|
|
||||||
Task<Stream> GenerateAudioStreamAsync(
|
|
||||||
string text,
|
|
||||||
string? speaker = null,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the Coqui TTS model and configuration.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if model is loaded and working</returns>
|
|
||||||
Task<bool> TestModelAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current TTS model information.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Model name and path</returns>
|
|
||||||
Task<(string ModelName, string? ModelPath)> GetModelInfoAsync();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets list of available voices/speakers for the current model.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of available speaker IDs</returns>
|
|
||||||
Task<IReadOnlyList<string>> GetAvailableSpeakersAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cleans up old audio files from storage.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="olderThan">Delete files older than this timespan</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Number of files deleted</returns>
|
|
||||||
Task<int> CleanupOldFilesAsync(
|
|
||||||
TimeSpan olderThan,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
namespace GermanApp.Domain.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Domain interface for Vosk speech recognition service.
|
|
||||||
/// This is part of the Domain layer.
|
|
||||||
/// </summary>
|
|
||||||
public interface IVoskService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from audio bytes.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="audioBytes">Audio data in bytes (WAV format)</param>
|
|
||||||
/// <param name="sampleRate">Sample rate of the audio in Hz</param>
|
|
||||||
/// <param name="hint">Optional hint/phrase to improve recognition accuracy</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Recognized text</returns>
|
|
||||||
Task<string> RecognizeSpeechAsync(
|
|
||||||
byte[] audioBytes,
|
|
||||||
int sampleRate = 16000,
|
|
||||||
string? hint = null,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from an audio file path.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="audioFilePath">Path to the audio file</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Recognized text</returns>
|
|
||||||
Task<string> RecognizeSpeechFromFileAsync(
|
|
||||||
string audioFilePath,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from a stream.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="audioStream">Audio stream</param>
|
|
||||||
/// <param name="sampleRate">Sample rate of the audio in Hz</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Recognized text</returns>
|
|
||||||
Task<string> RecognizeSpeechFromStreamAsync(
|
|
||||||
System.IO.Stream audioStream,
|
|
||||||
int sampleRate = 16000,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the Vosk model and configuration.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if model is loaded and working</returns>
|
|
||||||
Task<bool> TestModelAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current Vosk model information.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Model name and path</returns>
|
|
||||||
Task<(string ModelName, string ModelPath)> GetModelInfoAsync();
|
|
||||||
}
|
|
||||||
|
|
@ -13,9 +13,7 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.16" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.16" />
|
||||||
<PackageReference Include="Polly" Version="8.6.6" />
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
|
||||||
|
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
namespace GermanApp.Infrastructure.Configuration;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configuration settings for Coqui TTS service.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
///
|
|
||||||
/// Setup Instructions:
|
|
||||||
/// 1. Install Python 3.8+: https://www.python.org/downloads/
|
|
||||||
/// 2. Install Coqui TTS: pip install TTS
|
|
||||||
/// 3. Coqui will automatically download the model on first use based on ModelName
|
|
||||||
/// 4. Recommended German model: tts_models/de/deu/fairseq/vits
|
|
||||||
/// 5. Set AudioStoragePath to a directory with write permissions
|
|
||||||
///
|
|
||||||
/// Note: Models require ~1.5GB disk space. First run will download the model automatically.
|
|
||||||
/// Alternative: Pre-download with: python -m TTS.server --model_name tts_models/de/deu/fairseq/vits
|
|
||||||
/// </summary>
|
|
||||||
public class CoquiConfig
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Path to the Python executable.
|
|
||||||
/// Default: python3
|
|
||||||
/// </summary>
|
|
||||||
public string PythonPath { get; set; } = "python3";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Name of the Coqui TTS model to use.
|
|
||||||
/// Example: tts_models/de/deu/fairseq/vits
|
|
||||||
/// </summary>
|
|
||||||
public string ModelName { get; set; } = "tts_models/de/deu/fairseq/vits";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Path to the TTS Python package/module.
|
|
||||||
/// Default: TTS
|
|
||||||
/// </summary>
|
|
||||||
public string ModulePath { get; set; } = "TTS";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Output audio format.
|
|
||||||
/// Supported: wav, mp3, ogg, flac
|
|
||||||
/// Default: wav
|
|
||||||
/// </summary>
|
|
||||||
public string OutputFormat { get; set; } = "wav";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Output audio sample rate in Hz.
|
|
||||||
/// Default: 22050
|
|
||||||
/// </summary>
|
|
||||||
public int SampleRate { get; set; } = 22050;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Voice speaker ID for the model.
|
|
||||||
/// Default: (empty - uses model default)
|
|
||||||
/// </summary>
|
|
||||||
public string Speaker { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Language code for TTS.
|
|
||||||
/// Default: de
|
|
||||||
/// </summary>
|
|
||||||
public string Language { get; set; } = "de";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maximum text length in characters for a single TTS request.
|
|
||||||
/// Longer texts will be split.
|
|
||||||
/// Default: 500 characters
|
|
||||||
/// </summary>
|
|
||||||
public int MaxTextLength { get; set; } = 500;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Timeout in seconds for TTS processing.
|
|
||||||
/// Default: 60 seconds
|
|
||||||
/// </summary>
|
|
||||||
public int TimeoutSeconds { get; set; } = 60;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Path to store generated audio files.
|
|
||||||
/// Default: /var/audio/tts
|
|
||||||
/// </summary>
|
|
||||||
public string AudioStoragePath { get; set; } = "/var/audio/tts";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to use GPU acceleration if available.
|
|
||||||
/// Default: false
|
|
||||||
/// </summary>
|
|
||||||
public bool UseGPU { get; set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates the configuration.
|
|
||||||
/// </summary>
|
|
||||||
/// <exception cref="ArgumentException">Thrown when configuration is invalid</exception>
|
|
||||||
public void Validate()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(PythonPath))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Coqui PythonPath is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(ModelName))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Coqui ModelName is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(ModulePath))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Coqui ModulePath is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(OutputFormat))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Coqui OutputFormat is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
var validFormats = new[] { "wav", "mp3", "ogg", "flac" };
|
|
||||||
if (!validFormats.Contains(OutputFormat.ToLower()))
|
|
||||||
{
|
|
||||||
throw new ArgumentException(
|
|
||||||
$"Invalid OutputFormat '{OutputFormat}'. Valid formats: {string.Join(", ", validFormats)}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SampleRate <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("SampleRate must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (MaxTextLength <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("MaxTextLength must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (TimeoutSeconds <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("TimeoutSeconds must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(AudioStoragePath))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("AudioStoragePath is required");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
namespace GermanApp.Infrastructure.Configuration;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configuration settings for Mistral API connector.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class MistralConfig
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The Mistral API key for authentication.
|
|
||||||
/// </summary>
|
|
||||||
public string ApiKey { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The base URL for the Mistral API.
|
|
||||||
/// Default: https://api.mistral.ai/v1/
|
|
||||||
/// </summary>
|
|
||||||
public string BaseUrl { get; set; } = "https://api.mistral.ai/v1/";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The default model to use for completions.
|
|
||||||
/// Default: mistral-small-latest
|
|
||||||
/// </summary>
|
|
||||||
public string DefaultModel { get; set; } = "mistral-small-latest";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Timeout in seconds for HTTP requests.
|
|
||||||
/// Default: 30 seconds
|
|
||||||
/// </summary>
|
|
||||||
public int TimeoutSeconds { get; set; } = 30;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maximum number of retry attempts for failed requests.
|
|
||||||
/// Default: 3
|
|
||||||
/// </summary>
|
|
||||||
public int MaxRetries { get; set; } = 3;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maximum requests per minute rate limit.
|
|
||||||
/// Default: 10 requests/minute
|
|
||||||
/// </summary>
|
|
||||||
public int RateLimitPerMinute { get; set; } = 10;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to enable response caching.
|
|
||||||
/// Default: true
|
|
||||||
/// </summary>
|
|
||||||
public bool EnableCaching { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cache time-to-live in minutes.
|
|
||||||
/// Default: 60 minutes
|
|
||||||
/// </summary>
|
|
||||||
public int CacheTTLMinutes { get; set; } = 60;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Circuit breaker failure threshold (number of consecutive failures before opening).
|
|
||||||
/// Default: 5
|
|
||||||
/// </summary>
|
|
||||||
public int CircuitBreakerFailureThreshold { get; set; } = 5;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Circuit breaker reset timeout in minutes.
|
|
||||||
/// Default: 1 minute
|
|
||||||
/// </summary>
|
|
||||||
public int CircuitBreakerResetMinutes { get; set; } = 1;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates the configuration.
|
|
||||||
/// </summary>
|
|
||||||
/// <exception cref="ArgumentException">Thrown when configuration is invalid</exception>
|
|
||||||
public void Validate()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(ApiKey))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Mistral API key is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(BaseUrl))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Mistral BaseUrl is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Uri.TryCreate(BaseUrl, UriKind.Absolute, out _))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Mistral BaseUrl must be a valid URL");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (TimeoutSeconds <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("TimeoutSeconds must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (MaxRetries < 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("MaxRetries must be non-negative");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (RateLimitPerMinute <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("RateLimitPerMinute must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CacheTTLMinutes < 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("CacheTTLMinutes must be non-negative");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CircuitBreakerFailureThreshold <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("CircuitBreakerFailureThreshold must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CircuitBreakerResetMinutes <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("CircuitBreakerResetMinutes must be greater than 0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
namespace GermanApp.Infrastructure.Configuration;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configuration settings for Vosk speech recognition service.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
///
|
|
||||||
/// Setup Instructions:
|
|
||||||
/// 1. Install Python 3.8+: https://www.python.org/downloads/
|
|
||||||
/// 2. Install Vosk: pip install vosk
|
|
||||||
/// 3. Download German model:
|
|
||||||
/// wget https://alphacephei.com/vosk/models/vosk-model-de-0.22.zip
|
|
||||||
/// unzip vosk-model-de-0.22.zip
|
|
||||||
/// 4. Set ModelPath to the extracted directory (e.g., /models/vosk-model-de-0.22)
|
|
||||||
///
|
|
||||||
/// Note: Model requires ~500MB disk space
|
|
||||||
/// </summary>
|
|
||||||
public class VoskConfig
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Path to the Python executable.
|
|
||||||
/// Default: python3
|
|
||||||
/// </summary>
|
|
||||||
public string PythonPath { get; set; } = "python3";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Path to the Vosk model directory.
|
|
||||||
/// Example: /models/vosk-model-de-0.22
|
|
||||||
/// </summary>
|
|
||||||
public string ModelPath { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Expected audio sample rate in Hz.
|
|
||||||
/// Vosk models typically use 16000 Hz.
|
|
||||||
/// Default: 16000
|
|
||||||
/// </summary>
|
|
||||||
public int SampleRate { get; set; } = 16000;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maximum audio duration in seconds for speech recognition.
|
|
||||||
/// Default: 60 seconds
|
|
||||||
/// </summary>
|
|
||||||
public int MaxAudioDurationSeconds { get; set; } = 60;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Timeout in seconds for Vosk processing.
|
|
||||||
/// Default: 30 seconds
|
|
||||||
/// </summary>
|
|
||||||
public int TimeoutSeconds { get; set; } = 30;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether to enable beam width adjustment for accuracy vs speed tradeoff.
|
|
||||||
/// Higher values = more accurate but slower.
|
|
||||||
/// Default: 16
|
|
||||||
/// </summary>
|
|
||||||
public int BeamWidth { get; set; } = 16;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Path to the Vosk Python package/module.
|
|
||||||
/// Default: vosk
|
|
||||||
/// </summary>
|
|
||||||
public string ModulePath { get; set; } = "vosk";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates the configuration.
|
|
||||||
/// </summary>
|
|
||||||
/// <exception cref="ArgumentException">Thrown when configuration is invalid</exception>
|
|
||||||
public void Validate()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(PythonPath))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Vosk PythonPath is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(ModelPath))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Vosk ModelPath is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SampleRate <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("SampleRate must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (MaxAudioDurationSeconds <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("MaxAudioDurationSeconds must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (TimeoutSeconds <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("TimeoutSeconds must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (BeamWidth <= 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("BeamWidth must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(ModulePath))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Vosk ModulePath is required");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -14,16 +14,11 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
|
||||||
}
|
}
|
||||||
|
|
||||||
// DbSets for domain entities
|
// DbSets for domain entities
|
||||||
public virtual DbSet<Level> Levels { get; set; } = null!;
|
public DbSet<Level> Levels { get; set; } = null!;
|
||||||
public virtual DbSet<Lesson> Lessons { get; set; } = null!;
|
public DbSet<Lesson> Lessons { get; set; } = null!;
|
||||||
public virtual DbSet<User> Users { get; set; } = null!;
|
public DbSet<User> Users { get; set; } = null!;
|
||||||
public virtual DbSet<UserProgress> UserProgress { get; set; } = null!;
|
public DbSet<UserProgress> UserProgress { get; set; } = null!;
|
||||||
public virtual DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
|
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
|
||||||
public virtual DbSet<Quiz> Quizzes { get; set; } = null!;
|
|
||||||
public virtual DbSet<QuizQuestion> QuizQuestions { get; set; } = null!;
|
|
||||||
public virtual DbSet<QuizOption> QuizOptions { get; set; } = null!;
|
|
||||||
public virtual DbSet<StorySegment> StorySegments { get; set; } = null!;
|
|
||||||
public virtual DbSet<StoryProgress> StoryProgress { get; set; } = null!;
|
|
||||||
|
|
||||||
// Note: Value objects are not stored directly as entities.
|
// Note: Value objects are not stored directly as entities.
|
||||||
// They are owned by entities and stored as part of the entity's data.
|
// They are owned by entities and stored as part of the entity's data.
|
||||||
|
|
@ -110,7 +105,6 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
|
||||||
builder.Property(u => u.Email).IsRequired().HasMaxLength(100);
|
builder.Property(u => u.Email).IsRequired().HasMaxLength(100);
|
||||||
builder.Property(u => u.PasswordHash).IsRequired().HasMaxLength(255);
|
builder.Property(u => u.PasswordHash).IsRequired().HasMaxLength(255);
|
||||||
builder.Property(u => u.CurrentLevel).HasMaxLength(10).HasDefaultValue("A1");
|
builder.Property(u => u.CurrentLevel).HasMaxLength(10).HasDefaultValue("A1");
|
||||||
builder.Property(u => u.Role).HasMaxLength(20).HasDefaultValue("User");
|
|
||||||
builder.Property(u => u.Streak).HasDefaultValue(0);
|
builder.Property(u => u.Streak).HasDefaultValue(0);
|
||||||
builder.Property(u => u.TotalPoints).HasDefaultValue(0);
|
builder.Property(u => u.TotalPoints).HasDefaultValue(0);
|
||||||
builder.Property(u => u.CreatedAt).IsRequired();
|
builder.Property(u => u.CreatedAt).IsRequired();
|
||||||
|
|
@ -138,150 +132,6 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configure Quiz entity
|
|
||||||
modelBuilder.Entity<Quiz>(builder =>
|
|
||||||
{
|
|
||||||
builder.HasKey(q => q.Id);
|
|
||||||
builder.Property(q => q.LessonId).IsRequired();
|
|
||||||
builder.Property(q => q.Title).IsRequired().HasMaxLength(200);
|
|
||||||
builder.Property(q => q.Description).HasMaxLength(2000);
|
|
||||||
builder.Property(q => q.PassingScore).IsRequired().HasDefaultValue(80);
|
|
||||||
builder.Property(q => q.TimeLimitMinutes).HasDefaultValue(0);
|
|
||||||
builder.Property(q => q.IsActive).HasDefaultValue(true);
|
|
||||||
builder.Property(q => q.ShuffleQuestions).HasDefaultValue(true);
|
|
||||||
builder.Property(q => q.CreatedAt).IsRequired();
|
|
||||||
builder.Property(q => q.UpdatedAt).IsRequired(false);
|
|
||||||
|
|
||||||
// Foreign key to Lesson
|
|
||||||
builder.HasOne(q => q.Lesson)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(q => q.LessonId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
// Navigation to questions
|
|
||||||
builder.HasMany(q => q.Questions)
|
|
||||||
.WithOne(qq => qq.Quiz)
|
|
||||||
.HasForeignKey(qq => qq.QuizId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure QuizQuestion entity
|
|
||||||
modelBuilder.Entity<QuizQuestion>(builder =>
|
|
||||||
{
|
|
||||||
builder.HasKey(q => q.Id);
|
|
||||||
builder.Property(q => q.QuizId).IsRequired();
|
|
||||||
builder.Property(q => q.QuestionText).IsRequired().HasMaxLength(2000);
|
|
||||||
builder.Property(q => q.Type).IsRequired();
|
|
||||||
builder.Property(q => q.CorrectAnswer).HasMaxLength(1000);
|
|
||||||
builder.Property(q => q.Points).IsRequired().HasDefaultValue(1);
|
|
||||||
builder.Property(q => q.Difficulty).IsRequired().HasDefaultValue(3);
|
|
||||||
builder.Property(q => q.Order).IsRequired().HasDefaultValue(1);
|
|
||||||
builder.Property(q => q.IsActive).HasDefaultValue(true);
|
|
||||||
builder.Property(q => q.CreatedAt).IsRequired();
|
|
||||||
builder.Property(q => q.UpdatedAt).IsRequired(false);
|
|
||||||
|
|
||||||
// Foreign key to Quiz
|
|
||||||
builder.HasOne(q => q.Quiz)
|
|
||||||
.WithMany(qq => qq.Questions)
|
|
||||||
.HasForeignKey(q => q.QuizId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
// Unique constraint: one question per quiz per order
|
|
||||||
builder.HasIndex(q => new { q.QuizId, q.Order }).IsUnique();
|
|
||||||
|
|
||||||
// Navigation to options
|
|
||||||
builder.HasMany(q => q.Options)
|
|
||||||
.WithOne(o => o.QuizQuestion)
|
|
||||||
.HasForeignKey(o => o.QuizQuestionId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure QuizOption entity
|
|
||||||
modelBuilder.Entity<QuizOption>(builder =>
|
|
||||||
{
|
|
||||||
builder.HasKey(o => o.Id);
|
|
||||||
builder.Property(o => o.QuizQuestionId).IsRequired();
|
|
||||||
builder.Property(o => o.Text).IsRequired().HasMaxLength(1000);
|
|
||||||
builder.Property(o => o.IsCorrect).IsRequired().HasDefaultValue(false);
|
|
||||||
builder.Property(o => o.Order).IsRequired().HasDefaultValue(1);
|
|
||||||
|
|
||||||
// Foreign key to QuizQuestion
|
|
||||||
builder.HasOne(o => o.QuizQuestion)
|
|
||||||
.WithMany(q => q.Options)
|
|
||||||
.HasForeignKey(o => o.QuizQuestionId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
// Unique constraint: one option per question per order
|
|
||||||
builder.HasIndex(o => new { o.QuizQuestionId, o.Order }).IsUnique();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure StorySegment entity
|
|
||||||
modelBuilder.Entity<StorySegment>(builder =>
|
|
||||||
{
|
|
||||||
builder.HasKey(s => s.Id);
|
|
||||||
builder.Property(s => s.LevelId).IsRequired();
|
|
||||||
builder.Property(s => s.LessonId).IsRequired(false);
|
|
||||||
builder.Property(s => s.Content).IsRequired();
|
|
||||||
builder.Property(s => s.AudioUrl).HasMaxLength(255).IsRequired(false);
|
|
||||||
builder.Property(s => s.Order).IsRequired();
|
|
||||||
builder.Property(s => s.Title).IsRequired().HasMaxLength(200);
|
|
||||||
builder.Property(s => s.Theme).IsRequired().HasMaxLength(100);
|
|
||||||
builder.Property(s => s.EstimatedReadingMinutes).IsRequired().HasDefaultValue(2);
|
|
||||||
builder.Property(s => s.IsActive).HasDefaultValue(true);
|
|
||||||
builder.Property(s => s.CreatedAt).IsRequired();
|
|
||||||
builder.Property(s => s.UpdatedAt).IsRequired(false);
|
|
||||||
|
|
||||||
// Foreign key to Level
|
|
||||||
builder.HasOne(s => s.Level)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(s => s.LevelId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
// Foreign key to Lesson (optional)
|
|
||||||
builder.HasOne(s => s.Lesson)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(s => s.LessonId)
|
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
|
||||||
|
|
||||||
// Unique constraint: one segment per level per order
|
|
||||||
builder.HasIndex(s => new { s.LevelId, s.Order }).IsUnique();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure StoryProgress entity
|
|
||||||
modelBuilder.Entity<StoryProgress>(builder =>
|
|
||||||
{
|
|
||||||
builder.HasKey(sp => sp.Id);
|
|
||||||
builder.Property(sp => sp.UserId).IsRequired();
|
|
||||||
builder.Property(sp => sp.LevelId).IsRequired();
|
|
||||||
builder.Property(sp => sp.StorySegmentId).IsRequired();
|
|
||||||
builder.Property(sp => sp.IsCompleted).HasDefaultValue(false);
|
|
||||||
builder.Property(sp => sp.UnlockedAt).IsRequired();
|
|
||||||
builder.Property(sp => sp.CompletedAt).IsRequired(false);
|
|
||||||
builder.Property(sp => sp.CreatedAt).IsRequired();
|
|
||||||
builder.Property(sp => sp.UpdatedAt).IsRequired(false);
|
|
||||||
|
|
||||||
// Foreign key to User
|
|
||||||
builder.HasOne(sp => sp.User)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(sp => sp.UserId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
// Foreign key to Level
|
|
||||||
builder.HasOne(sp => sp.Level)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(sp => sp.LevelId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
// Foreign key to StorySegment
|
|
||||||
builder.HasOne(sp => sp.StorySegment)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(sp => sp.StorySegmentId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
// Unique constraint: one progress record per user per segment
|
|
||||||
builder.HasIndex(sp => new { sp.UserId, sp.StorySegmentId }).IsUnique();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Seed data (optional) - Note: For EF Core, we need to set properties directly
|
// Seed data (optional) - Note: For EF Core, we need to set properties directly
|
||||||
// In a real application, use migrations or a separate seeding mechanism
|
// In a real application, use migrations or a separate seeding mechanism
|
||||||
// modelBuilder.Entity<Lesson>().HasData(
|
// modelBuilder.Entity<Lesson>().HasData(
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Design;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.DbContext;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Factory for creating AppDbContext instances during design-time (e.g., EF migrations).
|
|
||||||
/// This is used by EF Core tools to create the DbContext without running the full application startup.
|
|
||||||
/// </summary>
|
|
||||||
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
|
||||||
{
|
|
||||||
public AppDbContext CreateDbContext(string[] args)
|
|
||||||
{
|
|
||||||
// Build configuration from appsettings.json
|
|
||||||
var configuration = new ConfigurationBuilder()
|
|
||||||
.SetBasePath(Directory.GetCurrentDirectory())
|
|
||||||
.AddJsonFile("appsettings.json")
|
|
||||||
.AddJsonFile("appsettings.Development.json", optional: true)
|
|
||||||
.AddEnvironmentVariables()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var builder = new DbContextOptionsBuilder<AppDbContext>();
|
|
||||||
|
|
||||||
// Use PostgreSQL
|
|
||||||
var connectionString = configuration.GetConnectionString("DefaultConnection")
|
|
||||||
?? "Host=localhost;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres";
|
|
||||||
|
|
||||||
builder.UseNpgsql(connectionString);
|
|
||||||
|
|
||||||
return new AppDbContext(builder.Options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,390 +0,0 @@
|
||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using GermanApp.Infrastructure.Data.DbContext;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AppDbContext))]
|
|
||||||
[Migration("20260612050652_AddQuizQuestionTables")]
|
|
||||||
partial class AddQuizQuestionTables
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "9.0.0")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<string>("Topic")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Lessons");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("Code")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(10)
|
|
||||||
.HasColumnType("character varying(10)");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Code")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Levels");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<bool>("IsCorrect")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<int>("QuizQuestionId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Text")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(1000)
|
|
||||||
.HasColumnType("character varying(1000)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("QuizQuestionId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("QuizOptions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("CorrectAnswer")
|
|
||||||
.HasMaxLength(1000)
|
|
||||||
.HasColumnType("character varying(1000)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("Difficulty")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(3);
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<string>("QuestionText")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<int>("Type")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("QuizQuestions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime>("ExpiresAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<DateTime?>("RevokedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Token")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("RefreshTokens");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("CurrentLevel")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasMaxLength(10)
|
|
||||||
.HasColumnType("character varying(10)")
|
|
||||||
.HasDefaultValue("A1");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<int>("Streak")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<int>("TotalPoints")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<string>("Username")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Email")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("Username")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Users");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<bool>("IsCompleted")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<DateTime>("LastAttemptDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("QuizScore")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId", "LessonId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("UserProgress");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.QuizQuestion", "QuizQuestion")
|
|
||||||
.WithMany("Options")
|
|
||||||
.HasForeignKey("QuizQuestionId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("QuizQuestion");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", "User")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Options");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddQuizQuestionTables : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "QuizQuestions",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
LessonId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
QuestionText = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
|
|
||||||
Type = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
CorrectAnswer = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
|
||||||
Difficulty = table.Column<int>(type: "integer", nullable: false, defaultValue: 3),
|
|
||||||
Order = table.Column<int>(type: "integer", nullable: false, defaultValue: 1),
|
|
||||||
IsActive = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_QuizQuestions", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_QuizQuestions_Lessons_LessonId",
|
|
||||||
column: x => x.LessonId,
|
|
||||||
principalTable: "Lessons",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "QuizOptions",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
QuizQuestionId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
Text = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
|
|
||||||
IsCorrect = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
|
||||||
Order = table.Column<int>(type: "integer", nullable: false, defaultValue: 1)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_QuizOptions", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_QuizOptions_QuizQuestions_QuizQuestionId",
|
|
||||||
column: x => x.QuizQuestionId,
|
|
||||||
principalTable: "QuizQuestions",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_QuizOptions_QuizQuestionId_Order",
|
|
||||||
table: "QuizOptions",
|
|
||||||
columns: new[] { "QuizQuestionId", "Order" },
|
|
||||||
unique: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_QuizQuestions_LessonId_Order",
|
|
||||||
table: "QuizQuestions",
|
|
||||||
columns: new[] { "LessonId", "Order" },
|
|
||||||
unique: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "QuizOptions");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "QuizQuestions");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,669 +0,0 @@
|
||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using GermanApp.Infrastructure.Data.DbContext;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AppDbContext))]
|
|
||||||
[Migration("20260613125706_AddStorySegmentAndStoryProgressTables")]
|
|
||||||
partial class AddStorySegmentAndStoryProgressTables
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "9.0.0")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LevelId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<string>("Topic")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Lessons");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("Code")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(10)
|
|
||||||
.HasColumnType("character varying(10)");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Code")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Levels");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("PassingScore")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(80);
|
|
||||||
|
|
||||||
b.Property<bool>("ShuffleQuestions")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("TimeLimitMinutes")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.ToTable("Quizzes");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<bool>("IsCorrect")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<int>("QuizQuestionId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Text")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(1000)
|
|
||||||
.HasColumnType("character varying(1000)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("QuizQuestionId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("QuizOptions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("CorrectAnswer")
|
|
||||||
.HasMaxLength(1000)
|
|
||||||
.HasColumnType("character varying(1000)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("Difficulty")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(3);
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<int>("Points")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<string>("QuestionText")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<int>("QuizId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Type")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("QuizId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("QuizQuestions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime>("ExpiresAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<DateTime?>("RevokedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Token")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("RefreshTokens");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StoryProgress", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime?>("CompletedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsCompleted")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("StorySegmentId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UnlockedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("UserId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId");
|
|
||||||
|
|
||||||
b.HasIndex("StorySegmentId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId1");
|
|
||||||
|
|
||||||
b.HasIndex("UserId", "StorySegmentId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("StoryProgress");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StorySegment", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("AudioUrl")
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<string>("Content")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("EstimatedReadingMinutes")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(2);
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int?>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LessonId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LevelId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Theme")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("CurrentLevel")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasMaxLength(10)
|
|
||||||
.HasColumnType("character varying(10)")
|
|
||||||
.HasDefaultValue("A1");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<int>("Streak")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<int>("TotalPoints")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<string>("Username")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Email")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("Username")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Users");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<bool>("IsCompleted")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<DateTime>("LastAttemptDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("QuizScore")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId", "LessonId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("UserProgress");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", null)
|
|
||||||
.WithMany("Lessons")
|
|
||||||
.HasForeignKey("LevelId1");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.QuizQuestion", "QuizQuestion")
|
|
||||||
.WithMany("Options")
|
|
||||||
.HasForeignKey("QuizQuestionId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("QuizQuestion");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Quiz", "Quiz")
|
|
||||||
.WithMany("Questions")
|
|
||||||
.HasForeignKey("QuizId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Quiz");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StoryProgress", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.StorySegment", "StorySegment")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("StorySegmentId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", "User")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
|
||||||
.WithMany("StoryProgress")
|
|
||||||
.HasForeignKey("UserId1");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
|
|
||||||
b.Navigation("StorySegment");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StorySegment", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", null)
|
|
||||||
.WithMany("StorySegments")
|
|
||||||
.HasForeignKey("LessonId1");
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", null)
|
|
||||||
.WithMany("StorySegments")
|
|
||||||
.HasForeignKey("LevelId1");
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", "User")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Lessons");
|
|
||||||
|
|
||||||
b.Navigation("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Questions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Options");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("StoryProgress");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddStorySegmentAndStoryProgressTables : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropForeignKey(
|
|
||||||
name: "FK_QuizQuestions_Lessons_LessonId",
|
|
||||||
table: "QuizQuestions");
|
|
||||||
|
|
||||||
migrationBuilder.RenameColumn(
|
|
||||||
name: "LessonId",
|
|
||||||
table: "QuizQuestions",
|
|
||||||
newName: "QuizId");
|
|
||||||
|
|
||||||
migrationBuilder.RenameIndex(
|
|
||||||
name: "IX_QuizQuestions_LessonId_Order",
|
|
||||||
table: "QuizQuestions",
|
|
||||||
newName: "IX_QuizQuestions_QuizId_Order");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "Points",
|
|
||||||
table: "QuizQuestions",
|
|
||||||
type: "integer",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: 1);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "LevelId1",
|
|
||||||
table: "Lessons",
|
|
||||||
type: "integer",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "Quizzes",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
LessonId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
|
||||||
Description = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
|
|
||||||
PassingScore = table.Column<int>(type: "integer", nullable: false, defaultValue: 80),
|
|
||||||
TimeLimitMinutes = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
|
|
||||||
IsActive = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
|
||||||
ShuffleQuestions = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_Quizzes", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Quizzes_Lessons_LessonId",
|
|
||||||
column: x => x.LessonId,
|
|
||||||
principalTable: "Lessons",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "StorySegments",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
LevelId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
LessonId = table.Column<int>(type: "integer", nullable: true),
|
|
||||||
Content = table.Column<string>(type: "text", nullable: false),
|
|
||||||
AudioUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
|
|
||||||
Order = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
|
||||||
Theme = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
|
||||||
EstimatedReadingMinutes = table.Column<int>(type: "integer", nullable: false, defaultValue: 2),
|
|
||||||
IsActive = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
|
||||||
LessonId1 = table.Column<int>(type: "integer", nullable: true),
|
|
||||||
LevelId1 = table.Column<int>(type: "integer", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_StorySegments", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StorySegments_Lessons_LessonId",
|
|
||||||
column: x => x.LessonId,
|
|
||||||
principalTable: "Lessons",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.SetNull);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StorySegments_Lessons_LessonId1",
|
|
||||||
column: x => x.LessonId1,
|
|
||||||
principalTable: "Lessons",
|
|
||||||
principalColumn: "Id");
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StorySegments_Levels_LevelId",
|
|
||||||
column: x => x.LevelId,
|
|
||||||
principalTable: "Levels",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StorySegments_Levels_LevelId1",
|
|
||||||
column: x => x.LevelId1,
|
|
||||||
principalTable: "Levels",
|
|
||||||
principalColumn: "Id");
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "StoryProgress",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
UserId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
LevelId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
StorySegmentId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
IsCompleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
|
||||||
UnlockedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
|
||||||
UserId1 = table.Column<int>(type: "integer", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_StoryProgress", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StoryProgress_Levels_LevelId",
|
|
||||||
column: x => x.LevelId,
|
|
||||||
principalTable: "Levels",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StoryProgress_StorySegments_StorySegmentId",
|
|
||||||
column: x => x.StorySegmentId,
|
|
||||||
principalTable: "StorySegments",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StoryProgress_Users_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "Users",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StoryProgress_Users_UserId1",
|
|
||||||
column: x => x.UserId1,
|
|
||||||
principalTable: "Users",
|
|
||||||
principalColumn: "Id");
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Lessons_LevelId1",
|
|
||||||
table: "Lessons",
|
|
||||||
column: "LevelId1");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Quizzes_LessonId",
|
|
||||||
table: "Quizzes",
|
|
||||||
column: "LessonId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StoryProgress_LevelId",
|
|
||||||
table: "StoryProgress",
|
|
||||||
column: "LevelId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StoryProgress_StorySegmentId",
|
|
||||||
table: "StoryProgress",
|
|
||||||
column: "StorySegmentId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StoryProgress_UserId_StorySegmentId",
|
|
||||||
table: "StoryProgress",
|
|
||||||
columns: new[] { "UserId", "StorySegmentId" },
|
|
||||||
unique: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StoryProgress_UserId1",
|
|
||||||
table: "StoryProgress",
|
|
||||||
column: "UserId1");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StorySegments_LessonId",
|
|
||||||
table: "StorySegments",
|
|
||||||
column: "LessonId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StorySegments_LessonId1",
|
|
||||||
table: "StorySegments",
|
|
||||||
column: "LessonId1");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StorySegments_LevelId_Order",
|
|
||||||
table: "StorySegments",
|
|
||||||
columns: new[] { "LevelId", "Order" },
|
|
||||||
unique: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StorySegments_LevelId1",
|
|
||||||
table: "StorySegments",
|
|
||||||
column: "LevelId1");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_Lessons_Levels_LevelId1",
|
|
||||||
table: "Lessons",
|
|
||||||
column: "LevelId1",
|
|
||||||
principalTable: "Levels",
|
|
||||||
principalColumn: "Id");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_QuizQuestions_Quizzes_QuizId",
|
|
||||||
table: "QuizQuestions",
|
|
||||||
column: "QuizId",
|
|
||||||
principalTable: "Quizzes",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropForeignKey(
|
|
||||||
name: "FK_Lessons_Levels_LevelId1",
|
|
||||||
table: "Lessons");
|
|
||||||
|
|
||||||
migrationBuilder.DropForeignKey(
|
|
||||||
name: "FK_QuizQuestions_Quizzes_QuizId",
|
|
||||||
table: "QuizQuestions");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "Quizzes");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "StoryProgress");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "StorySegments");
|
|
||||||
|
|
||||||
migrationBuilder.DropIndex(
|
|
||||||
name: "IX_Lessons_LevelId1",
|
|
||||||
table: "Lessons");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "Points",
|
|
||||||
table: "QuizQuestions");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "LevelId1",
|
|
||||||
table: "Lessons");
|
|
||||||
|
|
||||||
migrationBuilder.RenameColumn(
|
|
||||||
name: "QuizId",
|
|
||||||
table: "QuizQuestions",
|
|
||||||
newName: "LessonId");
|
|
||||||
|
|
||||||
migrationBuilder.RenameIndex(
|
|
||||||
name: "IX_QuizQuestions_QuizId_Order",
|
|
||||||
table: "QuizQuestions",
|
|
||||||
newName: "IX_QuizQuestions_LessonId_Order");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_QuizQuestions_Lessons_LessonId",
|
|
||||||
table: "QuizQuestions",
|
|
||||||
column: "LessonId",
|
|
||||||
principalTable: "Lessons",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,676 +0,0 @@
|
||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using GermanApp.Infrastructure.Data.DbContext;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AppDbContext))]
|
|
||||||
[Migration("20260614101057_AddRoleToUser")]
|
|
||||||
partial class AddRoleToUser
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "9.0.0")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LevelId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<string>("Topic")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Lessons");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("Code")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(10)
|
|
||||||
.HasColumnType("character varying(10)");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Code")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Levels");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("PassingScore")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(80);
|
|
||||||
|
|
||||||
b.Property<bool>("ShuffleQuestions")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("TimeLimitMinutes")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.ToTable("Quizzes");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<bool>("IsCorrect")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<int>("QuizQuestionId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Text")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(1000)
|
|
||||||
.HasColumnType("character varying(1000)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("QuizQuestionId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("QuizOptions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("CorrectAnswer")
|
|
||||||
.HasMaxLength(1000)
|
|
||||||
.HasColumnType("character varying(1000)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("Difficulty")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(3);
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<int>("Points")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<string>("QuestionText")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<int>("QuizId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Type")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("QuizId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("QuizQuestions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime>("ExpiresAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<DateTime?>("RevokedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Token")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("RefreshTokens");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StoryProgress", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime?>("CompletedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsCompleted")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("StorySegmentId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UnlockedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("UserId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId");
|
|
||||||
|
|
||||||
b.HasIndex("StorySegmentId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId1");
|
|
||||||
|
|
||||||
b.HasIndex("UserId", "StorySegmentId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("StoryProgress");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StorySegment", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("AudioUrl")
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<string>("Content")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("EstimatedReadingMinutes")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(2);
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int?>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LessonId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LevelId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Theme")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("CurrentLevel")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasMaxLength(10)
|
|
||||||
.HasColumnType("character varying(10)")
|
|
||||||
.HasDefaultValue("A1");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<string>("Role")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasMaxLength(20)
|
|
||||||
.HasColumnType("character varying(20)")
|
|
||||||
.HasDefaultValue("User");
|
|
||||||
|
|
||||||
b.Property<int>("Streak")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<int>("TotalPoints")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<string>("Username")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Email")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("Username")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Users");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<bool>("IsCompleted")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<DateTime>("LastAttemptDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("QuizScore")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId", "LessonId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("UserProgress");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", null)
|
|
||||||
.WithMany("Lessons")
|
|
||||||
.HasForeignKey("LevelId1");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.QuizQuestion", "QuizQuestion")
|
|
||||||
.WithMany("Options")
|
|
||||||
.HasForeignKey("QuizQuestionId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("QuizQuestion");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Quiz", "Quiz")
|
|
||||||
.WithMany("Questions")
|
|
||||||
.HasForeignKey("QuizId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Quiz");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StoryProgress", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.StorySegment", "StorySegment")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("StorySegmentId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", "User")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
|
||||||
.WithMany("StoryProgress")
|
|
||||||
.HasForeignKey("UserId1");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
|
|
||||||
b.Navigation("StorySegment");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StorySegment", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", null)
|
|
||||||
.WithMany("StorySegments")
|
|
||||||
.HasForeignKey("LessonId1");
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", null)
|
|
||||||
.WithMany("StorySegments")
|
|
||||||
.HasForeignKey("LevelId1");
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", "User")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Lessons");
|
|
||||||
|
|
||||||
b.Navigation("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Questions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Options");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("StoryProgress");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddRoleToUser : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "Role",
|
|
||||||
table: "Users",
|
|
||||||
type: "character varying(20)",
|
|
||||||
maxLength: 20,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "User");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "Role",
|
|
||||||
table: "Users");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -46,9 +46,6 @@ namespace GermanApp.Migrations
|
||||||
b.Property<int>("LevelId")
|
b.Property<int>("LevelId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int?>("LevelId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
b.Property<int>("Order")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
|
@ -67,8 +64,6 @@ namespace GermanApp.Migrations
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("LevelId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId", "Order")
|
b.HasIndex("LevelId", "Order")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
|
@ -107,150 +102,6 @@ namespace GermanApp.Migrations
|
||||||
b.ToTable("Levels");
|
b.ToTable("Levels");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("PassingScore")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(80);
|
|
||||||
|
|
||||||
b.Property<bool>("ShuffleQuestions")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("TimeLimitMinutes")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.ToTable("Quizzes");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<bool>("IsCorrect")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<int>("QuizQuestionId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Text")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(1000)
|
|
||||||
.HasColumnType("character varying(1000)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("QuizQuestionId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("QuizOptions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("CorrectAnswer")
|
|
||||||
.HasMaxLength(1000)
|
|
||||||
.HasColumnType("character varying(1000)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("Difficulty")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(3);
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<int>("Points")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<string>("QuestionText")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<int>("QuizId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Type")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("QuizId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("QuizQuestions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -288,128 +139,6 @@ namespace GermanApp.Migrations
|
||||||
b.ToTable("RefreshTokens");
|
b.ToTable("RefreshTokens");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StoryProgress", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime?>("CompletedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsCompleted")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("StorySegmentId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UnlockedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("UserId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId");
|
|
||||||
|
|
||||||
b.HasIndex("StorySegmentId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId1");
|
|
||||||
|
|
||||||
b.HasIndex("UserId", "StorySegmentId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("StoryProgress");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StorySegment", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("AudioUrl")
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<string>("Content")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("EstimatedReadingMinutes")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(2);
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int?>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LessonId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LevelId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Theme")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -438,13 +167,6 @@ namespace GermanApp.Migrations
|
||||||
.HasMaxLength(255)
|
.HasMaxLength(255)
|
||||||
.HasColumnType("character varying(255)");
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
b.Property<string>("Role")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasMaxLength(20)
|
|
||||||
.HasColumnType("character varying(20)")
|
|
||||||
.HasDefaultValue("User");
|
|
||||||
|
|
||||||
b.Property<int>("Streak")
|
b.Property<int>("Streak")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
|
|
@ -516,46 +238,9 @@ namespace GermanApp.Migrations
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", null)
|
|
||||||
.WithMany("Lessons")
|
|
||||||
.HasForeignKey("LevelId1");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
b.Navigation("Level");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.QuizQuestion", "QuizQuestion")
|
|
||||||
.WithMany("Options")
|
|
||||||
.HasForeignKey("QuizQuestionId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("QuizQuestion");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Quiz", "Quiz")
|
|
||||||
.WithMany("Questions")
|
|
||||||
.HasForeignKey("QuizId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Quiz");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
b.HasOne("GermanApp.Domain.Entities.User", null)
|
||||||
|
|
@ -565,63 +250,6 @@ namespace GermanApp.Migrations
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StoryProgress", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.StorySegment", "StorySegment")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("StorySegmentId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", "User")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
|
||||||
.WithMany("StoryProgress")
|
|
||||||
.HasForeignKey("UserId1");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
|
|
||||||
b.Navigation("StorySegment");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StorySegment", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", null)
|
|
||||||
.WithMany("StorySegments")
|
|
||||||
.HasForeignKey("LessonId1");
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", null)
|
|
||||||
.WithMany("StorySegments")
|
|
||||||
.HasForeignKey("LevelId1");
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
||||||
|
|
@ -640,33 +268,6 @@ namespace GermanApp.Migrations
|
||||||
|
|
||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Lessons");
|
|
||||||
|
|
||||||
b.Navigation("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Questions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Options");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("StoryProgress");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using GermanApp.Infrastructure.Data.DbContext;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Repositories;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entity Framework Core implementation of IQuizOptionRepository.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class QuizOptionRepository : IQuizOptionRepository
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _context;
|
|
||||||
|
|
||||||
public QuizOptionRepository(AppDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<QuizOption?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizOptions
|
|
||||||
.Include(o => o.QuizQuestion)
|
|
||||||
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizOption>> GetAllAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizOptions
|
|
||||||
.Include(o => o.QuizQuestion)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<QuizOption> AddAsync(QuizOption entity, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await _context.QuizOptions.AddAsync(entity, cancellationToken);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateAsync(QuizOption entity, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_context.QuizOptions.Update(entity);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteAsync(QuizOption entity, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_context.QuizOptions.Remove(entity);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizOptions
|
|
||||||
.AnyAsync(o => o.Id == id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizOption>> GetByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizOptions
|
|
||||||
.Include(o => o.QuizQuestion)
|
|
||||||
.Where(o => o.QuizQuestionId == questionId)
|
|
||||||
.OrderBy(o => o.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<QuizOption?> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizOptions
|
|
||||||
.Include(o => o.QuizQuestion)
|
|
||||||
.Where(o => o.QuizQuestionId == questionId && o.IsCorrect)
|
|
||||||
.OrderBy(o => o.Order)
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizOption>> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizOptions
|
|
||||||
.Include(o => o.QuizQuestion)
|
|
||||||
.Where(o => o.QuizQuestionId == questionId && o.IsCorrect)
|
|
||||||
.OrderBy(o => o.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var options = await _context.QuizOptions
|
|
||||||
.Where(o => o.QuizQuestionId == questionId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
_context.QuizOptions.RemoveRange(options);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> OrderExistsForQuestionAsync(int questionId, int order, int? excludeId = null, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var query = _context.QuizOptions
|
|
||||||
.Where(o => o.QuizQuestionId == questionId && o.Order == order);
|
|
||||||
|
|
||||||
if (excludeId.HasValue)
|
|
||||||
{
|
|
||||||
query = query.Where(o => o.Id != excludeId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await query.AnyAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using GermanApp.Infrastructure.Data.DbContext;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Repositories;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entity Framework Core implementation of IQuizQuestionRepository.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class QuizQuestionRepository : IQuizQuestionRepository
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _context;
|
|
||||||
|
|
||||||
public QuizQuestionRepository(AppDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<QuizQuestion?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.ThenInclude(q => q.Lesson)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.FirstOrDefaultAsync(q => q.Id == id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizQuestion>> GetAllAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.ThenInclude(q => q.Lesson)
|
|
||||||
.Include(q => q.Options)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<QuizQuestion> AddAsync(QuizQuestion entity, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await _context.QuizQuestions.AddAsync(entity, cancellationToken);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateAsync(QuizQuestion entity, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_context.QuizQuestions.Update(entity);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteAsync(QuizQuestion entity, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_context.QuizQuestions.Remove(entity);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.AnyAsync(q => q.Id == id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizQuestion>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.ThenInclude(q => q.Lesson)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.Where(q => q.Quiz != null && q.Quiz.LessonId == lessonId)
|
|
||||||
.OrderBy(q => q.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizQuestion>> GetAllOrderedAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.ThenInclude(q => q.Lesson)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.OrderBy(q => q.Quiz.Lesson.Order)
|
|
||||||
.ThenBy(q => q.Quiz.Id)
|
|
||||||
.ThenBy(q => q.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizQuestion>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.ThenInclude(q => q.Lesson)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.Where(q => q.Quiz != null && q.Quiz.LessonId == lessonId && q.IsActive)
|
|
||||||
.OrderBy(q => q.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.ThenInclude(q => q.Lesson)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.Where(q => q.Quiz != null && q.Quiz.LessonId == lessonId && q.IsActive)
|
|
||||||
.OrderBy(q => Guid.NewGuid()) // Random ordering
|
|
||||||
.Take(count)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizQuestion>> GetByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.Include(q => q.Options)
|
|
||||||
.Where(q => q.Difficulty == difficulty)
|
|
||||||
.OrderBy(q => q.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizQuestion>> GetByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.Include(q => q.Options)
|
|
||||||
.Where(q => q.Type == type)
|
|
||||||
.OrderBy(q => q.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> OrderExistsInLessonAsync(int lessonId, int order, int? excludeId = null, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var query = _context.QuizQuestions
|
|
||||||
.Where(q => q.Quiz != null && q.Quiz.LessonId == lessonId && q.Order == order);
|
|
||||||
|
|
||||||
if (excludeId.HasValue)
|
|
||||||
{
|
|
||||||
query = query.Where(q => q.Id != excludeId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await query.AnyAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Quiz-based methods (QuizId)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizQuestion>> GetByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.Where(q => q.QuizId == quizId)
|
|
||||||
.OrderBy(q => q.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizQuestion>> GetActiveByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.Where(q => q.QuizId == quizId && q.IsActive)
|
|
||||||
.OrderBy(q => q.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizQuestion>> GetByQuizWithOptionsAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.Where(q => q.QuizId == quizId)
|
|
||||||
.OrderBy(q => q.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuizQuestion>> GetRandomQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.QuizQuestions
|
|
||||||
.Include(q => q.Quiz)
|
|
||||||
.Include(q => q.Options.OrderBy(o => o.Order))
|
|
||||||
.Where(q => q.QuizId == quizId && q.IsActive)
|
|
||||||
.OrderBy(q => Guid.NewGuid()) // Random ordering
|
|
||||||
.Take(count)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _context.QuizQuestions
|
|
||||||
.Where(q => q.QuizId == quizId && q.IsActive)
|
|
||||||
.Select(q => q.Points)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
return questions.Sum();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> OrderExistsInQuizAsync(int quizId, int order, int? excludeId = null, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var query = _context.QuizQuestions
|
|
||||||
.Where(q => q.QuizId == quizId && q.Order == order);
|
|
||||||
|
|
||||||
if (excludeId.HasValue)
|
|
||||||
{
|
|
||||||
query = query.Where(q => q.Id != excludeId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await query.AnyAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using GermanApp.Infrastructure.Data.DbContext;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Repositories;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entity Framework Core implementation of IQuizRepository.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class QuizRepository : IQuizRepository
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _context;
|
|
||||||
|
|
||||||
public QuizRepository(AppDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Quiz?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.Include(q => q.Lesson)
|
|
||||||
.FirstOrDefaultAsync(q => q.Id == id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Quiz>> GetAllAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.Include(q => q.Lesson)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Quiz> AddAsync(Quiz entity, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await _context.Quizzes.AddAsync(entity, cancellationToken);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateAsync(Quiz entity, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_context.Quizzes.Update(entity);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteAsync(Quiz entity, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_context.Quizzes.Remove(entity);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.AnyAsync(q => q.Id == id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Quiz>> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.Include(q => q.Lesson)
|
|
||||||
.Where(q => q.LessonId == lessonId)
|
|
||||||
.OrderBy(q => q.Id)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Quiz>> GetAllOrderedAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.Include(q => q.Lesson)
|
|
||||||
.OrderBy(q => q.Lesson.Order)
|
|
||||||
.ThenBy(q => q.Id)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Quiz>> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.Include(q => q.Lesson)
|
|
||||||
.Where(q => q.LessonId == lessonId && q.IsActive)
|
|
||||||
.OrderBy(q => q.Id)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Quiz?> GetWithQuestionsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.Include(q => q.Lesson)
|
|
||||||
.Include(q => q.Questions.OrderBy(qq => qq.Order))
|
|
||||||
.ThenInclude(qq => qq.Options.OrderBy(o => o.Order))
|
|
||||||
.FirstOrDefaultAsync(q => q.Id == id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Quiz?> GetFirstByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.Include(q => q.Lesson)
|
|
||||||
.Where(q => q.LessonId == lessonId && q.IsActive)
|
|
||||||
.OrderBy(q => q.Id)
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Quizzes
|
|
||||||
.AnyAsync(q => q.LessonId == lessonId, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using GermanApp.Infrastructure.Data.DbContext;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Repositories;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entity Framework Core implementation of IStoryProgressRepository.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class StoryProgressRepository : IStoryProgressRepository
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _context;
|
|
||||||
|
|
||||||
public StoryProgressRepository(AppDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<StoryProgress>> GetByUserAndLevelAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.StoryProgress
|
|
||||||
.Where(p => p.UserId == userId && p.LevelId == levelId)
|
|
||||||
.Include(p => p.StorySegment)
|
|
||||||
.OrderBy(p => p.StorySegment != null ? p.StorySegment.Order : 0)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<StoryProgress?> GetByUserAndSegmentAsync(
|
|
||||||
int userId,
|
|
||||||
int storySegmentId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.StoryProgress
|
|
||||||
.FirstOrDefaultAsync(
|
|
||||||
p => p.UserId == userId && p.StorySegmentId == storySegmentId,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> GetHighestUnlockedOrderAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var highestOrder = await _context.StoryProgress
|
|
||||||
.Where(p => p.UserId == userId && p.LevelId == levelId)
|
|
||||||
.Include(p => p.StorySegment)
|
|
||||||
.Select(p => p.StorySegment != null ? p.StorySegment.Order : 0)
|
|
||||||
.MaxAsync(cancellationToken);
|
|
||||||
|
|
||||||
return highestOrder;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> IsSegmentUnlockedAsync(
|
|
||||||
int userId,
|
|
||||||
int storySegmentId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.StoryProgress
|
|
||||||
.AnyAsync(
|
|
||||||
p => p.UserId == userId && p.StorySegmentId == storySegmentId,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> IsSegmentCompletedAsync(
|
|
||||||
int userId,
|
|
||||||
int storySegmentId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.StoryProgress
|
|
||||||
.AnyAsync(
|
|
||||||
p => p.UserId == userId
|
|
||||||
&& p.StorySegmentId == storySegmentId
|
|
||||||
&& p.IsCompleted,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<StoryProgress> AddAsync(StoryProgress progress, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await _context.StoryProgress.AddAsync(progress, cancellationToken);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
return progress;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateAsync(StoryProgress progress, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_context.StoryProgress.Update(progress);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> GetTotalSegmentsForLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.StorySegments
|
|
||||||
.CountAsync(s => s.LevelId == levelId && s.IsActive, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> GetUnlockedCountAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.StoryProgress
|
|
||||||
.CountAsync(
|
|
||||||
p => p.UserId == userId && p.LevelId == levelId,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> GetCompletedCountAsync(
|
|
||||||
int userId,
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.StoryProgress
|
|
||||||
.CountAsync(
|
|
||||||
p => p.UserId == userId && p.LevelId == levelId && p.IsCompleted,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using GermanApp.Infrastructure.Data.DbContext;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Repositories;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entity Framework Core implementation of IStoryRepository.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class StoryRepository : IStoryRepository
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _context;
|
|
||||||
|
|
||||||
public StoryRepository(AppDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<StorySegment?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.StorySegments
|
|
||||||
.Include(s => s.Level)
|
|
||||||
.Include(s => s.Lesson)
|
|
||||||
.FirstOrDefaultAsync(s => s.Id == id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<StorySegment>> GetByLevelAsync(
|
|
||||||
int levelId,
|
|
||||||
bool includeInactive = false,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var query = _context.StorySegments
|
|
||||||
.Where(s => s.LevelId == levelId);
|
|
||||||
|
|
||||||
if (!includeInactive)
|
|
||||||
{
|
|
||||||
query = query.Where(s => s.IsActive);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await query
|
|
||||||
.Include(s => s.Level)
|
|
||||||
.Include(s => s.Lesson)
|
|
||||||
.OrderBy(s => s.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<StorySegment>> GetByLessonAsync(
|
|
||||||
int lessonId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.StorySegments
|
|
||||||
.Where(s => s.LessonId == lessonId)
|
|
||||||
.Include(s => s.Level)
|
|
||||||
.Include(s => s.Lesson)
|
|
||||||
.OrderBy(s => s.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<StorySegment?> GetNextSegmentToUnlockAsync(
|
|
||||||
int levelId,
|
|
||||||
int completedLessonOrder,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Get the next lesson order (the one after the completed lesson)
|
|
||||||
// Then find the story segment with matching Lesson.Order
|
|
||||||
var nextLessonOrder = completedLessonOrder + 1;
|
|
||||||
|
|
||||||
return await _context.StorySegments
|
|
||||||
.Where(s => s.LevelId == levelId && s.LessonId != null && s.IsActive)
|
|
||||||
.Where(s => s.Lesson != null && s.Lesson.Order == nextLessonOrder)
|
|
||||||
.Include(s => s.Lesson)
|
|
||||||
.OrderBy(s => s.Order)
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<StorySegment>> GetByOrderRangeAsync(
|
|
||||||
int levelId,
|
|
||||||
int startOrder,
|
|
||||||
int endOrder,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.StorySegments
|
|
||||||
.Where(s => s.LevelId == levelId
|
|
||||||
&& s.Order >= startOrder
|
|
||||||
&& s.Order <= endOrder
|
|
||||||
&& s.IsActive)
|
|
||||||
.Include(s => s.Level)
|
|
||||||
.Include(s => s.Lesson)
|
|
||||||
.OrderBy(s => s.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<StorySegment> AddAsync(StorySegment segment, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await _context.StorySegments.AddAsync(segment, cancellationToken);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
return segment;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateAsync(StorySegment segment, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_context.StorySegments.Update(segment);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var segment = await _context.StorySegments.FindAsync(new object[] { id }, cancellationToken);
|
|
||||||
if (segment != null)
|
|
||||||
{
|
|
||||||
_context.StorySegments.Remove(segment);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> GetMaxOrderAsync(int levelId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var maxOrder = await _context.StorySegments
|
|
||||||
.Where(s => s.LevelId == levelId)
|
|
||||||
.Select(s => s.Order)
|
|
||||||
.MaxAsync(cancellationToken);
|
|
||||||
|
|
||||||
return maxOrder;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.StorySegments
|
|
||||||
.AnyAsync(s => s.Id == id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<StorySegment>> GetSegmentsNeedingAudioAsync(
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.StorySegments
|
|
||||||
.Where(s => string.IsNullOrEmpty(s.AudioUrl) && s.IsActive)
|
|
||||||
.Include(s => s.Level)
|
|
||||||
.Include(s => s.Lesson)
|
|
||||||
.OrderBy(s => s.LevelId)
|
|
||||||
.ThenBy(s => s.Order)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using GermanApp.Infrastructure.Data.DbContext;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Repositories;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entity Framework Core implementation of IRepository<User, int>.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class UserRepository : IRepository<User, int>
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _context;
|
|
||||||
|
|
||||||
public UserRepository(AppDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<User?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Users
|
|
||||||
.Include(u => u.StoryProgress)
|
|
||||||
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<User>> GetAllAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Users
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<User> AddAsync(User entity, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await _context.Users.AddAsync(entity, cancellationToken);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateAsync(User entity, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_context.Users.Update(entity);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteAsync(User entity, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_context.Users.Remove(entity);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _context.Users
|
|
||||||
.AnyAsync(u => u.Id == id, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
using GermanApp.Domain.Entities;
|
using GermanApp.Domain.Entities;
|
||||||
using GermanApp.Infrastructure.Data.DbContext;
|
using GermanApp.Infrastructure.Data.DbContext;
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.SeedData;
|
namespace GermanApp.Infrastructure.Data.SeedData;
|
||||||
|
|
@ -25,17 +24,6 @@ public static class SeedDataExtension
|
||||||
// Only seed if there are no users
|
// Only seed if there are no users
|
||||||
if (!dbContext.Users.Any())
|
if (!dbContext.Users.Any())
|
||||||
{
|
{
|
||||||
// Get password hasher from service provider
|
|
||||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<User>>();
|
|
||||||
|
|
||||||
// Create temporary users for hashing (we need actual User objects)
|
|
||||||
var tempAdminUser = User.Create("admin", "admin@deutschlernen.com", "");
|
|
||||||
var tempTestUser = User.Create("testuser", "test@deutschlernen.com", "");
|
|
||||||
|
|
||||||
// Hash passwords using ASP.NET Core Identity's PasswordHasher
|
|
||||||
var adminPasswordHash = passwordHasher.HashPassword(tempAdminUser, "Admin@123!");
|
|
||||||
var testPasswordHash = passwordHasher.HashPassword(tempTestUser, "Test@123!");
|
|
||||||
|
|
||||||
// Seed CEFR levels first
|
// Seed CEFR levels first
|
||||||
var levels = new[]
|
var levels = new[]
|
||||||
{
|
{
|
||||||
|
|
@ -48,14 +36,21 @@ public static class SeedDataExtension
|
||||||
dbContext.Levels.AddRange(levels);
|
dbContext.Levels.AddRange(levels);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
// Seed an admin user with properly hashed password
|
// Seed an admin user
|
||||||
var adminUser = User.Create("admin", "admin@deutschlernen.com", adminPasswordHash);
|
var adminUser = User.Create(
|
||||||
adminUser.AssignAdminRole(); // Set role to Admin
|
"admin",
|
||||||
|
"admin@deutschlernen.com",
|
||||||
|
"$2a$11$N9qo8uLOickgx2ZMRZoMy.Mrq9Hq4yVJyX2sX7z3J0J9z6J9Z2K4a" // bcrypt hash of "Admin@123!"
|
||||||
|
);
|
||||||
adminUser.UpdateLevel("C1");
|
adminUser.UpdateLevel("C1");
|
||||||
dbContext.Users.Add(adminUser);
|
dbContext.Users.Add(adminUser);
|
||||||
|
|
||||||
// Seed a test user with properly hashed password
|
// Seed a test user
|
||||||
var testUser = User.Create("testuser", "test@deutschlernen.com", testPasswordHash);
|
var testUser = User.Create(
|
||||||
|
"testuser",
|
||||||
|
"test@deutschlernen.com",
|
||||||
|
"$2a$11$N9qo8uLOickgx2ZMRZoMy.Mrq9Hq4yVJyX2sX7z3J0J9z6J9Z2K4a" // bcrypt hash of "Test@123!"
|
||||||
|
);
|
||||||
dbContext.Users.Add(testUser);
|
dbContext.Users.Add(testUser);
|
||||||
|
|
||||||
// Seed some lessons
|
// Seed some lessons
|
||||||
|
|
@ -71,47 +66,7 @@ public static class SeedDataExtension
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
// Seed quiz questions for the lessons
|
Console.WriteLine("Database seeded with levels, admin user, test user, and sample lessons.");
|
||||||
dbContext.QuizQuestions.AddRange(new[]
|
|
||||||
{
|
|
||||||
// Questions for Greetings lesson (Lesson 1)
|
|
||||||
QuizQuestion.Create(1, "What is the German word for 'Hello'?", QuestionType.FillInTheBlank, "Hallo", 1, 1),
|
|
||||||
QuizQuestion.Create(1, "How do you say 'Good morning' in German?", QuestionType.FillInTheBlank, "Guten Morgen", 2, 2),
|
|
||||||
QuizQuestion.Create(1, "What is the German word for 'Goodbye'?", QuestionType.MultipleChoice, "Auf Wiedersehen", 1, 3),
|
|
||||||
// Questions for Numbers lesson (Lesson 2)
|
|
||||||
QuizQuestion.Create(2, "What is '1' in German?", QuestionType.FillInTheBlank, "eins", 1, 1),
|
|
||||||
QuizQuestion.Create(2, "What is '10' in German?", QuestionType.FillInTheBlank, "zehn", 2, 2),
|
|
||||||
QuizQuestion.Create(2, "Which number is 'zwanzig'?", QuestionType.MultipleChoice, "20", 2, 3),
|
|
||||||
// Questions for Grammar Basics lesson (Lesson 3)
|
|
||||||
QuizQuestion.Create(3, "Is 'Der Mann' masculine?", QuestionType.TrueFalse, "True", 3, 1),
|
|
||||||
QuizQuestion.Create(3, "What is the article for 'Frau'?", QuestionType.MultipleChoice, "die", 2, 2)
|
|
||||||
});
|
|
||||||
await dbContext.SaveChangesAsync();
|
|
||||||
|
|
||||||
// Get the quiz question IDs after save
|
|
||||||
var quizQuestions = await dbContext.QuizQuestions.OrderBy(q => q.Id).ToListAsync();
|
|
||||||
|
|
||||||
// Add options for multiple choice questions
|
|
||||||
// greetingsQ3 is ID 3 (3rd question created)
|
|
||||||
dbContext.QuizOptions.AddRange(new[]
|
|
||||||
{
|
|
||||||
QuizOption.Create(quizQuestions[2].Id, "Hallo", false, 1),
|
|
||||||
QuizOption.Create(quizQuestions[2].Id, "Danke", false, 2),
|
|
||||||
QuizOption.Create(quizQuestions[2].Id, "Auf Wiedersehen", true, 3),
|
|
||||||
QuizOption.Create(quizQuestions[2].Id, "Bitte", false, 4),
|
|
||||||
// Numbers Q3 is ID 6
|
|
||||||
QuizOption.Create(quizQuestions[5].Id, "10", false, 1),
|
|
||||||
QuizOption.Create(quizQuestions[5].Id, "20", true, 2),
|
|
||||||
QuizOption.Create(quizQuestions[5].Id, "30", false, 3),
|
|
||||||
QuizOption.Create(quizQuestions[5].Id, "100", false, 4),
|
|
||||||
// Grammar Q2 is ID 8
|
|
||||||
QuizOption.Create(quizQuestions[7].Id, "der", false, 1),
|
|
||||||
QuizOption.Create(quizQuestions[7].Id, "die", true, 2),
|
|
||||||
QuizOption.Create(quizQuestions[7].Id, "das", false, 3)
|
|
||||||
});
|
|
||||||
await dbContext.SaveChangesAsync();
|
|
||||||
|
|
||||||
Console.WriteLine("Database seeded with levels, admin user, test user, sample lessons, and quiz questions.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Health check for AI services (Mistral, Vosk, Coqui TTS).
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class AiServicesHealthCheck : IHealthCheck
|
|
||||||
{
|
|
||||||
private readonly IMistralService? _mistralService;
|
|
||||||
private readonly IVoskService? _voskService;
|
|
||||||
private readonly ITtsService? _ttsService;
|
|
||||||
private readonly ILogger<AiServicesHealthCheck> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new AI services health check.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="mistralService">The Mistral text generation service (optional, may be null in some environments)</param>
|
|
||||||
/// <param name="voskService">The Vosk speech recognition service (optional, may be null in some environments)</param>
|
|
||||||
/// <param name="ttsService">The Coqui TTS service (optional, may be null in some environments)</param>
|
|
||||||
/// <param name="logger">Logger for health check operations</param>
|
|
||||||
public AiServicesHealthCheck(
|
|
||||||
IMistralService? mistralService,
|
|
||||||
IVoskService? voskService,
|
|
||||||
ITtsService? ttsService,
|
|
||||||
ILogger<AiServicesHealthCheck> logger)
|
|
||||||
{
|
|
||||||
_mistralService = mistralService;
|
|
||||||
_voskService = voskService;
|
|
||||||
_ttsService = ttsService;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Executes the health check for all AI services.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
|
||||||
HealthCheckContext context,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var checks = new Dictionary<string, HealthStatus>();
|
|
||||||
var exceptions = new Dictionary<string, Exception>();
|
|
||||||
|
|
||||||
// Check Mistral service
|
|
||||||
if (_mistralService != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var isHealthy = await _mistralService.TestConnectionAsync(cancellationToken);
|
|
||||||
checks["Mistral"] = isHealthy ? HealthStatus.Healthy : HealthStatus.Unhealthy;
|
|
||||||
if (!isHealthy)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Mistral service health check failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
checks["Mistral"] = HealthStatus.Unhealthy;
|
|
||||||
exceptions["Mistral"] = ex;
|
|
||||||
_logger.LogError(ex, "Mistral service health check failed with exception");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
checks["Mistral"] = HealthStatus.Degraded;
|
|
||||||
_logger.LogWarning("Mistral service is not registered");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Vosk service
|
|
||||||
if (_voskService != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var isHealthy = await _voskService.TestModelAsync(cancellationToken);
|
|
||||||
checks["Vosk"] = isHealthy ? HealthStatus.Healthy : HealthStatus.Unhealthy;
|
|
||||||
if (!isHealthy)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Vosk service health check failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
checks["Vosk"] = HealthStatus.Unhealthy;
|
|
||||||
exceptions["Vosk"] = ex;
|
|
||||||
_logger.LogError(ex, "Vosk service health check failed with exception");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
checks["Vosk"] = HealthStatus.Degraded;
|
|
||||||
_logger.LogWarning("Vosk service is not registered");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Coqui TTS service
|
|
||||||
if (_ttsService != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var isHealthy = await _ttsService.TestModelAsync(cancellationToken);
|
|
||||||
checks["Coqui TTS"] = isHealthy ? HealthStatus.Healthy : HealthStatus.Unhealthy;
|
|
||||||
if (!isHealthy)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Coqui TTS service health check failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
checks["Coqui TTS"] = HealthStatus.Unhealthy;
|
|
||||||
exceptions["Coqui TTS"] = ex;
|
|
||||||
_logger.LogError(ex, "Coqui TTS service health check failed with exception");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
checks["Coqui TTS"] = HealthStatus.Degraded;
|
|
||||||
_logger.LogWarning("Coqui TTS service is not registered");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine overall status
|
|
||||||
var allHealthy = checks.Values.All(s => s == HealthStatus.Healthy);
|
|
||||||
var anyUnhealthy = checks.Values.Any(s => s == HealthStatus.Unhealthy);
|
|
||||||
var anyDegraded = checks.Values.Any(s => s == HealthStatus.Degraded);
|
|
||||||
|
|
||||||
HealthStatus overallStatus = allHealthy
|
|
||||||
? HealthStatus.Healthy
|
|
||||||
: anyUnhealthy
|
|
||||||
? HealthStatus.Unhealthy
|
|
||||||
: HealthStatus.Degraded;
|
|
||||||
|
|
||||||
// Build data dictionary with details
|
|
||||||
var data = new Dictionary<string, object>();
|
|
||||||
foreach (var check in checks)
|
|
||||||
{
|
|
||||||
data[check.Key] = new
|
|
||||||
{
|
|
||||||
Status = check.Value.ToString(),
|
|
||||||
Exception = exceptions.TryGetValue(check.Key, out var ex) ? ex.Message : null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return new HealthCheckResult(
|
|
||||||
overallStatus,
|
|
||||||
"AI Services health check",
|
|
||||||
data: data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -49,12 +49,11 @@ public class AuthService : IAuthService
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<AuthResponse> RegisterAsync(RegisterDto registerDto)
|
public async Task<AuthResponse> RegisterAsync(RegisterDto registerDto)
|
||||||
{
|
{
|
||||||
// Check if username or email already exists (case-insensitive for email)
|
// Check if username or email already exists
|
||||||
if (await _dbContext.Users.AnyAsync(u => u.Username == registerDto.Username))
|
if (await _dbContext.Users.AnyAsync(u => u.Username == registerDto.Username))
|
||||||
throw new InvalidOperationException("Username already taken");
|
throw new InvalidOperationException("Username already taken");
|
||||||
|
|
||||||
var normalizedEmail = registerDto.Email?.ToLowerInvariant() ?? string.Empty;
|
if (await _dbContext.Users.AnyAsync(u => u.Email == registerDto.Email))
|
||||||
if (await _dbContext.Users.AnyAsync(u => u.Email == normalizedEmail))
|
|
||||||
throw new InvalidOperationException("Email already in use");
|
throw new InvalidOperationException("Email already in use");
|
||||||
|
|
||||||
// Hash password and create user
|
// Hash password and create user
|
||||||
|
|
@ -79,7 +78,6 @@ public class AuthService : IAuthService
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
Username = user.Username,
|
Username = user.Username,
|
||||||
Email = user.Email,
|
Email = user.Email,
|
||||||
Role = user.Role,
|
|
||||||
Token = token,
|
Token = token,
|
||||||
RefreshToken = refreshTokenString,
|
RefreshToken = refreshTokenString,
|
||||||
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
||||||
|
|
@ -91,10 +89,7 @@ public class AuthService : IAuthService
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<AuthResponse> LoginAsync(LoginDto loginDto)
|
public async Task<AuthResponse> LoginAsync(LoginDto loginDto)
|
||||||
{
|
{
|
||||||
// Normalize email to lowercase for case-insensitive matching
|
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Email == loginDto.Email);
|
||||||
// Emails are stored in lowercase in the database
|
|
||||||
var normalizedEmail = loginDto.Email?.ToLowerInvariant() ?? string.Empty;
|
|
||||||
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Email == normalizedEmail);
|
|
||||||
|
|
||||||
if (user == null)
|
if (user == null)
|
||||||
throw new UnauthorizedAccessException("Invalid email or password");
|
throw new UnauthorizedAccessException("Invalid email or password");
|
||||||
|
|
@ -128,7 +123,6 @@ public class AuthService : IAuthService
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
Username = user.Username,
|
Username = user.Username,
|
||||||
Email = user.Email,
|
Email = user.Email,
|
||||||
Role = user.Role,
|
|
||||||
Token = token,
|
Token = token,
|
||||||
RefreshToken = refreshTokenString,
|
RefreshToken = refreshTokenString,
|
||||||
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
||||||
|
|
@ -153,15 +147,12 @@ public class AuthService : IAuthService
|
||||||
|
|
||||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
// Use JWT standard claims for better compatibility
|
|
||||||
// "sub" = subject (user identifier), "name" = username, "email" = email, "role" = user role
|
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), // JWT standard: sub for subject/user ID
|
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
new Claim(JwtRegisteredClaimNames.Name, user.Username),
|
new Claim(ClaimTypes.Name, user.Username),
|
||||||
new Claim(JwtRegisteredClaimNames.Email, user.Email),
|
new Claim(ClaimTypes.Email, user.Email),
|
||||||
new Claim(JwtRegisteredClaimNames.UniqueName, user.Username), // Additional: unique name
|
new Claim(ClaimTypes.Role, "User")
|
||||||
new Claim(ClaimTypes.Role, user.Role) // Use user's actual role (User or Admin)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var token = new JwtSecurityToken(
|
var token = new JwtSecurityToken(
|
||||||
|
|
@ -236,66 +227,4 @@ public class AuthService : IAuthService
|
||||||
storedToken.Revoke();
|
storedToken.Revoke();
|
||||||
await _dbContext.SaveChangesAsync();
|
await _dbContext.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates the first admin user (bootstrap).
|
|
||||||
/// This is a special method that creates an admin user without requiring authentication.
|
|
||||||
/// Only works if no admin user exists yet.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="registerDto">Admin user registration data</param>
|
|
||||||
/// <returns>Authentication response with token</returns>
|
|
||||||
public async Task<AuthResponse> CreateAdminUserAsync(RegisterDto registerDto)
|
|
||||||
{
|
|
||||||
// Check if any admin user already exists
|
|
||||||
if (await _dbContext.Users.AnyAsync(u => u.Role == "Admin"))
|
|
||||||
throw new InvalidOperationException("Admin user already exists. Bootstrap endpoint can only be used once.");
|
|
||||||
|
|
||||||
// Check if username or email already exists (case-insensitive for email)
|
|
||||||
if (await _dbContext.Users.AnyAsync(u => u.Username == registerDto.Username))
|
|
||||||
throw new InvalidOperationException("Username already taken");
|
|
||||||
|
|
||||||
var normalizedEmail = registerDto.Email?.ToLowerInvariant() ?? string.Empty;
|
|
||||||
if (await _dbContext.Users.AnyAsync(u => u.Email == normalizedEmail))
|
|
||||||
throw new InvalidOperationException("Email already in use");
|
|
||||||
|
|
||||||
// Hash password and create user
|
|
||||||
var user = User.Create(registerDto.Username, registerDto.Email.ToLowerInvariant(), string.Empty);
|
|
||||||
var passwordHash = _passwordHasher.HashPassword(user, registerDto.Password);
|
|
||||||
user.ChangePassword(passwordHash);
|
|
||||||
|
|
||||||
// Assign admin role
|
|
||||||
user.AssignAdminRole();
|
|
||||||
|
|
||||||
_dbContext.Users.Add(user);
|
|
||||||
await _dbContext.SaveChangesAsync();
|
|
||||||
|
|
||||||
// Generate JWT token
|
|
||||||
var token = GenerateJwtToken(user);
|
|
||||||
|
|
||||||
// Generate and store refresh token
|
|
||||||
var refreshTokenString = GenerateRefreshTokenString();
|
|
||||||
var refreshToken = RefreshToken.Create(user.Id, refreshTokenString);
|
|
||||||
_dbContext.RefreshTokens.Add(refreshToken);
|
|
||||||
await _dbContext.SaveChangesAsync();
|
|
||||||
|
|
||||||
return new AuthResponse
|
|
||||||
{
|
|
||||||
UserId = user.Id,
|
|
||||||
Username = user.Username,
|
|
||||||
Email = user.Email,
|
|
||||||
Role = user.Role,
|
|
||||||
Token = token,
|
|
||||||
RefreshToken = refreshTokenString,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if an admin user already exists.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>True if admin user exists, false otherwise</returns>
|
|
||||||
public async Task<bool> AdminUserExistsAsync()
|
|
||||||
{
|
|
||||||
return await _dbContext.Users.AnyAsync(u => u.Role == "Admin");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Circuit breaker implementation for Mistral API.
|
|
||||||
/// Prevents cascading failures by temporarily blocking requests after too many failures.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class MistralCircuitBreaker
|
|
||||||
{
|
|
||||||
private readonly int _failureThreshold;
|
|
||||||
private readonly TimeSpan _resetTimeout;
|
|
||||||
private int _failureCount = 0;
|
|
||||||
private DateTime _lastFailureTime = DateTime.MinValue;
|
|
||||||
private readonly object _lock = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new circuit breaker.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="failureThreshold">Number of consecutive failures before opening the circuit</param>
|
|
||||||
/// <param name="resetTimeout">Time to wait before attempting to close the circuit</param>
|
|
||||||
public MistralCircuitBreaker(int failureThreshold, TimeSpan resetTimeout)
|
|
||||||
{
|
|
||||||
_failureThreshold = failureThreshold;
|
|
||||||
_resetTimeout = resetTimeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets whether the circuit is currently closed (allowing requests).
|
|
||||||
/// </summary>
|
|
||||||
public bool IsClosed
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
if (_failureCount >= _failureThreshold)
|
|
||||||
{
|
|
||||||
// Circuit is open, check if reset timeout has elapsed
|
|
||||||
if (DateTime.UtcNow - _lastFailureTime > _resetTimeout)
|
|
||||||
{
|
|
||||||
// Reset the circuit
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
if (_failureCount >= _failureThreshold &&
|
|
||||||
DateTime.UtcNow - _lastFailureTime > _resetTimeout)
|
|
||||||
{
|
|
||||||
_failureCount = 0;
|
|
||||||
_lastFailureTime = DateTime.MinValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current failure count.
|
|
||||||
/// </summary>
|
|
||||||
public int FailureCount => _failureCount;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the last failure time.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime LastFailureTime => _lastFailureTime;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Records a successful request, resetting the failure count.
|
|
||||||
/// </summary>
|
|
||||||
public void RecordSuccess()
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
_failureCount = 0;
|
|
||||||
_lastFailureTime = DateTime.MinValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Records a failed request, incrementing the failure count.
|
|
||||||
/// </summary>
|
|
||||||
public void RecordFailure()
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
_failureCount++;
|
|
||||||
_lastFailureTime = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resets the circuit breaker to its initial state.
|
|
||||||
/// </summary>
|
|
||||||
public void Reset()
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
_failureCount = 0;
|
|
||||||
_lastFailureTime = DateTime.MinValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the time remaining until the circuit can be reset.
|
|
||||||
/// Returns TimeSpan.Zero if circuit is closed or already eligible for reset.
|
|
||||||
/// </summary>
|
|
||||||
public TimeSpan TimeUntilReset
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
if (_failureCount < _failureThreshold)
|
|
||||||
return TimeSpan.Zero;
|
|
||||||
|
|
||||||
var elapsed = DateTime.UtcNow - _lastFailureTime;
|
|
||||||
if (elapsed >= _resetTimeout)
|
|
||||||
return TimeSpan.Zero;
|
|
||||||
|
|
||||||
return _resetTimeout - elapsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
using System.Net;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using GermanApp.Application.Models;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using GermanApp.Infrastructure.Configuration;
|
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// HTTP client implementation for Mistral API.
|
|
||||||
/// Implements IMistralConnector from Domain layer.
|
|
||||||
/// </summary>
|
|
||||||
public class MistralConnector : IMistralConnector
|
|
||||||
{
|
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
private readonly MistralConfig _config;
|
|
||||||
private readonly ILogger<MistralConnector> _logger;
|
|
||||||
private readonly MistralRateLimiter _rateLimiter;
|
|
||||||
private readonly IMemoryCache _cache;
|
|
||||||
private readonly MistralCircuitBreaker _circuitBreaker;
|
|
||||||
private readonly JsonSerializerOptions _jsonOptions;
|
|
||||||
|
|
||||||
public MistralConnector(HttpClient httpClient, MistralConfig config,
|
|
||||||
ILogger<MistralConnector> logger, IMemoryCache cache)
|
|
||||||
{
|
|
||||||
_httpClient = httpClient;
|
|
||||||
_config = config ?? new MistralConfig();
|
|
||||||
_logger = logger;
|
|
||||||
_cache = cache;
|
|
||||||
|
|
||||||
// Initialize JSON options (always needed)
|
|
||||||
// Mistral API uses snake_case for property names (max_tokens, not maxTokens)
|
|
||||||
_jsonOptions = new JsonSerializerOptions
|
|
||||||
{
|
|
||||||
PropertyNameCaseInsensitive = true,
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
|
||||||
};
|
|
||||||
|
|
||||||
// Skip validation and initialization during EF migrations
|
|
||||||
// (In Docker, AI configs may be set via environment variables or may be optional)
|
|
||||||
bool isEfDesignTime = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_EF") == "true";
|
|
||||||
bool hasApiKey = !string.IsNullOrWhiteSpace(_config.ApiKey);
|
|
||||||
bool hasValidBaseUrl = Uri.TryCreate(_config.BaseUrl, UriKind.Absolute, out var baseUri);
|
|
||||||
|
|
||||||
if (!isEfDesignTime)
|
|
||||||
{
|
|
||||||
// Always set up HttpClient if we have a valid BaseUrl
|
|
||||||
if (hasValidBaseUrl)
|
|
||||||
{
|
|
||||||
_httpClient.BaseAddress = baseUri;
|
|
||||||
_httpClient.Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds);
|
|
||||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
|
||||||
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
|
||||||
|
|
||||||
// Only add auth header if we have an API key
|
|
||||||
if (hasApiKey)
|
|
||||||
{
|
|
||||||
_config.Validate();
|
|
||||||
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_config.ApiKey}");
|
|
||||||
}
|
|
||||||
|
|
||||||
_rateLimiter = new MistralRateLimiter(_config.RateLimitPerMinute);
|
|
||||||
_circuitBreaker = new MistralCircuitBreaker(
|
|
||||||
_config.CircuitBreakerFailureThreshold,
|
|
||||||
TimeSpan.FromMinutes(_config.CircuitBreakerResetMinutes));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// No valid BaseUrl - this is an error condition
|
|
||||||
_logger.LogError("Mistral configuration has invalid or missing BaseUrl: {BaseUrl}",
|
|
||||||
_config.BaseUrl);
|
|
||||||
_rateLimiter = new MistralRateLimiter(10);
|
|
||||||
_circuitBreaker = new MistralCircuitBreaker(5, TimeSpan.FromMinutes(1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// EF Design time - initialize with defaults
|
|
||||||
_rateLimiter = new MistralRateLimiter(10);
|
|
||||||
_circuitBreaker = new MistralCircuitBreaker(5, TimeSpan.FromMinutes(1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<MistralResponse> CompleteAsync(MistralRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await ExecuteRequestAsync<MistralRequest, MistralResponse>(
|
|
||||||
"completions", request, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<MistralResponse> ChatAsync(MistralChatRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await ExecuteRequestAsync<MistralChatRequest, MistralResponse>(
|
|
||||||
"chat/completions", request, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<MistralModel>> ListModelsAsync(
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
CheckCircuitAndRateLimit("models");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await ExecuteWithRetryAsync(async () =>
|
|
||||||
{
|
|
||||||
var httpResponse = await _httpClient.GetAsync("models", cancellationToken);
|
|
||||||
return await HandleResponseAsync<MistralListModelsResponse>(httpResponse);
|
|
||||||
}, "ListModels");
|
|
||||||
_circuitBreaker.RecordSuccess();
|
|
||||||
return response.Data;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
HandleRequestError(ex, "ListModels");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<MistralModel> GetModelAsync(string modelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(modelId))
|
|
||||||
throw new ArgumentException("Model ID cannot be null or empty", nameof(modelId));
|
|
||||||
CheckCircuitAndRateLimit("models");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await ExecuteWithRetryAsync(async () =>
|
|
||||||
{
|
|
||||||
var httpResponse = await _httpClient.GetAsync($"models/{modelId}", cancellationToken);
|
|
||||||
return await HandleResponseAsync<MistralModel>(httpResponse);
|
|
||||||
}, "GetModel");
|
|
||||||
_circuitBreaker.RecordSuccess();
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
HandleRequestError(ex, "GetModel");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<TResponse> ExecuteRequestAsync<TRequest, TResponse>(
|
|
||||||
string endpoint, TRequest request, CancellationToken cancellationToken)
|
|
||||||
where TResponse : class
|
|
||||||
{
|
|
||||||
CheckCircuitAndRateLimit(endpoint);
|
|
||||||
|
|
||||||
// Generate cache key for caching
|
|
||||||
var cacheKey = GenerateCacheKey(request);
|
|
||||||
if (_config.EnableCaching && _cache.TryGetValue(cacheKey, out TResponse cachedResponse))
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Cache hit for {Endpoint}", endpoint);
|
|
||||||
return cachedResponse!;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await ExecuteWithRetryAsync(async () =>
|
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
|
||||||
_logger.LogDebug("Mistral API request to {Endpoint}: {Request}", endpoint, json);
|
|
||||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
||||||
var httpResponse = await _httpClient.PostAsync(endpoint, content, cancellationToken);
|
|
||||||
return await HandleResponseAsync<TResponse>(httpResponse);
|
|
||||||
}, endpoint);
|
|
||||||
|
|
||||||
_circuitBreaker.RecordSuccess();
|
|
||||||
|
|
||||||
// Cache the response if caching is enabled
|
|
||||||
if (_config.EnableCaching && response != null)
|
|
||||||
{
|
|
||||||
_cache.Set(cacheKey, response, TimeSpan.FromMinutes(_config.CacheTTLMinutes));
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
HandleRequestError(ex, endpoint);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CheckCircuitAndRateLimit(string endpoint)
|
|
||||||
{
|
|
||||||
if (!_circuitBreaker.IsClosed)
|
|
||||||
throw new AiServiceException("Mistral API circuit breaker is open",
|
|
||||||
AiErrorCode.ServiceUnavailable);
|
|
||||||
if (!_rateLimiter.TryAcquire(endpoint))
|
|
||||||
throw new AiServiceException("Rate limit exceeded",
|
|
||||||
AiErrorCode.RateLimited);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> action, string operationName)
|
|
||||||
{
|
|
||||||
int retryCount = 0;
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_config.TimeoutSeconds));
|
|
||||||
return await action();
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (retryCount < _config.MaxRetries && IsRetryable(ex))
|
|
||||||
{
|
|
||||||
retryCount++;
|
|
||||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, retryCount));
|
|
||||||
_logger.LogWarning(ex, "{Operation} failed (attempt {RetryCount}), retrying in {Delay}s...",
|
|
||||||
operationName, retryCount, delay.TotalSeconds);
|
|
||||||
await Task.Delay(delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsRetryable(Exception ex) => ex switch
|
|
||||||
{
|
|
||||||
HttpRequestException or TimeoutException or TaskCanceledException => true,
|
|
||||||
_ => false
|
|
||||||
};
|
|
||||||
|
|
||||||
private async Task<T> HandleResponseAsync<T>(HttpResponseMessage httpResponse)
|
|
||||||
{
|
|
||||||
if (!httpResponse.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var errorContent = await httpResponse.Content.ReadAsStringAsync();
|
|
||||||
_logger.LogError("Mistral API error response (status {StatusCode}): {Content}",
|
|
||||||
(int)httpResponse.StatusCode, errorContent);
|
|
||||||
|
|
||||||
AiErrorCode errorCode = MapStatusCodeToErrorCode(httpResponse.StatusCode);
|
|
||||||
string message = $"HTTP {(int)httpResponse.StatusCode}: {httpResponse.ReasonPhrase}";
|
|
||||||
|
|
||||||
// Try to extract error message from JSON without requiring exact DTO match
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(errorContent);
|
|
||||||
if (doc.RootElement.TryGetProperty("message", out var messageElement))
|
|
||||||
{
|
|
||||||
message = messageElement.ValueKind switch
|
|
||||||
{
|
|
||||||
JsonValueKind.String => messageElement.GetString() ?? message,
|
|
||||||
JsonValueKind.Object => messageElement.EnumerateObject().FirstOrDefault().Value.GetString() ?? message,
|
|
||||||
JsonValueKind.Array => messageElement.EnumerateArray().FirstOrDefault().GetString() ?? message,
|
|
||||||
_ => message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (doc.RootElement.TryGetProperty("type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String)
|
|
||||||
{
|
|
||||||
errorCode = typeElement.GetString() switch
|
|
||||||
{
|
|
||||||
"rate_limit_exceeded" or "rate_limit" => AiErrorCode.RateLimited,
|
|
||||||
"invalid_request_error" => AiErrorCode.InvalidRequest,
|
|
||||||
"authentication_error" or "invalid_api_key" => AiErrorCode.AuthenticationError,
|
|
||||||
"server_error" or "internal_server_error" => AiErrorCode.Temporary,
|
|
||||||
"timeout" => AiErrorCode.Timeout,
|
|
||||||
_ => errorCode
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Could not parse error response JSON, using status code mapping");
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogError("Mistral API error: {StatusCode} - {Message}",
|
|
||||||
(int)httpResponse.StatusCode, message);
|
|
||||||
|
|
||||||
httpResponse.EnsureSuccessStatusCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
var content = await httpResponse.Content.ReadAsStringAsync();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = JsonSerializer.Deserialize<T>(content, _jsonOptions);
|
|
||||||
if (result == null)
|
|
||||||
{
|
|
||||||
_logger.LogError("Mistral API returned null when deserializing to {Type}. Content: {Content}", typeof(T).Name, content);
|
|
||||||
throw new AiServiceException("Invalid response from Mistral API",
|
|
||||||
AiErrorCode.InvalidResponse);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to deserialize Mistral response to {Type}. Raw JSON: {Content}", typeof(T).Name, content);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GenerateCacheKey<T>(T request)
|
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
|
||||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
|
||||||
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json));
|
|
||||||
return Convert.ToBase64String(hashBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private AiErrorCode MapStatusCodeToErrorCode(HttpStatusCode statusCode)
|
|
||||||
{
|
|
||||||
return statusCode switch
|
|
||||||
{
|
|
||||||
HttpStatusCode.Unauthorized => AiErrorCode.AuthenticationError,
|
|
||||||
HttpStatusCode.TooManyRequests => AiErrorCode.RateLimited,
|
|
||||||
HttpStatusCode.RequestTimeout => AiErrorCode.Timeout,
|
|
||||||
HttpStatusCode.BadRequest => AiErrorCode.InvalidRequest,
|
|
||||||
HttpStatusCode.InternalServerError or
|
|
||||||
HttpStatusCode.ServiceUnavailable or
|
|
||||||
HttpStatusCode.BadGateway or
|
|
||||||
HttpStatusCode.GatewayTimeout => AiErrorCode.Temporary,
|
|
||||||
_ => AiErrorCode.Unknown
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleRequestError(Exception ex, string operationName)
|
|
||||||
{
|
|
||||||
_circuitBreaker.RecordFailure();
|
|
||||||
_logger.LogError(ex, "Mistral API request failed: {Operation}", operationName);
|
|
||||||
if (ex is not AiServiceException)
|
|
||||||
{
|
|
||||||
throw new AiServiceException($"Mistral {operationName} failed: {ex.Message}",
|
|
||||||
ex, AiErrorCode.Temporary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
using System.Collections.Concurrent;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Simple in-memory rate limiter for Mistral API requests.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class MistralRateLimiter
|
|
||||||
{
|
|
||||||
private readonly int _maxRequests;
|
|
||||||
private readonly TimeSpan _window;
|
|
||||||
private readonly ConcurrentDictionary<string, List<DateTime>> _requests = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new rate limiter.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="maxRequestsPerMinute">Maximum requests allowed per minute</param>
|
|
||||||
public MistralRateLimiter(int maxRequestsPerMinute)
|
|
||||||
{
|
|
||||||
_maxRequests = maxRequestsPerMinute;
|
|
||||||
_window = TimeSpan.FromMinutes(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempts to acquire a rate limit token for the specified endpoint.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="endpoint">The API endpoint being called</param>
|
|
||||||
/// <returns>True if request is allowed, false if rate limit exceeded</returns>
|
|
||||||
public bool TryAcquire(string endpoint)
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var requests = _requests.GetOrAdd(endpoint, _ => new List<DateTime>());
|
|
||||||
|
|
||||||
lock (requests)
|
|
||||||
{
|
|
||||||
// Remove old requests outside the window
|
|
||||||
requests.RemoveAll(r => now - r > _window);
|
|
||||||
|
|
||||||
// Check if limit exceeded
|
|
||||||
if (requests.Count >= _maxRequests)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Add new request
|
|
||||||
requests.Add(now);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current request count for an endpoint.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="endpoint">The API endpoint</param>
|
|
||||||
/// <returns>Number of requests in the current window</returns>
|
|
||||||
public int GetCurrentCount(string endpoint)
|
|
||||||
{
|
|
||||||
if (_requests.TryGetValue(endpoint, out var requests))
|
|
||||||
{
|
|
||||||
lock (requests)
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
requests.RemoveAll(r => now - r > _window);
|
|
||||||
return requests.Count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resets the rate limiter for a specific endpoint.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="endpoint">The API endpoint</param>
|
|
||||||
public void Reset(string endpoint)
|
|
||||||
{
|
|
||||||
if (_requests.TryGetValue(endpoint, out var requests))
|
|
||||||
{
|
|
||||||
lock (requests)
|
|
||||||
{
|
|
||||||
requests.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resets all rate limiters.
|
|
||||||
/// </summary>
|
|
||||||
public void ResetAll()
|
|
||||||
{
|
|
||||||
foreach (var kvp in _requests)
|
|
||||||
{
|
|
||||||
lock (kvp.Value)
|
|
||||||
{
|
|
||||||
kvp.Value.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,480 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using GermanApp.Infrastructure.Configuration;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Infrastructure service for Coqui TTS text-to-speech.
|
|
||||||
/// Uses Python process to call Coqui TTS library.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class TtsService : ITtsService
|
|
||||||
{
|
|
||||||
private readonly CoquiConfig _config;
|
|
||||||
private readonly ILogger<TtsService> _logger;
|
|
||||||
|
|
||||||
public TtsService(
|
|
||||||
IOptions<CoquiConfig> config,
|
|
||||||
ILogger<TtsService> logger)
|
|
||||||
{
|
|
||||||
_config = config.Value ?? new CoquiConfig();
|
|
||||||
_logger = logger;
|
|
||||||
|
|
||||||
// Skip validation during EF migrations or when configs are not set
|
|
||||||
bool isEfDesignTime = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_EF") == "true";
|
|
||||||
bool hasPythonPath = !string.IsNullOrWhiteSpace(_config.PythonPath);
|
|
||||||
|
|
||||||
if (!isEfDesignTime && hasPythonPath)
|
|
||||||
{
|
|
||||||
ValidateConfiguration();
|
|
||||||
// Ensure audio storage directory exists
|
|
||||||
Directory.CreateDirectory(_config.AudioStoragePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates TTS configuration on startup.
|
|
||||||
/// </summary>
|
|
||||||
/// <exception cref="InvalidOperationException">Thrown when configuration is invalid</exception>
|
|
||||||
private void ValidateConfiguration()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(_config.PythonPath))
|
|
||||||
{
|
|
||||||
_logger.LogError("Coqui PythonPath is not configured");
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Coqui PythonPath is not configured. Please set Coqui:PythonPath in appsettings.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_config.ModelName))
|
|
||||||
{
|
|
||||||
_logger.LogError("Coqui ModelName is not configured");
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Coqui ModelName is not configured. Please set Coqui:ModelName in appsettings.json. " +
|
|
||||||
"Example: tts_models/de/deu/fairseq/vits");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_config.AudioStoragePath))
|
|
||||||
{
|
|
||||||
_logger.LogError("Coqui AudioStoragePath is not configured");
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Coqui AudioStoragePath is not configured. Please set Coqui:AudioStoragePath in appsettings.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_config.MaxTextLength <= 0)
|
|
||||||
{
|
|
||||||
_logger.LogError("Coqui MaxTextLength must be positive");
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Coqui MaxTextLength must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_config.TimeoutSeconds <= 0)
|
|
||||||
{
|
|
||||||
_logger.LogError("Coqui TimeoutSeconds must be positive");
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Coqui TimeoutSeconds must be greater than 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Coqui TTS configuration validated: Model={ModelName}, Storage={AudioStoragePath}",
|
|
||||||
_config.ModelName, _config.AudioStoragePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio from text.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<byte[]> GenerateAudioAsync(
|
|
||||||
string text,
|
|
||||||
string? speaker = null,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
|
||||||
throw new ArgumentException("Text cannot be empty", nameof(text));
|
|
||||||
|
|
||||||
// Split long text into chunks
|
|
||||||
if (text.Length > _config.MaxTextLength)
|
|
||||||
{
|
|
||||||
var chunks = SplitText(text, _config.MaxTextLength);
|
|
||||||
var audioParts = new List<byte[]>();
|
|
||||||
|
|
||||||
foreach (var chunk in chunks)
|
|
||||||
{
|
|
||||||
var audio = await GenerateAudioForChunkAsync(chunk, speaker, language, cancellationToken);
|
|
||||||
audioParts.Add(audio);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine audio bytes
|
|
||||||
return CombineAudioBytes(audioParts);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await GenerateAudioForChunkAsync(text, speaker, language, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio for a single text chunk.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<byte[]> GenerateAudioForChunkAsync(
|
|
||||||
string text,
|
|
||||||
string? speaker,
|
|
||||||
string language,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var tempFile = Path.GetTempFileName() + "." + _config.OutputFormat;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await GenerateAudioToFileInternalAsync(text, tempFile, speaker, language, cancellationToken);
|
|
||||||
return await File.ReadAllBytesAsync(tempFile, cancellationToken);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
try { File.Delete(tempFile); }
|
|
||||||
catch (Exception ex) { _logger.LogError(ex, "Failed to delete temp file: {File}", tempFile); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio and saves to a file.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> GenerateAudioToFileAsync(
|
|
||||||
string text,
|
|
||||||
string outputPath,
|
|
||||||
string? speaker = null,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
|
||||||
throw new ArgumentException("Text cannot be empty", nameof(text));
|
|
||||||
|
|
||||||
// Ensure output directory exists
|
|
||||||
var directory = Path.GetDirectoryName(outputPath);
|
|
||||||
if (!string.IsNullOrEmpty(directory))
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
|
|
||||||
await GenerateAudioToFileInternalAsync(text, outputPath, speaker, language, cancellationToken);
|
|
||||||
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Internal method to generate audio to a specific file path.
|
|
||||||
/// </summary>
|
|
||||||
private async Task GenerateAudioToFileInternalAsync(
|
|
||||||
string text,
|
|
||||||
string outputPath,
|
|
||||||
string? speaker,
|
|
||||||
string language,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var script = BuildTtsScript(text, outputPath, speaker, language);
|
|
||||||
var tempScript = Path.GetTempFileName() + ".py";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await File.WriteAllTextAsync(tempScript, script, cancellationToken);
|
|
||||||
|
|
||||||
var result = await ExecutePythonProcessAsync(tempScript, cancellationToken);
|
|
||||||
|
|
||||||
if (result.ExitCode != 0)
|
|
||||||
{
|
|
||||||
_logger.LogError("TTS generation failed. stderr: {Error}", result.StandardError);
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"TTS generation failed: " + result.StandardError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify output file exists
|
|
||||||
if (!File.Exists(outputPath))
|
|
||||||
{
|
|
||||||
throw new FileNotFoundException(
|
|
||||||
"TTS output file not created", outputPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
try { File.Delete(tempScript); }
|
|
||||||
catch (Exception ex) { _logger.LogError(ex, "Failed to delete temp script: {File}", tempScript); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio as a stream.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<System.IO.Stream> GenerateAudioStreamAsync(
|
|
||||||
string text,
|
|
||||||
string? speaker = null,
|
|
||||||
string language = "de",
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// For streaming, we generate to a temp file and return a FileStream
|
|
||||||
var tempFile = Path.GetTempFileName() + "." + _config.OutputFormat;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await GenerateAudioToFileInternalAsync(text, tempFile, speaker, language, cancellationToken);
|
|
||||||
return new FileStream(tempFile, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Clean up on error
|
|
||||||
try { File.Delete(tempFile); }
|
|
||||||
catch { }
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the Python script for TTS generation.
|
|
||||||
/// </summary>
|
|
||||||
private string BuildTtsScript(string text, string outputPath, string? speaker, string language)
|
|
||||||
{
|
|
||||||
var script = new StringBuilder();
|
|
||||||
script.AppendLine("import sys");
|
|
||||||
script.AppendLine("import json");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Handle import errors
|
|
||||||
script.AppendLine("try:");
|
|
||||||
script.AppendLine(" from TTS.api import TTS");
|
|
||||||
script.AppendLine("except ImportError as e:");
|
|
||||||
script.AppendLine(" print(json.dumps({'error': 'Coqui TTS not installed: ' + str(e)}))");
|
|
||||||
script.AppendLine(" sys.exit(1)");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Load TTS model
|
|
||||||
script.AppendLine($"model_name = '{_config.ModelName}'");
|
|
||||||
script.AppendLine("try:");
|
|
||||||
script.AppendLine(" tts = TTS(model_name=model_name)");
|
|
||||||
script.AppendLine("except Exception as e:");
|
|
||||||
script.AppendLine(" print(json.dumps({'error': 'Failed to load TTS model: ' + str(e)}))");
|
|
||||||
script.AppendLine(" sys.exit(1)");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Configure TTS
|
|
||||||
if (!string.IsNullOrEmpty(speaker))
|
|
||||||
{
|
|
||||||
script.AppendLine($"tts.speaker = '{speaker}'");
|
|
||||||
}
|
|
||||||
script.AppendLine($"tts.language = '{language}'");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Generate and save audio
|
|
||||||
script.AppendLine($"text = '''{text.Replace("'''", "\\'''")}'''");
|
|
||||||
script.AppendLine($"output_path = '{outputPath.Replace("\\", "\\\\")}'");
|
|
||||||
script.AppendLine("tts.tts_to_file(text=text, file_path=output_path)");
|
|
||||||
script.AppendLine("print('success')");
|
|
||||||
|
|
||||||
return script.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Result of Python process execution.
|
|
||||||
/// </summary>
|
|
||||||
private class ProcessResult
|
|
||||||
{
|
|
||||||
public string StandardOutput { get; set; } = string.Empty;
|
|
||||||
public string StandardError { get; set; } = string.Empty;
|
|
||||||
public int ExitCode { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Executes a Python process and returns the result.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<ProcessResult> ExecutePythonProcessAsync(
|
|
||||||
string scriptPath,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var processInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = _config.PythonPath,
|
|
||||||
Arguments = scriptPath,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
StandardOutputEncoding = Encoding.UTF8,
|
|
||||||
StandardErrorEncoding = Encoding.UTF8
|
|
||||||
};
|
|
||||||
|
|
||||||
using var process = new Process { StartInfo = processInfo };
|
|
||||||
|
|
||||||
// Use a timeout for the process
|
|
||||||
var timeoutTask = Task.Delay(_config.TimeoutSeconds * 1000, cancellationToken);
|
|
||||||
|
|
||||||
process.Start();
|
|
||||||
|
|
||||||
var outputTask = process.StandardOutput.ReadToEndAsync();
|
|
||||||
var errorTask = process.StandardError.ReadToEndAsync();
|
|
||||||
var exitTask = process.WaitForExitAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Wait for process to complete or timeout
|
|
||||||
var completedTask = await Task.WhenAny(exitTask, timeoutTask);
|
|
||||||
|
|
||||||
if (completedTask == timeoutTask)
|
|
||||||
{
|
|
||||||
// Kill the process if it times out
|
|
||||||
try { process.Kill(); }
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
_logger.LogError("TTS process timed out after {Seconds} seconds", _config.TimeoutSeconds);
|
|
||||||
throw new TimeoutException(
|
|
||||||
$"TTS generation timed out after {_config.TimeoutSeconds} seconds");
|
|
||||||
}
|
|
||||||
|
|
||||||
var output = await outputTask;
|
|
||||||
var error = await errorTask;
|
|
||||||
|
|
||||||
return new ProcessResult
|
|
||||||
{
|
|
||||||
StandardOutput = output,
|
|
||||||
StandardError = error,
|
|
||||||
ExitCode = process.ExitCode
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Splits text into chunks of maximum length.
|
|
||||||
/// </summary>
|
|
||||||
private IReadOnlyList<string> SplitText(string text, int maxLength)
|
|
||||||
{
|
|
||||||
var chunks = new List<string>();
|
|
||||||
var currentChunk = new StringBuilder();
|
|
||||||
|
|
||||||
foreach (var word in text.Split(new[] { ' ', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries))
|
|
||||||
{
|
|
||||||
if (currentChunk.Length + word.Length + 1 <= maxLength)
|
|
||||||
{
|
|
||||||
if (currentChunk.Length > 0)
|
|
||||||
currentChunk.Append(' ');
|
|
||||||
currentChunk.Append(word);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
chunks.Add(currentChunk.ToString());
|
|
||||||
currentChunk.Clear();
|
|
||||||
currentChunk.Append(word);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentChunk.Length > 0)
|
|
||||||
chunks.Add(currentChunk.ToString());
|
|
||||||
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Combines multiple audio byte arrays into a single array.
|
|
||||||
/// Note: This is a simple concatenation. For proper audio merging,
|
|
||||||
/// you would need to use an audio library to handle the WAV header correctly.
|
|
||||||
/// </summary>
|
|
||||||
private byte[] CombineAudioBytes(IReadOnlyList<byte[]> audioParts)
|
|
||||||
{
|
|
||||||
if (audioParts.Count == 1)
|
|
||||||
return audioParts[0];
|
|
||||||
|
|
||||||
// For WAV files, we need to handle the header properly
|
|
||||||
// This is a simplified version that assumes all parts are valid WAV files
|
|
||||||
// In production, you would want to use NAudio or similar to properly merge WAV files
|
|
||||||
|
|
||||||
var totalLength = audioParts.Sum(p => p.Length);
|
|
||||||
var combined = new byte[totalLength];
|
|
||||||
var offset = 0;
|
|
||||||
|
|
||||||
foreach (var part in audioParts)
|
|
||||||
{
|
|
||||||
Buffer.BlockCopy(part, 0, combined, offset, part.Length);
|
|
||||||
offset += part.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
return combined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the Coqui TTS model and configuration.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<bool> TestModelAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Test with a simple phrase
|
|
||||||
var testText = "Hallo, das ist ein Test.";
|
|
||||||
var testFile = Path.Combine(_config.AudioStoragePath, "test." + _config.OutputFormat);
|
|
||||||
|
|
||||||
await GenerateAudioToFileAsync(testText, testFile, null, "de", cancellationToken);
|
|
||||||
|
|
||||||
// Verify file was created
|
|
||||||
if (File.Exists(testFile))
|
|
||||||
{
|
|
||||||
// Clean up test file
|
|
||||||
File.Delete(testFile);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "TTS model test failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current TTS model information.
|
|
||||||
/// </summary>
|
|
||||||
public virtual Task<(string ModelName, string? ModelPath)> GetModelInfoAsync()
|
|
||||||
{
|
|
||||||
return Task.FromResult<(string ModelName, string? ModelPath)>((_config.ModelName, null));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets list of available voices/speakers for the current model.
|
|
||||||
/// </summary>
|
|
||||||
public virtual Task<IReadOnlyList<string>> GetAvailableSpeakersAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Coqui TTS doesn't always have multiple speakers for all models
|
|
||||||
// This would need to query the model or use a predefined list
|
|
||||||
// For now, return a default list for German models
|
|
||||||
var defaultSpeakers = new List<string> { "default" };
|
|
||||||
return Task.FromResult<IReadOnlyList<string>>(defaultSpeakers);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cleans up old audio files from storage.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<int> CleanupOldFilesAsync(
|
|
||||||
TimeSpan olderThan,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var cutoff = DateTime.UtcNow - olderThan;
|
|
||||||
var deletedCount = 0;
|
|
||||||
|
|
||||||
if (Directory.Exists(_config.AudioStoragePath))
|
|
||||||
{
|
|
||||||
foreach (var file in Directory.GetFiles(_config.AudioStoragePath, "*", SearchOption.AllDirectories))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var fileInfo = new FileInfo(file);
|
|
||||||
if (fileInfo.LastWriteTimeUtc < cutoff)
|
|
||||||
{
|
|
||||||
File.Delete(file);
|
|
||||||
deletedCount++;
|
|
||||||
_logger.LogInformation("Deleted old audio file: {File}", file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to delete audio file: {File}", file);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Yield to avoid blocking for long periods
|
|
||||||
await Task.Yield();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return deletedCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,357 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using GermanApp.Infrastructure.Configuration;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Infrastructure service for Vosk speech recognition.
|
|
||||||
/// Uses Python process to call Vosk library.
|
|
||||||
/// This is part of the Infrastructure layer.
|
|
||||||
/// </summary>
|
|
||||||
public class VoskService : IVoskService
|
|
||||||
{
|
|
||||||
private readonly VoskConfig _config;
|
|
||||||
private readonly ILogger<VoskService> _logger;
|
|
||||||
|
|
||||||
public VoskService(
|
|
||||||
IOptions<VoskConfig> config,
|
|
||||||
ILogger<VoskService> logger)
|
|
||||||
{
|
|
||||||
_config = config.Value ?? new VoskConfig();
|
|
||||||
_logger = logger;
|
|
||||||
|
|
||||||
// Skip validation during EF migrations or when configs are not set
|
|
||||||
bool isEfDesignTime = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_EF") == "true";
|
|
||||||
bool hasModelPath = !string.IsNullOrWhiteSpace(_config.ModelPath);
|
|
||||||
|
|
||||||
if (!isEfDesignTime && hasModelPath)
|
|
||||||
{
|
|
||||||
ValidateModelPath();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates that the Vosk model directory exists and is accessible.
|
|
||||||
/// </summary>
|
|
||||||
/// <exception cref="InvalidOperationException">Thrown when model path is not configured</exception>
|
|
||||||
/// <exception cref="DirectoryNotFoundException">Thrown when model directory doesn't exist</exception>
|
|
||||||
private void ValidateModelPath()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(_config.ModelPath))
|
|
||||||
{
|
|
||||||
_logger.LogError("Vosk ModelPath is not configured. Please set Vosk:ModelPath in appsettings.json");
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Vosk model path is not configured. " +
|
|
||||||
"Please download vosk-model-de-0.22 from https://alphacephei.com/vosk/models " +
|
|
||||||
"and set the ModelPath in appsettings.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Directory.Exists(_config.ModelPath))
|
|
||||||
{
|
|
||||||
_logger.LogError("Vosk model directory not found: {ModelPath}", _config.ModelPath);
|
|
||||||
throw new DirectoryNotFoundException(
|
|
||||||
$"Vosk model directory not found: {_config.ModelPath}. " +
|
|
||||||
"Please download vosk-model-de-0.22 from https://alphacephei.com/vosk/models " +
|
|
||||||
"and extract it to the configured path");
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Vosk model directory validated: {ModelPath}", _config.ModelPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from audio bytes.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> RecognizeSpeechAsync(
|
|
||||||
byte[] audioBytes,
|
|
||||||
int sampleRate = 16000,
|
|
||||||
string? hint = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (audioBytes == null || audioBytes.Length == 0)
|
|
||||||
throw new ArgumentException("Audio data cannot be empty", nameof(audioBytes));
|
|
||||||
|
|
||||||
// Validate sample rate
|
|
||||||
if (sampleRate != _config.SampleRate)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(
|
|
||||||
"Audio sample rate ({SampleRate}) does not match expected ({Expected}). " +
|
|
||||||
"Results may be inaccurate.",
|
|
||||||
sampleRate,
|
|
||||||
_config.SampleRate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write audio to temp file
|
|
||||||
var tempFile = Path.GetTempFileName() + ".wav";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await File.WriteAllBytesAsync(tempFile, audioBytes, cancellationToken);
|
|
||||||
|
|
||||||
return await RecognizeFromFileInternalAsync(tempFile, hint, cancellationToken);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
// Clean up temp file
|
|
||||||
try { File.Delete(tempFile); }
|
|
||||||
catch (Exception ex) { _logger.LogError(ex, "Failed to delete temp file: {File}", tempFile); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from an audio file path.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> RecognizeSpeechFromFileAsync(
|
|
||||||
string audioFilePath,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (!File.Exists(audioFilePath))
|
|
||||||
throw new FileNotFoundException("Audio file not found", audioFilePath);
|
|
||||||
|
|
||||||
return await RecognizeFromFileInternalAsync(audioFilePath, null, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from a stream.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<string> RecognizeSpeechFromStreamAsync(
|
|
||||||
System.IO.Stream audioStream,
|
|
||||||
int sampleRate = 16000,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (audioStream == null || !audioStream.CanRead)
|
|
||||||
throw new ArgumentException("Audio stream must be readable", nameof(audioStream));
|
|
||||||
|
|
||||||
// Read stream to temp file
|
|
||||||
var tempFile = Path.GetTempFileName() + ".wav";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using (var fileStream = File.Create(tempFile))
|
|
||||||
{
|
|
||||||
await audioStream.CopyToAsync(fileStream, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await RecognizeFromFileInternalAsync(tempFile, null, cancellationToken);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
try { File.Delete(tempFile); }
|
|
||||||
catch (Exception ex) { _logger.LogError(ex, "Failed to delete temp file: {File}", tempFile); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Internal method to recognize from file with error handling.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<string> RecognizeFromFileInternalAsync(
|
|
||||||
string audioFilePath,
|
|
||||||
string? hint,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// Validate model path
|
|
||||||
if (!Directory.Exists(_config.ModelPath))
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Vosk model directory not found: {_config.ModelPath}");
|
|
||||||
|
|
||||||
// Build Python command
|
|
||||||
var script = BuildRecognitionScript(audioFilePath, hint);
|
|
||||||
var tempScript = Path.GetTempFileName() + ".py";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Write the Python script to a temp file
|
|
||||||
await File.WriteAllTextAsync(tempScript, script, cancellationToken);
|
|
||||||
|
|
||||||
// Execute Python process
|
|
||||||
var result = await ExecutePythonProcessAsync(tempScript, cancellationToken);
|
|
||||||
|
|
||||||
// Parse result
|
|
||||||
var recognizedText = ParseVoskOutput(result.StandardOutput);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(recognizedText))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Vosk returned empty result. stderr: {Error}", result.StandardError);
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"Speech recognition failed: " + result.StandardError);
|
|
||||||
}
|
|
||||||
|
|
||||||
return recognizedText.Trim();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
try { File.Delete(tempScript); }
|
|
||||||
catch (Exception ex) { _logger.LogError(ex, "Failed to delete temp script: {File}", tempScript); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the Python script for speech recognition.
|
|
||||||
/// </summary>
|
|
||||||
private string BuildRecognitionScript(string audioFilePath, string? hint)
|
|
||||||
{
|
|
||||||
var script = new StringBuilder();
|
|
||||||
script.AppendLine("import sys");
|
|
||||||
script.AppendLine("import json");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Handle potential import errors
|
|
||||||
script.AppendLine("try:");
|
|
||||||
script.AppendLine(" from vosk import Model, KaldiRecognizer, SetLogLevel");
|
|
||||||
script.AppendLine("except ImportError as e:");
|
|
||||||
script.AppendLine(" print(json.dumps({'error': 'Vosk not installed: ' + str(e)}))");
|
|
||||||
script.AppendLine(" sys.exit(1)");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Set log level to suppress warnings
|
|
||||||
script.AppendLine("SetLogLevel(-1)");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Load model
|
|
||||||
script.AppendLine($"model_path = '{_config.ModelPath.Replace("\\", "\\\\")}'");
|
|
||||||
script.AppendLine("try:");
|
|
||||||
script.AppendLine(" model = Model(model_path)");
|
|
||||||
script.AppendLine("except Exception as e:");
|
|
||||||
script.AppendLine(" print(json.dumps({'error': 'Failed to load model: ' + str(e)}))");
|
|
||||||
script.AppendLine(" sys.exit(1)");
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Configure recognizer
|
|
||||||
script.AppendLine($"sample_rate = {_config.SampleRate}");
|
|
||||||
script.AppendLine($"beam_width = {_config.BeamWidth}");
|
|
||||||
script.AppendLine("rec = KaldiRecognizer(model, sample_rate)");
|
|
||||||
if (!string.IsNullOrEmpty(hint))
|
|
||||||
{
|
|
||||||
script.AppendLine($"rec.SetGrammar('\"{hint.Replace("'", "\\'")}\"')");
|
|
||||||
}
|
|
||||||
script.AppendLine();
|
|
||||||
|
|
||||||
// Process audio file
|
|
||||||
script.AppendLine($"audio_path = '{audioFilePath.Replace("\\", "\\\\")}'");
|
|
||||||
script.AppendLine("with open(audio_path, 'rb') as f:");
|
|
||||||
script.AppendLine(" while True:");
|
|
||||||
script.AppendLine(" data = f.read(4000)");
|
|
||||||
script.AppendLine(" if len(data) == 0:");
|
|
||||||
script.AppendLine(" break");
|
|
||||||
script.AppendLine(" if rec.AcceptWaveform(data):");
|
|
||||||
script.AppendLine(" pass");
|
|
||||||
script.AppendLine(" else:");
|
|
||||||
script.AppendLine(" pass");
|
|
||||||
script.AppendLine();
|
|
||||||
script.AppendLine("result = rec.FinalResult()");
|
|
||||||
script.AppendLine("result_dict = json.loads(result)");
|
|
||||||
script.AppendLine("print(result_dict.get('text', ''))");
|
|
||||||
|
|
||||||
return script.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Executes a Python process and returns the result.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<(string StandardOutput, string StandardError)> ExecutePythonProcessAsync(
|
|
||||||
string scriptPath,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var processInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = _config.PythonPath,
|
|
||||||
Arguments = scriptPath,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
StandardOutputEncoding = Encoding.UTF8,
|
|
||||||
StandardErrorEncoding = Encoding.UTF8
|
|
||||||
};
|
|
||||||
|
|
||||||
using var process = new Process { StartInfo = processInfo };
|
|
||||||
|
|
||||||
// Use a timeout for the process
|
|
||||||
var timeoutTask = Task.Delay(_config.TimeoutSeconds * 1000, cancellationToken);
|
|
||||||
|
|
||||||
process.Start();
|
|
||||||
|
|
||||||
var outputTask = process.StandardOutput.ReadToEndAsync();
|
|
||||||
var errorTask = process.StandardError.ReadToEndAsync();
|
|
||||||
var exitTask = process.WaitForExitAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Wait for process to complete or timeout
|
|
||||||
var completedTask = await Task.WhenAny(exitTask, timeoutTask);
|
|
||||||
|
|
||||||
if (completedTask == timeoutTask)
|
|
||||||
{
|
|
||||||
// Kill the process if it times out
|
|
||||||
try { process.Kill(); }
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
_logger.LogError("Vosk process timed out after {Seconds} seconds", _config.TimeoutSeconds);
|
|
||||||
throw new TimeoutException(
|
|
||||||
$"Vosk speech recognition timed out after {_config.TimeoutSeconds} seconds");
|
|
||||||
}
|
|
||||||
|
|
||||||
var output = await outputTask;
|
|
||||||
var error = await errorTask;
|
|
||||||
|
|
||||||
return (output, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses Vosk JSON output to extract the recognized text.
|
|
||||||
/// </summary>
|
|
||||||
private string ParseVoskOutput(string output)
|
|
||||||
{
|
|
||||||
// Vosk outputs JSON with a "text" field
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(output.Trim());
|
|
||||||
if (doc.RootElement.TryGetProperty("text", out var textElement))
|
|
||||||
{
|
|
||||||
return textElement.GetString() ?? string.Empty;
|
|
||||||
}
|
|
||||||
// Sometimes the output is just the text directly
|
|
||||||
return output.Trim();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// If JSON parsing fails, try to return the output as-is
|
|
||||||
return output.Trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the Vosk model and configuration.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<bool> TestModelAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Create a simple test: try to recognize silence or a known phrase
|
|
||||||
// For now, just validate that the model directory exists
|
|
||||||
if (!Directory.Exists(_config.ModelPath))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Try to run a simple test with an empty/short audio
|
|
||||||
// This validates the Python environment and model loading
|
|
||||||
var testAudio = new byte[100]; // Very short audio
|
|
||||||
await RecognizeSpeechAsync(testAudio, _config.SampleRate, null, cancellationToken);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Vosk model test failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current Vosk model information.
|
|
||||||
/// </summary>
|
|
||||||
public virtual Task<(string ModelName, string ModelPath)> GetModelInfoAsync()
|
|
||||||
{
|
|
||||||
var modelName = Path.GetFileName(_config.ModelPath.TrimEnd('/', '\\'));
|
|
||||||
return Task.FromResult((modelName, _config.ModelPath));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,276 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Application.Interfaces;
|
|
||||||
using GermanApp.Application.DTOs.Auth;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Net;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// API controller for admin operations.
|
|
||||||
/// All endpoints require Admin role.
|
|
||||||
/// This is part of the Presentation layer.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public class AdminController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IAdminService _adminService;
|
|
||||||
private readonly IUserReportService _reportService;
|
|
||||||
|
|
||||||
public AdminController(IAdminService adminService, IUserReportService reportService)
|
|
||||||
{
|
|
||||||
_adminService = adminService;
|
|
||||||
_reportService = reportService;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// USER MANAGEMENT ENDPOINTS
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all users (Admin only).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of all users</returns>
|
|
||||||
[HttpGet("users")]
|
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<AdminUserListItemDto>), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetAllUsersAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var users = await _adminService.GetAllUsersAsync(cancellationToken);
|
|
||||||
return Ok(users);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a specific user by ID (Admin only).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The user DTO</returns>
|
|
||||||
[HttpGet("users/{userId}")]
|
|
||||||
[ProducesResponseType(typeof(AdminUserDto), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetUserByIdAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var user = await _adminService.GetUserByIdAsync(userId, cancellationToken);
|
|
||||||
|
|
||||||
if (user == null)
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
return Ok(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates a user's role (Admin only).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="dto">The role update data</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The updated user DTO</returns>
|
|
||||||
[HttpPut("users/{userId}/role")]
|
|
||||||
[ProducesResponseType(typeof(AdminUserDto), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> UpdateUserRoleAsync(
|
|
||||||
int userId,
|
|
||||||
[FromBody] UpdateUserRoleDto dto,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var user = await _adminService.UpdateUserRoleAsync(userId, dto.Role, cancellationToken);
|
|
||||||
|
|
||||||
if (user == null)
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
return Ok(user);
|
|
||||||
}
|
|
||||||
catch (ArgumentException ex)
|
|
||||||
{
|
|
||||||
return BadRequest(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a user (Admin only, cannot delete self).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID to delete</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Success or error</returns>
|
|
||||||
[HttpDelete("users/{userId}")]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NoContent)]
|
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> DeleteUserAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Get admin user ID from JWT claim
|
|
||||||
// Note: JWT "sub" claim is mapped by ASP.NET Core JWT middleware to ClaimTypes.NameIdentifier
|
|
||||||
var adminUserIdClaim = User.FindFirst(ClaimTypes.NameIdentifier) ?? User.FindFirst("sub");
|
|
||||||
if (adminUserIdClaim == null || !int.TryParse(adminUserIdClaim.Value, out var adminUserId) || adminUserId == 0)
|
|
||||||
return Unauthorized();
|
|
||||||
|
|
||||||
var result = await _adminService.DeleteUserAsync(userId, adminUserId, cancellationToken);
|
|
||||||
|
|
||||||
if (!result)
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
return BadRequest(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// DASHBOARD ENDPOINTS
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets admin dashboard statistics.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Dashboard statistics</returns>
|
|
||||||
[HttpGet("dashboard/stats")]
|
|
||||||
[ProducesResponseType(typeof(AdminDashboardStatsDto), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetDashboardStatsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var stats = await _adminService.GetDashboardStatsAsync(cancellationToken);
|
|
||||||
return Ok(stats);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// REPORT ENDPOINTS
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a progress report for a specific user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>User progress report</returns>
|
|
||||||
[HttpGet("reports/users/{userId}")]
|
|
||||||
[ProducesResponseType(typeof(UserProgressReportDto), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetUserReportAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var report = await _reportService.GenerateUserReportAsync(userId, cancellationToken);
|
|
||||||
return Ok(report);
|
|
||||||
}
|
|
||||||
catch (ArgumentException ex)
|
|
||||||
{
|
|
||||||
return BadRequest(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates progress reports for all users.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of all user progress reports</returns>
|
|
||||||
[HttpGet("reports/all-users")]
|
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<UserProgressReportDto>), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetAllUserReportsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var reports = await _reportService.GenerateAllUserReportsAsync(cancellationToken);
|
|
||||||
return Ok(reports);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets lesson completion data for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of lesson completion data</returns>
|
|
||||||
[HttpGet("reports/users/{userId}/lessons")]
|
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<LessonCompletionDto>), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetUserLessonProgressAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var progress = await _reportService.GetUserLessonProgressAsync(userId, cancellationToken);
|
|
||||||
return Ok(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz results for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of quiz results</returns>
|
|
||||||
[HttpGet("reports/users/{userId}/quizzes")]
|
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<UserQuizResultDto>), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetUserQuizResultsAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var results = await _reportService.GetUserQuizResultsAsync(userId, cancellationToken);
|
|
||||||
return Ok(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exports all user reports as CSV.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>CSV content</returns>
|
|
||||||
[HttpGet("reports/export/csv")]
|
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> ExportReportsAsCsvAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var csv = await _reportService.ExportReportsAsCsvAsync(cancellationToken);
|
|
||||||
return Ok(new { CsvContent = csv });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a specific user's full report (combined data).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Combined user report data</returns>
|
|
||||||
[HttpGet("reports/users/{userId}/full")]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetUserFullReportAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var progressReportTask = _reportService.GenerateUserReportAsync(userId, cancellationToken);
|
|
||||||
var lessonProgressTask = _reportService.GetUserLessonProgressAsync(userId, cancellationToken);
|
|
||||||
var quizResultsTask = _reportService.GetUserQuizResultsAsync(userId, cancellationToken);
|
|
||||||
|
|
||||||
await Task.WhenAll(progressReportTask, lessonProgressTask, quizResultsTask);
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
ProgressReport = await progressReportTask,
|
|
||||||
LessonProgress = await lessonProgressTask,
|
|
||||||
QuizResults = await quizResultsTask
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (ArgumentException ex)
|
|
||||||
{
|
|
||||||
return BadRequest(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,9 +2,7 @@ using GermanApp.Application.DTOs.Auth;
|
||||||
using GermanApp.Application.Interfaces;
|
using GermanApp.Application.Interfaces;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Security.Claims;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
namespace GermanApp.Presentation.Controllers;
|
||||||
|
|
||||||
|
|
@ -29,7 +27,6 @@ public class AuthController : ControllerBase
|
||||||
/// <param name="registerDto">Registration data</param>
|
/// <param name="registerDto">Registration data</param>
|
||||||
/// <returns>Authentication response with JWT token</returns>
|
/// <returns>Authentication response with JWT token</returns>
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
[AllowAnonymous]
|
|
||||||
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
|
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
||||||
public async Task<IActionResult> Register([FromBody] RegisterDto registerDto)
|
public async Task<IActionResult> Register([FromBody] RegisterDto registerDto)
|
||||||
|
|
@ -55,7 +52,6 @@ public class AuthController : ControllerBase
|
||||||
/// <param name="loginDto">Login data</param>
|
/// <param name="loginDto">Login data</param>
|
||||||
/// <returns>Authentication response with JWT token</returns>
|
/// <returns>Authentication response with JWT token</returns>
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
[AllowAnonymous]
|
|
||||||
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
|
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.Unauthorized)]
|
[ProducesResponseType(typeof(string), (int)HttpStatusCode.Unauthorized)]
|
||||||
public async Task<IActionResult> Login([FromBody] LoginDto loginDto)
|
public async Task<IActionResult> Login([FromBody] LoginDto loginDto)
|
||||||
|
|
@ -81,33 +77,25 @@ public class AuthController : ControllerBase
|
||||||
/// <returns>Current user information</returns>
|
/// <returns>Current user information</returns>
|
||||||
[HttpGet("me")]
|
[HttpGet("me")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[ProducesResponseType((int)HttpStatusCode.OK)]
|
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
||||||
public async Task<IActionResult> GetCurrentUser()
|
public async Task<IActionResult> GetCurrentUser()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get user ID from JWT claim
|
var userId = int.Parse(User.FindFirst("nameid")?.Value ?? "0");
|
||||||
// Note: JWT "sub" claim is mapped by ASP.NET Core JWT middleware to ClaimTypes.NameIdentifier
|
if (userId == 0)
|
||||||
// (http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier)
|
|
||||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier) ?? User.FindFirst("sub");
|
|
||||||
if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId) || userId == 0)
|
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
var user = await _authService.GetCurrentUserAsync(userId);
|
var user = await _authService.GetCurrentUserAsync(userId);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
// Return full user profile including role, level, streak, and points
|
return Ok(new AuthResponse
|
||||||
return Ok(new
|
|
||||||
{
|
{
|
||||||
userId = user.Id,
|
UserId = user.Id,
|
||||||
username = user.Username,
|
Username = user.Username,
|
||||||
email = user.Email,
|
Email = user.Email
|
||||||
role = user.Role,
|
|
||||||
currentLevel = user.CurrentLevel,
|
|
||||||
streak = user.Streak,
|
|
||||||
totalPoints = user.TotalPoints
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
using GermanApp.Application.DTOs.Auth;
|
|
||||||
using GermanApp.Application.Interfaces;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using System.Net;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Controller for bootstrap operations (first admin user creation).
|
|
||||||
/// This controller should be removed or disabled after the first admin is created.
|
|
||||||
/// This is part of the Presentation layer.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
public class BootstrapController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IAuthService _authService;
|
|
||||||
|
|
||||||
public BootstrapController(IAuthService authService)
|
|
||||||
{
|
|
||||||
_authService = authService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates the first admin user.
|
|
||||||
/// This endpoint is PUBLIC (no authentication required) but can only be used once.
|
|
||||||
/// After creating the first admin, this endpoint will return 400 BadRequest.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="registerDto">Admin user registration data</param>
|
|
||||||
/// <returns>Authentication response with JWT token</returns>
|
|
||||||
[HttpPost("admin")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.Conflict)]
|
|
||||||
public async Task<IActionResult> CreateAdminUser([FromBody] RegisterDto registerDto)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _authService.CreateAdminUserAsync(registerDto);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
// Admin already exists - this is expected after first use
|
|
||||||
if (ex.Message.Contains("Admin user already exists"))
|
|
||||||
return Conflict(ex.Message);
|
|
||||||
return BadRequest(ex.Message);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode((int)HttpStatusCode.InternalServerError, ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if an admin user already exists.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>True if admin exists, false otherwise</returns>
|
|
||||||
[HttpGet("admin-exists")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
[ProducesResponseType(typeof(bool), (int)HttpStatusCode.OK)]
|
|
||||||
public async Task<IActionResult> CheckAdminExists()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var exists = await _authService.AdminUserExistsAsync();
|
|
||||||
return Ok(exists);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode((int)HttpStatusCode.InternalServerError, ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Application.Services;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// API controller for managing lessons.
|
|
||||||
/// This is part of the Presentation layer.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize]
|
|
||||||
public class LessonsController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly LessonService _lessonService;
|
|
||||||
private readonly ProgressService _progressService;
|
|
||||||
|
|
||||||
public LessonsController(LessonService lessonService, ProgressService progressService)
|
|
||||||
{
|
|
||||||
_lessonService = lessonService;
|
|
||||||
_progressService = progressService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all lessons.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of all lessons</returns>
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<IActionResult> GetAllLessonsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lessons = await _lessonService.GetAllLessonsAsync(cancellationToken);
|
|
||||||
return Ok(lessons);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all lessons ordered by level and lesson order.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of all lessons in order</returns>
|
|
||||||
[HttpGet("ordered")]
|
|
||||||
public async Task<IActionResult> GetAllOrderedLessonsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lessons = await _lessonService.GetAllOrderedLessonsAsync(cancellationToken);
|
|
||||||
return Ok(lessons);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a specific lesson by its ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The lesson ID</param>
|
|
||||||
/// <returns>The lesson with the specified ID</returns>
|
|
||||||
[HttpGet("{id}")]
|
|
||||||
public async Task<IActionResult> GetLessonByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lesson = await _lessonService.GetLessonByIdAsync(id, cancellationToken);
|
|
||||||
if (lesson == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(lesson);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets lessons by level ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <returns>List of lessons for the specified level</returns>
|
|
||||||
[HttpGet("by-level/{levelId}")]
|
|
||||||
public async Task<IActionResult> GetLessonsByLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lessons = await _lessonService.GetLessonsByLevelAsync(levelId, cancellationToken);
|
|
||||||
return Ok(lessons);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets beginner lessons (A1 and A2 - levels 1 and 2).
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of beginner lessons</returns>
|
|
||||||
[HttpGet("beginner")]
|
|
||||||
public async Task<IActionResult> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lessons = await _lessonService.GetBeginnerLessonsAsync(cancellationToken);
|
|
||||||
return Ok(lessons);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets advanced lessons (B2 and C1 - levels 4 and 5).
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of advanced lessons</returns>
|
|
||||||
[HttpGet("advanced")]
|
|
||||||
public async Task<IActionResult> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lessons = await _lessonService.GetAdvancedLessonsAsync(cancellationToken);
|
|
||||||
return Ok(lessons);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="dto">The lesson data</param>
|
|
||||||
/// <returns>The created lesson</returns>
|
|
||||||
[HttpPost]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> CreateLessonAsync(CreateLessonDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lesson = await _lessonService.CreateLessonAsync(dto, cancellationToken);
|
|
||||||
return CreatedAtAction(nameof(GetLessonByIdAsync), new { id = lesson.Id }, lesson);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an existing lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The lesson ID</param>
|
|
||||||
/// <param name="dto">The updated lesson data</param>
|
|
||||||
/// <returns>The updated lesson</returns>
|
|
||||||
[HttpPut("{id}")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> UpdateLessonAsync(int id, UpdateLessonDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lesson = await _lessonService.UpdateLessonAsync(id, dto, cancellationToken);
|
|
||||||
if (lesson == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(lesson);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a lesson by its ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The lesson ID</param>
|
|
||||||
/// <returns>No content on success</returns>
|
|
||||||
[HttpDelete("{id}")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> DeleteLessonAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var result = await _lessonService.DeleteLessonAsync(id, cancellationToken);
|
|
||||||
if (!result)
|
|
||||||
return NotFound();
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the first lesson in a level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <returns>The first lesson in the level</returns>
|
|
||||||
[HttpGet("first/{levelId}")]
|
|
||||||
public async Task<IActionResult> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lesson = await _lessonService.GetFirstLessonInLevelAsync(levelId, cancellationToken);
|
|
||||||
if (lesson == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(lesson);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the next lesson after the specified one.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="currentLessonId">The current lesson ID</param>
|
|
||||||
/// <returns>The next lesson</returns>
|
|
||||||
[HttpGet("next/{currentLessonId}")]
|
|
||||||
public async Task<IActionResult> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lesson = await _lessonService.GetNextLessonAsync(currentLessonId, cancellationToken);
|
|
||||||
if (lesson == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(lesson);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets lessons that the user can access (based on completion of previous lessons).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <returns>List of accessible lessons for the user</returns>
|
|
||||||
[HttpGet("accessible/{userId}")]
|
|
||||||
[Authorize]
|
|
||||||
public async Task<IActionResult> GetAccessibleLessonsAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var lessons = await _lessonService.GetAccessibleLessonsAsync(userId, cancellationToken);
|
|
||||||
return Ok(lessons);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if the next lesson is unlocked for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="currentLessonId">The current lesson ID</param>
|
|
||||||
/// <returns>True if the next lesson is unlocked</returns>
|
|
||||||
[HttpGet("unlocked/{userId}/{currentLessonId}")]
|
|
||||||
[Authorize]
|
|
||||||
public async Task<IActionResult> IsNextLessonUnlockedAsync(int userId, int currentLessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var isUnlocked = await _progressService.IsNextLessonUnlockedAsync(userId, currentLessonId, cancellationToken);
|
|
||||||
return Ok(isUnlocked);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Application.Services;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// API controller for managing CEFR levels.
|
|
||||||
/// This is part of the Presentation layer.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize]
|
|
||||||
public class LevelsController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly LevelService _levelService;
|
|
||||||
|
|
||||||
public LevelsController(LevelService levelService)
|
|
||||||
{
|
|
||||||
_levelService = levelService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all CEFR levels ordered by their sort order.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of all levels</returns>
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<IActionResult> GetAllLevelsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var levels = await _levelService.GetAllLevelsAsync(cancellationToken);
|
|
||||||
return Ok(levels);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a specific level by its ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The level ID</param>
|
|
||||||
/// <returns>The level with the specified ID</returns>
|
|
||||||
[HttpGet("{id}")]
|
|
||||||
public async Task<IActionResult> GetLevelByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var level = await _levelService.GetLevelByIdAsync(id, cancellationToken);
|
|
||||||
if (level == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(level);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a level by its code (e.g., "A1", "B2").
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="code">The level code</param>
|
|
||||||
/// <returns>The level with the specified code</returns>
|
|
||||||
[HttpGet("by-code/{code}")]
|
|
||||||
public async Task<IActionResult> GetLevelByCodeAsync(string code, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var level = await _levelService.GetLevelByCodeAsync(code, cancellationToken);
|
|
||||||
if (level == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(level);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new CEFR level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="dto">The level data</param>
|
|
||||||
/// <returns>The created level</returns>
|
|
||||||
[HttpPost]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> CreateLevelAsync(CreateLevelDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var level = await _levelService.CreateLevelAsync(dto, cancellationToken);
|
|
||||||
return CreatedAtAction(nameof(GetLevelByIdAsync), new { id = level.Id }, level);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an existing CEFR level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The level ID</param>
|
|
||||||
/// <param name="dto">The updated level data</param>
|
|
||||||
/// <returns>The updated level</returns>
|
|
||||||
[HttpPut("{id}")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> UpdateLevelAsync(int id, UpdateLevelDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var level = await _levelService.UpdateLevelAsync(id, dto, cancellationToken);
|
|
||||||
if (level == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(level);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a CEFR level by its ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The level ID</param>
|
|
||||||
/// <returns>No content on success</returns>
|
|
||||||
[HttpDelete("{id}")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> DeleteLevelAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var result = await _levelService.DeleteLevelAsync(id, cancellationToken);
|
|
||||||
if (!result)
|
|
||||||
return NotFound();
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the first level (lowest order number) - typically A1.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>The first level</returns>
|
|
||||||
[HttpGet("first")]
|
|
||||||
public async Task<IActionResult> GetFirstLevelAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var level = await _levelService.GetFirstLevelAsync(cancellationToken);
|
|
||||||
if (level == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(level);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the next level after the specified one.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="currentLevelId">The current level ID</param>
|
|
||||||
/// <returns>The next level</returns>
|
|
||||||
[HttpGet("next/{currentLevelId}")]
|
|
||||||
public async Task<IActionResult> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var level = await _levelService.GetNextLevelAsync(currentLevelId, cancellationToken);
|
|
||||||
if (level == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(level);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// API controller for Mistral AI text generation.
|
|
||||||
/// This is part of the Presentation layer.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize]
|
|
||||||
public class MistralController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IMistralService _mistralService;
|
|
||||||
|
|
||||||
public MistralController(IMistralService mistralService)
|
|
||||||
{
|
|
||||||
_mistralService = mistralService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates text from a prompt.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">Text generation request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Generated text</returns>
|
|
||||||
[HttpPost("generate")]
|
|
||||||
public async Task<IActionResult> GenerateText(
|
|
||||||
[FromBody] TextGenerationRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Prompt))
|
|
||||||
return BadRequest("Prompt is required");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _mistralService.GenerateTextAsync(
|
|
||||||
request.Prompt,
|
|
||||||
request.Model,
|
|
||||||
request.Temperature,
|
|
||||||
request.MaxTokens,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return Ok(new TextGenerationResponse(result));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates text from chat messages.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">Chat generation request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Generated text</returns>
|
|
||||||
[HttpPost("chat")]
|
|
||||||
public async Task<IActionResult> GenerateChat(
|
|
||||||
[FromBody] ChatGenerationRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (request.Messages == null || request.Messages.Count == 0)
|
|
||||||
return BadRequest("Messages are required");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _mistralService.GenerateChatAsync(
|
|
||||||
request.Messages,
|
|
||||||
request.Model,
|
|
||||||
request.Temperature,
|
|
||||||
request.MaxTokens,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return Ok(new TextGenerationResponse(result));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a story based on lesson context.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">Story generation request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Generated story</returns>
|
|
||||||
[HttpPost("story")]
|
|
||||||
public async Task<IActionResult> GenerateStory(
|
|
||||||
[FromBody] StoryGenerationRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Level))
|
|
||||||
return BadRequest("Level is required");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _mistralService.GenerateStoryAsync(
|
|
||||||
request.Level,
|
|
||||||
request.Topic ?? string.Empty,
|
|
||||||
request.VocabularyWords ?? new List<string>(),
|
|
||||||
request.Length,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return Ok(new StoryGenerationResponse(result));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Provides feedback on user writing.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">Writing feedback request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Generated feedback</returns>
|
|
||||||
[HttpPost("writing-feedback")]
|
|
||||||
public async Task<IActionResult> GenerateWritingFeedback(
|
|
||||||
[FromBody] WritingFeedbackRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.UserText))
|
|
||||||
return BadRequest("User text is required");
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Level))
|
|
||||||
return BadRequest("Level is required");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _mistralService.GenerateWritingFeedbackAsync(
|
|
||||||
request.UserText,
|
|
||||||
request.Level,
|
|
||||||
request.Prompt,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return Ok(new WritingFeedbackResponse(result));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the Mistral API connection.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Health check result</returns>
|
|
||||||
[HttpGet("health")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> HealthCheck(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var isHealthy = await _mistralService.TestConnectionAsync(cancellationToken);
|
|
||||||
return Ok(new { Healthy = isHealthy });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for text generation.
|
|
||||||
/// </summary>
|
|
||||||
public record TextGenerationRequest(
|
|
||||||
string Prompt,
|
|
||||||
string? Model = null,
|
|
||||||
float Temperature = 0.7f,
|
|
||||||
int? MaxTokens = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for chat generation.
|
|
||||||
/// </summary>
|
|
||||||
public record ChatGenerationRequest(
|
|
||||||
IReadOnlyList<(string Role, string Content)> Messages,
|
|
||||||
string? Model = null,
|
|
||||||
float Temperature = 0.7f,
|
|
||||||
int? MaxTokens = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for story generation.
|
|
||||||
/// </summary>
|
|
||||||
public record StoryGenerationRequest(
|
|
||||||
string Level,
|
|
||||||
string? Topic = null,
|
|
||||||
IReadOnlyList<string>? VocabularyWords = null,
|
|
||||||
int Length = 200);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for writing feedback.
|
|
||||||
/// </summary>
|
|
||||||
public record WritingFeedbackRequest(
|
|
||||||
string UserText,
|
|
||||||
string Level,
|
|
||||||
string? Prompt = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO for text generation.
|
|
||||||
/// </summary>
|
|
||||||
public record TextGenerationResponse(string Text);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO for story generation.
|
|
||||||
/// </summary>
|
|
||||||
public record StoryGenerationResponse(string Story);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO for writing feedback.
|
|
||||||
/// </summary>
|
|
||||||
public record WritingFeedbackResponse(string Feedback);
|
|
||||||
|
|
@ -1,302 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Application.Services;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// API controller for managing quiz questions.
|
|
||||||
/// This is part of the Presentation layer.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize]
|
|
||||||
public class QuizQuestionsController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly QuizQuestionService _quizQuestionService;
|
|
||||||
|
|
||||||
public QuizQuestionsController(QuizQuestionService quizQuestionService)
|
|
||||||
{
|
|
||||||
_quizQuestionService = quizQuestionService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all quiz questions.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of all quiz questions</returns>
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<IActionResult> GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionService.GetAllQuizQuestionsAsync(cancellationToken);
|
|
||||||
return Ok(questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all active quiz questions for a specific quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quizId">The quiz ID</param>
|
|
||||||
/// <returns>List of quiz questions for the specified quiz</returns>
|
|
||||||
[HttpGet("by-quiz/{quizId}")]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
|
||||||
return Ok(questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all active quiz questions for a specific lesson (legacy - uses first quiz).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <returns>List of quiz questions for the specified lesson</returns>
|
|
||||||
[HttpGet("by-lesson/{lessonId}")]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionService.GetActiveQuizQuestionsByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return Ok(questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a specific quiz question by its ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz question ID</param>
|
|
||||||
/// <returns>The quiz question with the specified ID</returns>
|
|
||||||
[HttpGet("{id}")]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var question = await _quizQuestionService.GetQuizQuestionByIdAsync(id, cancellationToken);
|
|
||||||
if (question == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(question);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a random set of quiz questions for a quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quizId">The quiz ID</param>
|
|
||||||
/// <param name="count">Number of questions to return</param>
|
|
||||||
/// <returns>List of random quiz questions for the quiz</returns>
|
|
||||||
[HttpGet("random/{quizId}/{count}")]
|
|
||||||
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken);
|
|
||||||
return Ok(questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a random set of quiz questions for a lesson (legacy - uses first quiz).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <param name="count">Number of questions to return</param>
|
|
||||||
/// <returns>List of random quiz questions for the lesson</returns>
|
|
||||||
[HttpGet("random/by-lesson/{lessonId}/{count}")]
|
|
||||||
public async Task<IActionResult> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionService.GetRandomQuizQuestionsForLessonAsync(lessonId, count, cancellationToken);
|
|
||||||
return Ok(questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions by difficulty level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="difficulty">The difficulty level (1-5)</param>
|
|
||||||
/// <returns>List of quiz questions with the specified difficulty</returns>
|
|
||||||
[HttpGet("by-difficulty/{difficulty}")]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionService.GetQuizQuestionsByDifficultyAsync(difficulty, cancellationToken);
|
|
||||||
return Ok(questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions by type.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="type">The question type</param>
|
|
||||||
/// <returns>List of quiz questions with the specified type</returns>
|
|
||||||
[HttpGet("by-type/{type}")]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionService.GetQuizQuestionsByTypeAsync(type, cancellationToken);
|
|
||||||
return Ok(questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new quiz question.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="dto">The quiz question data (requires QuizId)</param>
|
|
||||||
/// <returns>The created quiz question</returns>
|
|
||||||
[HttpPost]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> CreateQuizQuestionAsync(CreateQuizQuestionDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var question = await _quizQuestionService.CreateQuizQuestionAsync(dto, cancellationToken);
|
|
||||||
return CreatedAtAction(nameof(GetQuizQuestionByIdAsync), new { id = question.Id }, question);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an existing quiz question.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz question ID</param>
|
|
||||||
/// <param name="dto">The updated quiz question data</param>
|
|
||||||
/// <returns>The updated quiz question</returns>
|
|
||||||
[HttpPut("{id}")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> UpdateQuizQuestionAsync(int id, UpdateQuizQuestionDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var question = await _quizQuestionService.UpdateQuizQuestionAsync(id, dto, cancellationToken);
|
|
||||||
if (question == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(question);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a quiz question by its ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz question ID</param>
|
|
||||||
/// <returns>No content on success</returns>
|
|
||||||
[HttpDelete("{id}")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> DeleteQuizQuestionAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var result = await _quizQuestionService.DeleteQuizQuestionAsync(id, cancellationToken);
|
|
||||||
if (!result)
|
|
||||||
return NotFound();
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz options for a specific question.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="questionId">The question ID</param>
|
|
||||||
/// <returns>List of quiz options for the question</returns>
|
|
||||||
[HttpGet("options/{questionId}")]
|
|
||||||
public async Task<IActionResult> GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var options = await _quizQuestionService.GetQuizOptionsByQuestionAsync(questionId, cancellationToken);
|
|
||||||
return Ok(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the correct option for a question.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="questionId">The question ID</param>
|
|
||||||
/// <returns>The correct option for the question</returns>
|
|
||||||
[HttpGet("correct-option/{questionId}")]
|
|
||||||
public async Task<IActionResult> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var option = await _quizQuestionService.GetCorrectOptionAsync(questionId, cancellationToken);
|
|
||||||
if (option == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(option);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all correct options for a question (for MultipleSelect type).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="questionId">The question ID</param>
|
|
||||||
/// <returns>List of correct options for the question</returns>
|
|
||||||
[HttpGet("correct-options/{questionId}")]
|
|
||||||
public async Task<IActionResult> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var options = await _quizQuestionService.GetCorrectOptionsAsync(questionId, cancellationToken);
|
|
||||||
return Ok(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new quiz option for a question.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="questionId">The question ID</param>
|
|
||||||
/// <param name="dto">The quiz option data</param>
|
|
||||||
/// <returns>The created quiz option</returns>
|
|
||||||
[HttpPost("options/{questionId}")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> CreateQuizOptionAsync(int questionId, CreateQuizOptionDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var option = await _quizQuestionService.CreateQuizOptionAsync(questionId, dto, cancellationToken);
|
|
||||||
return CreatedAtAction(nameof(GetQuizOptionsByQuestionAsync), new { questionId }, option);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an existing quiz option.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz option ID</param>
|
|
||||||
/// <param name="dto">The updated quiz option data</param>
|
|
||||||
/// <returns>The updated quiz option</returns>
|
|
||||||
[HttpPut("options/{id}")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> UpdateQuizOptionAsync(int id, UpdateQuizOptionDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var option = await _quizQuestionService.UpdateQuizOptionAsync(id, dto, cancellationToken);
|
|
||||||
if (option == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(option);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a quiz option by its ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz option ID</param>
|
|
||||||
/// <returns>No content on success</returns>
|
|
||||||
[HttpDelete("options/{id}")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> DeleteQuizOptionAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var result = await _quizQuestionService.DeleteQuizOptionAsync(id, cancellationToken);
|
|
||||||
if (!result)
|
|
||||||
return NotFound();
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Submits quiz answers and returns the result.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="dto">The quiz answers submission (requires QuizId)</param>
|
|
||||||
/// <returns>The quiz result</returns>
|
|
||||||
[HttpPost("submit/{userId}")]
|
|
||||||
[Authorize]
|
|
||||||
public async Task<IActionResult> SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Convert legacy DTO to new format
|
|
||||||
var newDto = new SubmitQuizDto(dto.QuizId, dto.Answers);
|
|
||||||
var result = await _quizQuestionService.SubmitQuizAnswersForQuizAsync(userId, newDto, cancellationToken);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of quiz questions for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <returns>The count of quiz questions for the lesson</returns>
|
|
||||||
[HttpGet("count/{lessonId}")]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var count = await _quizQuestionService.GetQuizQuestionCountForLessonAsync(lessonId, cancellationToken);
|
|
||||||
return Ok(new { lessonId, count });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the difficulty distribution for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <returns>The difficulty distribution for the lesson</returns>
|
|
||||||
[HttpGet("difficulty-distribution/{lessonId}")]
|
|
||||||
public async Task<IActionResult> GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var distribution = await _quizQuestionService.GetDifficultyDistributionAsync(lessonId, cancellationToken);
|
|
||||||
return Ok(distribution);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the type distribution for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <returns>The type distribution for the lesson</returns>
|
|
||||||
[HttpGet("type-distribution/{lessonId}")]
|
|
||||||
public async Task<IActionResult> GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var distribution = await _quizQuestionService.GetTypeDistributionAsync(lessonId, cancellationToken);
|
|
||||||
return Ok(distribution);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,327 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Application.Services;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// API controller for managing quizzes.
|
|
||||||
/// This is part of the Presentation layer.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize]
|
|
||||||
public class QuizzesController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly QuizService _quizService;
|
|
||||||
private readonly QuizQuestionService _quizQuestionService;
|
|
||||||
|
|
||||||
public QuizzesController(
|
|
||||||
QuizService quizService,
|
|
||||||
QuizQuestionService quizQuestionService)
|
|
||||||
{
|
|
||||||
_quizService = quizService;
|
|
||||||
_quizQuestionService = quizQuestionService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all quizzes.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of all quizzes</returns>
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<IActionResult> GetAllQuizzesAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizService.GetAllQuizzesAsync(cancellationToken);
|
|
||||||
return Ok(quizzes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all quizzes as list items (summary).
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of quiz summary items</returns>
|
|
||||||
[HttpGet("list")]
|
|
||||||
public async Task<IActionResult> GetAllQuizListItemsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizService.GetAllQuizListItemsAsync(cancellationToken);
|
|
||||||
return Ok(quizzes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a specific quiz by its ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz ID</param>
|
|
||||||
/// <returns>The quiz with the specified ID</returns>
|
|
||||||
[HttpGet("{id}")]
|
|
||||||
public async Task<IActionResult> GetQuizByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizService.GetQuizByIdAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(quiz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a quiz with its questions by ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz ID</param>
|
|
||||||
/// <returns>The quiz with questions</returns>
|
|
||||||
[HttpGet("{id}/with-questions")]
|
|
||||||
public async Task<IActionResult> GetQuizWithQuestionsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizService.GetQuizWithQuestionsAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(quiz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all quizzes for a specific lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <returns>List of quizzes for the specified lesson</returns>
|
|
||||||
[HttpGet("by-lesson/{lessonId}")]
|
|
||||||
public async Task<IActionResult> GetQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizService.GetQuizzesByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return Ok(quizzes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quizzes for a specific lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <returns>List of active quizzes for the specified lesson</returns>
|
|
||||||
[HttpGet("active/by-lesson/{lessonId}")]
|
|
||||||
public async Task<IActionResult> GetActiveQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizService.GetActiveQuizzesByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return Ok(quizzes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the first quiz for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <returns>The first quiz for the lesson</returns>
|
|
||||||
[HttpGet("first/by-lesson/{lessonId}")]
|
|
||||||
public async Task<IActionResult> GetFirstQuizByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizService.GetFirstQuizByLessonAsync(lessonId, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(quiz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all active quizzes.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of active quizzes</returns>
|
|
||||||
[HttpGet("active")]
|
|
||||||
public async Task<IActionResult> GetActiveQuizzesAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizService.GetActiveQuizzesAsync(cancellationToken);
|
|
||||||
return Ok(quizzes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="dto">The quiz data</param>
|
|
||||||
/// <returns>The created quiz</returns>
|
|
||||||
[HttpPost]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> CreateQuizAsync(CreateQuizDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizService.CreateQuizAsync(dto, cancellationToken);
|
|
||||||
return CreatedAtAction(nameof(GetQuizByIdAsync), new { id = quiz.Id }, quiz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an existing quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz ID</param>
|
|
||||||
/// <param name="dto">The updated quiz data</param>
|
|
||||||
/// <returns>The updated quiz</returns>
|
|
||||||
[HttpPut("{id}")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> UpdateQuizAsync(int id, UpdateQuizDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizService.UpdateQuizAsync(id, dto, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(quiz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a quiz by its ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz ID</param>
|
|
||||||
/// <returns>No content on success</returns>
|
|
||||||
[HttpDelete("{id}")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> DeleteQuizAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var result = await _quizService.DeleteQuizAsync(id, cancellationToken);
|
|
||||||
if (!result)
|
|
||||||
return NotFound();
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Activates a quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz ID</param>
|
|
||||||
/// <returns>The activated quiz</returns>
|
|
||||||
[HttpPost("{id}/activate")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> ActivateQuizAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizService.ActivateQuizAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(quiz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deactivates a quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz ID</param>
|
|
||||||
/// <returns>The deactivated quiz</returns>
|
|
||||||
[HttpPost("{id}/deactivate")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> DeactivateQuizAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quiz = await _quizService.DeactivateQuizAsync(id, cancellationToken);
|
|
||||||
if (quiz == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(quiz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a quiz exists by ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The quiz ID</param>
|
|
||||||
/// <returns>True if the quiz exists</returns>
|
|
||||||
[HttpGet("exists/{id}")]
|
|
||||||
public async Task<IActionResult> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var exists = await _quizService.ExistsAsync(id, cancellationToken);
|
|
||||||
return Ok(new { exists });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a quiz exists for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <returns>True if a quiz exists for the lesson</returns>
|
|
||||||
[HttpGet("exists/by-lesson/{lessonId}")]
|
|
||||||
public async Task<IActionResult> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var exists = await _quizService.ExistsByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return Ok(new { lessonId, exists });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total number of quizzes.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>The total count of quizzes</returns>
|
|
||||||
[HttpGet("count")]
|
|
||||||
public async Task<IActionResult> GetTotalQuizCountAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var count = await _quizService.GetTotalQuizCountAsync(cancellationToken);
|
|
||||||
return Ok(new { count });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of quizzes for a lesson.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
|
||||||
/// <returns>The count of quizzes for the lesson</returns>
|
|
||||||
[HttpGet("count/by-lesson/{lessonId}")]
|
|
||||||
public async Task<IActionResult> GetQuizCountByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var count = await _quizService.GetQuizCountByLessonAsync(lessonId, cancellationToken);
|
|
||||||
return Ok(new { lessonId, count });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quizzes by passing score range.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="minScore">Minimum passing score</param>
|
|
||||||
/// <param name="maxScore">Maximum passing score</param>
|
|
||||||
/// <returns>List of quizzes in the score range</returns>
|
|
||||||
[HttpGet("by-passing-score/{minScore}/{maxScore}")]
|
|
||||||
public async Task<IActionResult> GetQuizzesByPassingScoreRangeAsync(int minScore, int maxScore, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var quizzes = await _quizService.GetQuizzesByPassingScoreRangeAsync(minScore, maxScore, cancellationToken);
|
|
||||||
return Ok(quizzes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz questions for a specific quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quizId">The quiz ID</param>
|
|
||||||
/// <returns>List of quiz questions for the quiz</returns>
|
|
||||||
[HttpGet("{quizId}/questions")]
|
|
||||||
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionService.GetQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
|
||||||
return Ok(questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets active quiz questions for a specific quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quizId">The quiz ID</param>
|
|
||||||
/// <returns>List of active quiz questions for the quiz</returns>
|
|
||||||
[HttpGet("{quizId}/questions/active")]
|
|
||||||
public async Task<IActionResult> GetActiveQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
|
||||||
return Ok(questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets random quiz questions for a quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quizId">The quiz ID</param>
|
|
||||||
/// <param name="count">Number of questions to return</param>
|
|
||||||
/// <returns>List of random quiz questions</returns>
|
|
||||||
[HttpGet("{quizId}/questions/random/{count}")]
|
|
||||||
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken);
|
|
||||||
return Ok(questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Submits quiz answers and returns the result.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="quizId">The quiz ID</param>
|
|
||||||
/// <param name="dto">The quiz answers submission</param>
|
|
||||||
/// <returns>The quiz result</returns>
|
|
||||||
[HttpPost("submit/{userId}/{quizId}")]
|
|
||||||
[Authorize]
|
|
||||||
public async Task<IActionResult> SubmitQuizAnswersAsync(int userId, int quizId, SubmitQuizDto dto, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Update the submission to use the correct quizId
|
|
||||||
var submission = new SubmitQuizDto(quizId, dto.Answers);
|
|
||||||
var result = await _quizQuestionService.SubmitQuizAnswersForQuizAsync(userId, submission, cancellationToken);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total points for a quiz.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quizId">The quiz ID</param>
|
|
||||||
/// <returns>The total points for the quiz</returns>
|
|
||||||
[HttpGet("{quizId}/total-points")]
|
|
||||||
public async Task<IActionResult> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var points = await _quizQuestionService.GetTotalPointsForQuizAsync(quizId, cancellationToken);
|
|
||||||
return Ok(new { quizId, points });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// API controller for speech recognition using Vosk.
|
|
||||||
/// This is part of the Presentation layer.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize]
|
|
||||||
public class SpeechController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IVoskService _voskService;
|
|
||||||
|
|
||||||
public SpeechController(IVoskService voskService)
|
|
||||||
{
|
|
||||||
_voskService = voskService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recognizes speech from audio bytes.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">Audio recognition request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Recognized text</returns>
|
|
||||||
[HttpPost("recognize")]
|
|
||||||
[RequestSizeLimit(10_000_000)] // 10MB limit
|
|
||||||
[RequestFormLimits(MultipartBodyLengthLimit = 10_000_000)]
|
|
||||||
public async Task<IActionResult> RecognizeSpeech(
|
|
||||||
[FromForm] SpeechRecognitionRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (request?.Audio == null || request.Audio.Length == 0)
|
|
||||||
return BadRequest("Audio data is required");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _voskService.RecognizeSpeechAsync(
|
|
||||||
request.Audio,
|
|
||||||
request.SampleRate,
|
|
||||||
request.Hint,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return Ok(new SpeechRecognitionResponse(result));
|
|
||||||
}
|
|
||||||
catch (TimeoutException ex)
|
|
||||||
{
|
|
||||||
return StatusCode(504, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the speech recognition service.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Health check result</returns>
|
|
||||||
[HttpGet("health")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> HealthCheck(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var isHealthy = await _voskService.TestModelAsync(cancellationToken);
|
|
||||||
return Ok(new { Healthy = isHealthy });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets information about the current Vosk model.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Model information</returns>
|
|
||||||
[HttpGet("model-info")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetModelInfo(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var (modelName, modelPath) = await _voskService.GetModelInfoAsync();
|
|
||||||
return Ok(new { ModelName = modelName, ModelPath = modelPath });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for speech recognition.
|
|
||||||
/// </summary>
|
|
||||||
public record SpeechRecognitionRequest(
|
|
||||||
byte[] Audio,
|
|
||||||
int SampleRate = 16000,
|
|
||||||
string? Hint = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO for speech recognition.
|
|
||||||
/// </summary>
|
|
||||||
public record SpeechRecognitionResponse(string Text);
|
|
||||||
|
|
@ -1,494 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Application.Services;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// API controller for story operations.
|
|
||||||
/// This is part of the Presentation layer.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize] // All story endpoints require authentication
|
|
||||||
public class StoryController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly StoryService _storyService;
|
|
||||||
private readonly StoryGenerationService _generationService;
|
|
||||||
private readonly StoryUnlockService _unlockService;
|
|
||||||
private readonly ILevelRepository _levelRepository;
|
|
||||||
private readonly ILessonRepository _lessonRepository;
|
|
||||||
private readonly ILogger<StoryController> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new StoryController.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storyService">Service for story operations</param>
|
|
||||||
/// <param name="generationService">Service for story generation</param>
|
|
||||||
/// <param name="unlockService">Service for story unlocking</param>
|
|
||||||
/// <param name="levelRepository">Repository for levels</param>
|
|
||||||
/// <param name="lessonRepository">Repository for lessons</param>
|
|
||||||
/// <param name="logger">Logger for controller operations</param>
|
|
||||||
public StoryController(
|
|
||||||
StoryService storyService,
|
|
||||||
StoryGenerationService generationService,
|
|
||||||
StoryUnlockService unlockService,
|
|
||||||
ILevelRepository levelRepository,
|
|
||||||
ILessonRepository lessonRepository,
|
|
||||||
ILogger<StoryController> logger)
|
|
||||||
{
|
|
||||||
_storyService = storyService;
|
|
||||||
_generationService = generationService;
|
|
||||||
_unlockService = unlockService;
|
|
||||||
_levelRepository = levelRepository;
|
|
||||||
_lessonRepository = lessonRepository;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all levels that have stories.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of levels with story segments</returns>
|
|
||||||
[HttpGet("levels")]
|
|
||||||
public async Task<ActionResult<IReadOnlyList<LevelWithStoriesDto>>> GetLevelsWithStoriesAsync(
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Getting levels with stories");
|
|
||||||
|
|
||||||
var levels = await _levelRepository.GetAllOrderedAsync(cancellationToken);
|
|
||||||
|
|
||||||
var result = new List<LevelWithStoriesDto>();
|
|
||||||
foreach (var level in levels)
|
|
||||||
{
|
|
||||||
var segments = await _storyService.GetByLevelAsync(level.Id, false, cancellationToken);
|
|
||||||
var hasStories = segments.Count > 0;
|
|
||||||
|
|
||||||
result.Add(new LevelWithStoriesDto(
|
|
||||||
level.Id,
|
|
||||||
level.Name,
|
|
||||||
level.Code,
|
|
||||||
level.Order,
|
|
||||||
hasStories,
|
|
||||||
segments.Count));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all story segments for a specific level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="includeInactive">Whether to include inactive segments</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of story segments for the level</returns>
|
|
||||||
[HttpGet("levels/{levelId}/segments")]
|
|
||||||
public async Task<ActionResult<IReadOnlyList<StorySegmentDto>>> GetSegmentsByLevelAsync(
|
|
||||||
int levelId,
|
|
||||||
[FromQuery] bool includeInactive = false,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Getting story segments for level {LevelId}", levelId);
|
|
||||||
|
|
||||||
var segments = await _storyService.GetByLevelAsync(levelId, includeInactive, cancellationToken);
|
|
||||||
|
|
||||||
if (!segments.Any())
|
|
||||||
{
|
|
||||||
_logger.LogInformation("No story segments found for level {LevelId}", levelId);
|
|
||||||
return Ok(new List<StorySegmentDto>()); // Return empty list instead of NotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(segments);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a specific story segment by ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The story segment</returns>
|
|
||||||
[HttpGet("segments/{id}")]
|
|
||||||
public async Task<ActionResult<StorySegmentDto>> GetSegmentByIdAsync(
|
|
||||||
int id,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Getting story segment by ID: {Id}", id);
|
|
||||||
|
|
||||||
var segment = await _storyService.GetByIdAsync(id, cancellationToken);
|
|
||||||
|
|
||||||
if (segment == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Story segment not found: {Id}", id);
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(segment);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new story segment (Admin only).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="dto">The DTO containing segment data</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The created story segment</returns>
|
|
||||||
[HttpPost("segments")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<ActionResult<StorySegmentDto>> CreateSegmentAsync(
|
|
||||||
[FromBody] CreateStorySegmentDto dto,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Creating story segment");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var created = await _storyService.CreateAsync(dto, cancellationToken);
|
|
||||||
return CreatedAtAction(nameof(GetSegmentByIdAsync), new { id = created.Id }, created);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to create story segment");
|
|
||||||
return Conflict(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an existing story segment (Admin only).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The segment ID</param>
|
|
||||||
/// <param name="dto">The DTO containing updated data</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The updated story segment</returns>
|
|
||||||
[HttpPut("segments/{id}")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<ActionResult<StorySegmentDto>> UpdateSegmentAsync(
|
|
||||||
int id,
|
|
||||||
[FromBody] UpdateStorySegmentDto dto,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Updating story segment {Id}", id);
|
|
||||||
|
|
||||||
var updated = await _storyService.UpdateAsync(id, dto, cancellationToken);
|
|
||||||
|
|
||||||
if (updated == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Story segment not found for update: {Id}", id);
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a story segment (Admin only).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>No content if successful</returns>
|
|
||||||
[HttpDelete("segments/{id}")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<IActionResult> DeleteSegmentAsync(
|
|
||||||
int id,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Deleting story segment {Id}", id);
|
|
||||||
|
|
||||||
var deleted = await _storyService.DeleteAsync(id, cancellationToken);
|
|
||||||
|
|
||||||
if (!deleted)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Story segment not found for deletion: {Id}", id);
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a story for a level using AI.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="request">The generation request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The generated story with segments</returns>
|
|
||||||
[HttpPost("levels/{levelId}/generate")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<ActionResult<StoryGenerationResponseDto>> GenerateStoryAsync(
|
|
||||||
int levelId,
|
|
||||||
[FromBody] StoryGenerationRequestDto request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Generating story for level {LevelId} with theme '{Theme}'",
|
|
||||||
levelId, request.Theme);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Get all lessons for the level
|
|
||||||
var lessons = await _lessonRepository.GetByLevelAsync(levelId, cancellationToken);
|
|
||||||
|
|
||||||
if (!lessons.Any())
|
|
||||||
{
|
|
||||||
_logger.LogWarning("No lessons found for level {LevelId}", levelId);
|
|
||||||
return BadRequest("No lessons found for this level");
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = await _generationService.GenerateStoryAsync(
|
|
||||||
levelId,
|
|
||||||
request.Theme,
|
|
||||||
lessons,
|
|
||||||
request.SegmentCount,
|
|
||||||
request.CustomPrompt,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to generate story for level {LevelId}", levelId);
|
|
||||||
return StatusCode(500, ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio for a specific story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="segmentId">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The updated segment with audio URL</returns>
|
|
||||||
[HttpPost("segments/{segmentId}/audio")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public async Task<ActionResult<StorySegmentDto>> GenerateSegmentAudioAsync(
|
|
||||||
int segmentId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Generating audio for story segment {SegmentId}", segmentId);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _generationService.GenerateAudioAsync(segmentId, cancellationToken);
|
|
||||||
|
|
||||||
if (result == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Segment not found or audio generation failed: {SegmentId}", segmentId);
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to generate audio for segment {SegmentId}", segmentId);
|
|
||||||
return StatusCode(500, ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the user's progress through a story for a level.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The user's story progress</returns>
|
|
||||||
[HttpGet("levels/{levelId}/progress")]
|
|
||||||
public async Task<ActionResult<StoryProgressDto>> GetUserProgressAsync(
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Getting story progress for user in level {LevelId}", levelId);
|
|
||||||
|
|
||||||
var userId = GetUserId();
|
|
||||||
|
|
||||||
var progress = await _unlockService.GetUserProgressAsync(userId, levelId, cancellationToken);
|
|
||||||
|
|
||||||
return Ok(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Marks a story segment as completed (read/listened to).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="segmentId">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>No content if successful</returns>
|
|
||||||
[HttpPost("segments/{segmentId}/complete")]
|
|
||||||
public async Task<IActionResult> MarkSegmentAsCompletedAsync(
|
|
||||||
int segmentId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Marking segment {SegmentId} as completed", segmentId);
|
|
||||||
|
|
||||||
var userId = GetUserId();
|
|
||||||
|
|
||||||
var success = await _unlockService.MarkSegmentAsCompletedAsync(
|
|
||||||
userId, segmentId, cancellationToken);
|
|
||||||
|
|
||||||
if (!success)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Failed to mark segment {SegmentId} as completed for user {UserId}",
|
|
||||||
segmentId, userId);
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the next story segment for a user to read.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The next segment to read</returns>
|
|
||||||
[HttpGet("levels/{levelId}/next")]
|
|
||||||
public async Task<ActionResult<StorySegmentDto?>> GetNextSegmentAsync(
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Getting next story segment for user in level {LevelId}", levelId);
|
|
||||||
|
|
||||||
var userId = GetUserId();
|
|
||||||
|
|
||||||
// Get user's progress
|
|
||||||
var progress = await _unlockService.GetUserProgressAsync(userId, levelId, cancellationToken);
|
|
||||||
|
|
||||||
// Find the first unlocked but not completed segment
|
|
||||||
foreach (var segmentProgress in progress.Segments.OrderBy(s => s.Order))
|
|
||||||
{
|
|
||||||
if (segmentProgress.IsUnlocked && !segmentProgress.IsCompleted)
|
|
||||||
{
|
|
||||||
var segment = await _storyService.GetByIdAsync(segmentProgress.SegmentId, cancellationToken);
|
|
||||||
if (segment != null)
|
|
||||||
{
|
|
||||||
return Ok(segment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no segment is unlocked, check if we can unlock one
|
|
||||||
// This would typically be triggered by lesson completion, but we can check here too
|
|
||||||
return Ok(null); // No segment available
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a specific segment is unlocked for the current user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="segmentId">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if unlocked</returns>
|
|
||||||
[HttpGet("segments/{segmentId}/unlocked")]
|
|
||||||
public async Task<ActionResult<bool>> IsSegmentUnlockedAsync(
|
|
||||||
int segmentId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Checking if segment {SegmentId} is unlocked", segmentId);
|
|
||||||
|
|
||||||
var userId = GetUserId();
|
|
||||||
|
|
||||||
var isUnlocked = await _unlockService.IsSegmentUnlockedAsync(userId, segmentId, cancellationToken);
|
|
||||||
|
|
||||||
return Ok(isUnlocked);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the audio file for a story segment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="segmentId">The segment ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The audio file</returns>
|
|
||||||
[HttpGet("segments/{segmentId}/audio")]
|
|
||||||
public async Task<IActionResult> GetSegmentAudioAsync(
|
|
||||||
int segmentId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Getting audio for segment {SegmentId}", segmentId);
|
|
||||||
|
|
||||||
var segment = await _storyService.GetByIdAsync(segmentId, cancellationToken);
|
|
||||||
|
|
||||||
if (segment == null || string.IsNullOrEmpty(segment.AudioUrl))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Segment not found or no audio: {SegmentId}", segmentId);
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Static files are served from wwwroot, and audio files are stored at wwwroot/audio/story/
|
|
||||||
// The segment.AudioUrl is already in the format "/audio/story/levelN-segmentM.wav"
|
|
||||||
// The static file middleware will serve it automatically
|
|
||||||
return Ok(new { AudioUrl = segment.AudioUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all lessons in a level with their associated story segments.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="levelId">The level ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of lessons with story segment status</returns>
|
|
||||||
[HttpGet("levels/{levelId}/lessons-with-stories")]
|
|
||||||
public async Task<ActionResult<IReadOnlyList<LessonWithStoryStatusDto>>> GetLessonsWithStoryStatusAsync(
|
|
||||||
int levelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Getting lessons with story status for level {LevelId}", levelId);
|
|
||||||
|
|
||||||
var lessons = await _lessonRepository.GetByLevelAsync(levelId, cancellationToken);
|
|
||||||
var segments = await _storyService.GetByLevelAsync(levelId, false, cancellationToken);
|
|
||||||
|
|
||||||
var result = new List<LessonWithStoryStatusDto>();
|
|
||||||
foreach (var lesson in lessons)
|
|
||||||
{
|
|
||||||
var lessonSegments = segments.Where(s => s.LessonId == lesson.Id).ToList();
|
|
||||||
|
|
||||||
result.Add(new LessonWithStoryStatusDto(
|
|
||||||
lesson.Id,
|
|
||||||
lesson.Title,
|
|
||||||
lesson.Order,
|
|
||||||
lesson.Topic,
|
|
||||||
lessonSegments.Count > 0,
|
|
||||||
lessonSegments.Count));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current user's ID from the JWT claims.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>The user ID</returns>
|
|
||||||
private int GetUserId()
|
|
||||||
{
|
|
||||||
// Note: JWT "sub" claim is mapped by ASP.NET Core JWT middleware to ClaimTypes.NameIdentifier
|
|
||||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier) ?? User.FindFirst("sub");
|
|
||||||
|
|
||||||
if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId))
|
|
||||||
{
|
|
||||||
throw new UnauthorizedAccessException("User ID not found in token");
|
|
||||||
}
|
|
||||||
|
|
||||||
return userId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for level with story information.
|
|
||||||
/// </summary>
|
|
||||||
public record LevelWithStoriesDto(
|
|
||||||
int Id,
|
|
||||||
string Name,
|
|
||||||
string Code,
|
|
||||||
int Order,
|
|
||||||
bool HasStories,
|
|
||||||
int StorySegmentCount);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for lesson with story segment status.
|
|
||||||
/// </summary>
|
|
||||||
public record LessonWithStoryStatusDto(
|
|
||||||
int Id,
|
|
||||||
string Title,
|
|
||||||
int Order,
|
|
||||||
string Topic,
|
|
||||||
bool HasStorySegment,
|
|
||||||
int StorySegmentCount);
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// API controller for text-to-speech using Coqui TTS.
|
|
||||||
/// This is part of the Presentation layer.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize]
|
|
||||||
public class TtsController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly ITtsService _ttsService;
|
|
||||||
|
|
||||||
public TtsController(ITtsService ttsService)
|
|
||||||
{
|
|
||||||
_ttsService = ttsService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio from text.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">TTS generation request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Audio file as bytes</returns>
|
|
||||||
[HttpPost("generate")]
|
|
||||||
[RequestSizeLimit(5_000_000)] // 5MB limit for response
|
|
||||||
public async Task<IActionResult> GenerateAudio(
|
|
||||||
[FromBody] TtsGenerationRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Text))
|
|
||||||
return BadRequest("Text is required");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var audioBytes = await _ttsService.GenerateAudioAsync(
|
|
||||||
request.Text,
|
|
||||||
request.Speaker,
|
|
||||||
request.Language,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// Return audio as WAV or specified format
|
|
||||||
var contentType = GetContentType(request.Format);
|
|
||||||
return File(audioBytes, contentType);
|
|
||||||
}
|
|
||||||
catch (TimeoutException ex)
|
|
||||||
{
|
|
||||||
return StatusCode(504, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates audio and returns a file URL.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">TTS generation request with filename</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Audio file URL</returns>
|
|
||||||
[HttpPost("generate-file")]
|
|
||||||
public async Task<IActionResult> GenerateAudioToFile(
|
|
||||||
[FromBody] TtsFileGenerationRequest request,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Text))
|
|
||||||
return BadRequest("Text is required");
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Filename))
|
|
||||||
return BadRequest("Filename is required");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var outputPath = Path.Combine("audio", "tts", request.Filename);
|
|
||||||
await _ttsService.GenerateAudioToFileAsync(
|
|
||||||
request.Text,
|
|
||||||
outputPath,
|
|
||||||
request.Speaker,
|
|
||||||
request.Language,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return Ok(new TtsFileResponse(
|
|
||||||
Url: $"/audio/tts/{request.Filename}",
|
|
||||||
Filename: request.Filename));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests the TTS service.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Health check result</returns>
|
|
||||||
[HttpGet("health")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> HealthCheck(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var isHealthy = await _ttsService.TestModelAsync(cancellationToken);
|
|
||||||
return Ok(new { Healthy = isHealthy });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets information about the current TTS model.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Model information</returns>
|
|
||||||
[HttpGet("model-info")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetModelInfo(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var (modelName, modelPath) = await _ttsService.GetModelInfoAsync();
|
|
||||||
return Ok(new { ModelName = modelName, ModelPath = modelPath });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets list of available voices/speakers.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>List of speaker IDs</returns>
|
|
||||||
[HttpGet("speakers")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public async Task<IActionResult> GetSpeakers(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var speakers = await _ttsService.GetAvailableSpeakersAsync(cancellationToken);
|
|
||||||
return Ok(new { Speakers = speakers });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { Error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the content type for the specified audio format.
|
|
||||||
/// </summary>
|
|
||||||
private string GetContentType(string? format)
|
|
||||||
{
|
|
||||||
format = format?.ToLower() ?? "wav";
|
|
||||||
return format switch
|
|
||||||
{
|
|
||||||
"wav" => "audio/wav",
|
|
||||||
"mp3" => "audio/mpeg",
|
|
||||||
"ogg" => "audio/ogg",
|
|
||||||
"flac" => "audio/flac",
|
|
||||||
_ => "audio/wav"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for TTS generation.
|
|
||||||
/// </summary>
|
|
||||||
public record TtsGenerationRequest(
|
|
||||||
string Text,
|
|
||||||
string? Speaker = null,
|
|
||||||
string Language = "de",
|
|
||||||
string? Format = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request DTO for TTS file generation.
|
|
||||||
/// </summary>
|
|
||||||
public record TtsFileGenerationRequest(
|
|
||||||
string Text,
|
|
||||||
string Filename,
|
|
||||||
string? Speaker = null,
|
|
||||||
string Language = "de");
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response DTO for TTS file generation.
|
|
||||||
/// </summary>
|
|
||||||
public record TtsFileResponse(
|
|
||||||
string Url,
|
|
||||||
string Filename);
|
|
||||||
|
|
@ -23,7 +23,6 @@ public static class LessonsEndpoints
|
||||||
var lessons = await repository.GetAllAsync();
|
var lessons = await repository.GetAllAsync();
|
||||||
return Results.Ok(lessons.Select(l => l.ToDto()));
|
return Results.Ok(lessons.Select(l => l.ToDto()));
|
||||||
})
|
})
|
||||||
.RequireAuthorization()
|
|
||||||
.WithName("GetAllLessons")
|
.WithName("GetAllLessons")
|
||||||
.WithOpenApi(operation => new(operation)
|
.WithOpenApi(operation => new(operation)
|
||||||
{
|
{
|
||||||
|
|
@ -37,7 +36,6 @@ public static class LessonsEndpoints
|
||||||
var lesson = await repository.GetByIdAsync(id);
|
var lesson = await repository.GetByIdAsync(id);
|
||||||
return lesson is null ? Results.NotFound() : Results.Ok(lesson.ToDto());
|
return lesson is null ? Results.NotFound() : Results.Ok(lesson.ToDto());
|
||||||
})
|
})
|
||||||
.RequireAuthorization()
|
|
||||||
.WithName("GetLessonById")
|
.WithName("GetLessonById")
|
||||||
.WithOpenApi(operation => new(operation)
|
.WithOpenApi(operation => new(operation)
|
||||||
{
|
{
|
||||||
|
|
@ -56,7 +54,6 @@ public static class LessonsEndpoints
|
||||||
beginnerLessons.AddRange(level2Lessons);
|
beginnerLessons.AddRange(level2Lessons);
|
||||||
return Results.Ok(beginnerLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto()));
|
return Results.Ok(beginnerLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto()));
|
||||||
})
|
})
|
||||||
.RequireAuthorization()
|
|
||||||
.WithName("GetBeginnerLessons")
|
.WithName("GetBeginnerLessons")
|
||||||
.WithOpenApi(operation => new(operation)
|
.WithOpenApi(operation => new(operation)
|
||||||
{
|
{
|
||||||
|
|
@ -75,7 +72,6 @@ public static class LessonsEndpoints
|
||||||
advancedLessons.AddRange(level5Lessons);
|
advancedLessons.AddRange(level5Lessons);
|
||||||
return Results.Ok(advancedLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto()));
|
return Results.Ok(advancedLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto()));
|
||||||
})
|
})
|
||||||
.RequireAuthorization()
|
|
||||||
.WithName("GetAdvancedLessons")
|
.WithName("GetAdvancedLessons")
|
||||||
.WithOpenApi(operation => new(operation)
|
.WithOpenApi(operation => new(operation)
|
||||||
{
|
{
|
||||||
|
|
@ -89,7 +85,6 @@ public static class LessonsEndpoints
|
||||||
var lessons = await repository.GetByLevelAsync(level);
|
var lessons = await repository.GetByLevelAsync(level);
|
||||||
return Results.Ok(lessons.Select(l => l.ToDto()));
|
return Results.Ok(lessons.Select(l => l.ToDto()));
|
||||||
})
|
})
|
||||||
.RequireAuthorization()
|
|
||||||
.WithName("GetLessonsByLevel")
|
.WithName("GetLessonsByLevel")
|
||||||
.WithOpenApi(operation => new(operation)
|
.WithOpenApi(operation => new(operation)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
using FluentValidation;
|
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Validators;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validator for CreateLessonDto.
|
|
||||||
/// Validates input when creating a new lesson.
|
|
||||||
/// </summary>
|
|
||||||
public class CreateLessonValidator : AbstractValidator<CreateLessonDto>
|
|
||||||
{
|
|
||||||
public CreateLessonValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.Title)
|
|
||||||
.NotEmpty().WithMessage("Lesson title is required.")
|
|
||||||
.MinimumLength(3).WithMessage("Lesson title must be at least 3 characters long.")
|
|
||||||
.MaximumLength(200).WithMessage("Lesson title must not exceed 200 characters.");
|
|
||||||
|
|
||||||
RuleFor(x => x.Description)
|
|
||||||
.MaximumLength(2000).WithMessage("Lesson description must not exceed 2000 characters.");
|
|
||||||
|
|
||||||
RuleFor(x => x.LevelId)
|
|
||||||
.GreaterThan(0).WithMessage("Level ID must be greater than 0.");
|
|
||||||
|
|
||||||
RuleFor(x => x.Order)
|
|
||||||
.GreaterThanOrEqualTo(0).WithMessage("Order must be at least 0.")
|
|
||||||
.LessThanOrEqualTo(1000).WithMessage("Order must not exceed 1000.");
|
|
||||||
|
|
||||||
RuleFor(x => x.Topic)
|
|
||||||
.NotEmpty().WithMessage("Topic is required.")
|
|
||||||
.MinimumLength(2).WithMessage("Topic must be at least 2 characters long.")
|
|
||||||
.MaximumLength(100).WithMessage("Topic must not exceed 100 characters.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validator for UpdateLessonDto.
|
|
||||||
/// Validates input when updating an existing lesson.
|
|
||||||
/// </summary>
|
|
||||||
public class UpdateLessonValidator : AbstractValidator<UpdateLessonDto>
|
|
||||||
{
|
|
||||||
public UpdateLessonValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.Title)
|
|
||||||
.NotEmpty().WithMessage("Lesson title is required.")
|
|
||||||
.MinimumLength(3).WithMessage("Lesson title must be at least 3 characters long.")
|
|
||||||
.MaximumLength(200).WithMessage("Lesson title must not exceed 200 characters.");
|
|
||||||
|
|
||||||
RuleFor(x => x.Description)
|
|
||||||
.MaximumLength(2000).WithMessage("Lesson description must not exceed 2000 characters.");
|
|
||||||
|
|
||||||
RuleFor(x => x.LevelId)
|
|
||||||
.GreaterThan(0).WithMessage("Level ID must be greater than 0.");
|
|
||||||
|
|
||||||
RuleFor(x => x.Order)
|
|
||||||
.GreaterThanOrEqualTo(0).WithMessage("Order must be at least 0.")
|
|
||||||
.LessThanOrEqualTo(1000).WithMessage("Order must not exceed 1000.");
|
|
||||||
|
|
||||||
RuleFor(x => x.Topic)
|
|
||||||
.NotEmpty().WithMessage("Topic is required.")
|
|
||||||
.MinimumLength(2).WithMessage("Topic must be at least 2 characters long.")
|
|
||||||
.MaximumLength(100).WithMessage("Topic must not exceed 100 characters.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
using FluentValidation;
|
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Validators;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validator for CreateLevelDto.
|
|
||||||
/// Validates input when creating a new CEFR level.
|
|
||||||
/// </summary>
|
|
||||||
public class CreateLevelValidator : AbstractValidator<CreateLevelDto>
|
|
||||||
{
|
|
||||||
public CreateLevelValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.Name)
|
|
||||||
.NotEmpty().WithMessage("Level name is required.")
|
|
||||||
.MinimumLength(2).WithMessage("Level name must be at least 2 characters long.")
|
|
||||||
.MaximumLength(100).WithMessage("Level name must not exceed 100 characters.");
|
|
||||||
|
|
||||||
RuleFor(x => x.Code)
|
|
||||||
.NotEmpty().WithMessage("Level code is required.")
|
|
||||||
.MinimumLength(1).WithMessage("Level code must be at least 1 character long.")
|
|
||||||
.MaximumLength(10).WithMessage("Level code must not exceed 10 characters.")
|
|
||||||
.Matches("^[A-Za-z][A-Za-z0-9]*$").WithMessage("Level code must start with a letter and contain only letters and numbers.")
|
|
||||||
.Must(BeValidCefrCode).WithMessage("Level code must be a valid CEFR code (A1, A2, B1, B2, C1, C2).");
|
|
||||||
|
|
||||||
RuleFor(x => x.Order)
|
|
||||||
.GreaterThan(0).WithMessage("Order must be greater than 0.")
|
|
||||||
.LessThanOrEqualTo(100).WithMessage("Order must not exceed 100.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool BeValidCefrCode(string code)
|
|
||||||
{
|
|
||||||
// Normalize to uppercase for comparison
|
|
||||||
var upperCode = code.ToUpperInvariant();
|
|
||||||
|
|
||||||
// Valid CEFR levels
|
|
||||||
var validCodes = new[] { "A1", "A2", "B1", "B2", "C1", "C2" };
|
|
||||||
|
|
||||||
return validCodes.Contains(upperCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validator for UpdateLevelDto.
|
|
||||||
/// Validates input when updating an existing CEFR level.
|
|
||||||
/// </summary>
|
|
||||||
public class UpdateLevelValidator : AbstractValidator<UpdateLevelDto>
|
|
||||||
{
|
|
||||||
public UpdateLevelValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.Name)
|
|
||||||
.NotEmpty().WithMessage("Level name is required.")
|
|
||||||
.MinimumLength(2).WithMessage("Level name must be at least 2 characters long.")
|
|
||||||
.MaximumLength(100).WithMessage("Level name must not exceed 100 characters.");
|
|
||||||
|
|
||||||
RuleFor(x => x.Code)
|
|
||||||
.NotEmpty().WithMessage("Level code is required.")
|
|
||||||
.MinimumLength(1).WithMessage("Level code must be at least 1 character long.")
|
|
||||||
.MaximumLength(10).WithMessage("Level code must not exceed 10 characters.")
|
|
||||||
.Matches("^[A-Za-z][A-Za-z0-9]*$").WithMessage("Level code must start with a letter and contain only letters and numbers.")
|
|
||||||
.Must(BeValidCefrCode).WithMessage("Level code must be a valid CEFR code (A1, A2, B1, B2, C1, C2).");
|
|
||||||
|
|
||||||
RuleFor(x => x.Order)
|
|
||||||
.GreaterThan(0).WithMessage("Order must be greater than 0.")
|
|
||||||
.LessThanOrEqualTo(100).WithMessage("Order must not exceed 100.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool BeValidCefrCode(string code)
|
|
||||||
{
|
|
||||||
// Normalize to uppercase for comparison
|
|
||||||
var upperCode = code.ToUpperInvariant();
|
|
||||||
|
|
||||||
// Valid CEFR levels
|
|
||||||
var validCodes = new[] { "A1", "A2", "B1", "B2", "C1", "C2" };
|
|
||||||
|
|
||||||
return validCodes.Contains(upperCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
using FluentValidation;
|
|
||||||
using FluentValidation.AspNetCore;
|
|
||||||
using GermanApp.Application.DTOs;
|
using GermanApp.Application.DTOs;
|
||||||
using GermanApp.Application.Services;
|
using GermanApp.Application.Services;
|
||||||
using GermanApp.Application.Interfaces;
|
using GermanApp.Application.Interfaces;
|
||||||
|
|
@ -10,19 +8,14 @@ using GermanApp.Infrastructure.Data.DbContext;
|
||||||
using GermanApp.Infrastructure.Data.Repositories;
|
using GermanApp.Infrastructure.Data.Repositories;
|
||||||
using GermanApp.Infrastructure.Data.SeedData;
|
using GermanApp.Infrastructure.Data.SeedData;
|
||||||
using GermanApp.Infrastructure.Services;
|
using GermanApp.Infrastructure.Services;
|
||||||
using GermanApp.Infrastructure.Configuration;
|
|
||||||
using GermanApp.Presentation.Controllers;
|
using GermanApp.Presentation.Controllers;
|
||||||
using GermanApp.Presentation.Validators;
|
|
||||||
using GermanApp.Presentation.Endpoints;
|
using GermanApp.Presentation.Endpoints;
|
||||||
using GermanApp.Shared.Middleware;
|
using GermanApp.Shared.Middleware;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using System.IO;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
// Configure Serilog
|
// Configure Serilog
|
||||||
|
|
@ -31,7 +24,7 @@ Log.Logger = new LoggerConfiguration()
|
||||||
.WriteTo.Console()
|
.WriteTo.Console()
|
||||||
.CreateBootstrapLogger();
|
.CreateBootstrapLogger();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
|
@ -50,8 +43,7 @@ try
|
||||||
|
|
||||||
// Add Health Checks
|
// Add Health Checks
|
||||||
builder.Services.AddHealthChecks()
|
builder.Services.AddHealthChecks()
|
||||||
.AddDbContextCheck<AppDbContext>()
|
.AddDbContextCheck<AppDbContext>();
|
||||||
.AddCheck<AiServicesHealthCheck>("ai_services");
|
|
||||||
|
|
||||||
// Add Password Hasher for custom User entity
|
// Add Password Hasher for custom User entity
|
||||||
builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
|
builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
|
||||||
|
|
@ -78,7 +70,7 @@ try
|
||||||
ValidIssuer = jwtIssuer,
|
ValidIssuer = jwtIssuer,
|
||||||
ValidAudience = jwtAudience,
|
ValidAudience = jwtAudience,
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
||||||
ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes of clock difference
|
ClockSkew = TimeSpan.Zero
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -91,45 +83,15 @@ try
|
||||||
// Configure CORS
|
// Configure CORS
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
// AllowAll policy - for production without credentials
|
|
||||||
// Note: Cannot use AllowAnyOrigin() with AllowCredentials()
|
|
||||||
options.AddPolicy("AllowAll", builder =>
|
options.AddPolicy("AllowAll", builder =>
|
||||||
{
|
{
|
||||||
builder.AllowAnyOrigin()
|
builder.AllowAnyOrigin()
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()
|
||||||
.AllowAnyHeader();
|
.AllowAnyHeader();
|
||||||
// Note: No AllowCredentials() - cannot combine with AllowAnyOrigin()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Development policy - for Docker and local development with credentials
|
|
||||||
options.AddPolicy("Development", builder =>
|
|
||||||
{
|
|
||||||
builder.WithOrigins("http://localhost:5173", "http://localhost:5174", "http://localhost:5175", "http://localhost:3000")
|
|
||||||
.AllowAnyMethod()
|
|
||||||
.AllowAnyHeader()
|
|
||||||
.AllowCredentials();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Docker policy - for when frontend is served from Docker nginx
|
|
||||||
options.AddPolicy("Docker", builder =>
|
|
||||||
{
|
|
||||||
builder.WithOrigins("http://localhost:3000")
|
|
||||||
.AllowAnyMethod()
|
|
||||||
.AllowAnyHeader()
|
|
||||||
.AllowCredentials();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// PRESENTATION LAYER - Validation
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// Add FluentValidation
|
|
||||||
builder.Services.AddValidatorsFromAssemblyContaining<CreateLevelValidator>();
|
|
||||||
builder.Services.AddFluentValidationAutoValidation();
|
|
||||||
|
|
||||||
// Add services for Minimal APIs
|
// Add services for Minimal APIs
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
@ -154,64 +116,11 @@ try
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register repositories (Infrastructure implementations of Domain interfaces)
|
// Register repositories (Infrastructure implementations of Domain interfaces)
|
||||||
builder.Services.AddScoped<IRepository<User, int>, UserRepository>();
|
|
||||||
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
|
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
|
||||||
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
|
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
|
||||||
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
|
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
|
||||||
builder.Services.AddScoped<IQuizRepository, QuizRepository>();
|
|
||||||
builder.Services.AddScoped<IQuizQuestionRepository, QuizQuestionRepository>();
|
|
||||||
builder.Services.AddScoped<IQuizOptionRepository, QuizOptionRepository>();
|
|
||||||
builder.Services.AddScoped<IStoryRepository, StoryRepository>();
|
|
||||||
builder.Services.AddScoped<IStoryProgressRepository, StoryProgressRepository>();
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// INFRASTRUCTURE LAYER - AI Services
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// Add Memory Cache for caching (used by AI services)
|
|
||||||
builder.Services.AddMemoryCache();
|
|
||||||
|
|
||||||
// Add Mistral API configuration
|
|
||||||
builder.Services.Configure<MistralConfig>(builder.Configuration.GetSection("Mistral"));
|
|
||||||
|
|
||||||
// Add HttpClient for Mistral API
|
|
||||||
builder.Services.AddHttpClient("MistralClient");
|
|
||||||
|
|
||||||
// APPLICATION LAYER - Use Cases & Services
|
|
||||||
// ============================================
|
|
||||||
// Register Mistral Connector with null check for config
|
|
||||||
builder.Services.AddScoped<IMistralConnector>(provider =>
|
|
||||||
{
|
|
||||||
var httpClient = provider.GetRequiredService<IHttpClientFactory>().CreateClient("MistralClient");
|
|
||||||
var config = provider.GetRequiredService<IOptions<MistralConfig>>().Value ?? new MistralConfig();
|
|
||||||
var logger = provider.GetRequiredService<ILogger<MistralConnector>>();
|
|
||||||
var cache = provider.GetRequiredService<IMemoryCache>();
|
|
||||||
|
|
||||||
return new MistralConnector(httpClient, config, logger, cache);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add AI service configurations
|
|
||||||
builder.Services.Configure<VoskConfig>(builder.Configuration.GetSection("Vosk"));
|
|
||||||
builder.Services.Configure<CoquiConfig>(builder.Configuration.GetSection("Coqui"));
|
|
||||||
|
|
||||||
// Validate AI service configurations
|
|
||||||
try
|
|
||||||
{
|
|
||||||
ValidateAiConfigurations(builder.Configuration);
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
// Skip validation during EF migrations and design-time when configs may not be fully set
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register AI services (Infrastructure implementations of Domain interfaces)
|
|
||||||
builder.Services.AddScoped<IMistralService, MistralService>();
|
|
||||||
builder.Services.AddScoped<IVoskService, VoskService>();
|
|
||||||
builder.Services.AddScoped<ITtsService, TtsService>();
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// APPLICATION LAYER - Use Cases & Services
|
|
||||||
// ========================================================================================
|
|
||||||
// APPLICATION LAYER - Use Cases & Services
|
// APPLICATION LAYER - Use Cases & Services
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
|
@ -221,65 +130,15 @@ try
|
||||||
builder.Services.AddScoped<LevelService>();
|
builder.Services.AddScoped<LevelService>();
|
||||||
builder.Services.AddScoped<LessonService>();
|
builder.Services.AddScoped<LessonService>();
|
||||||
builder.Services.AddScoped<ProgressService>();
|
builder.Services.AddScoped<ProgressService>();
|
||||||
builder.Services.AddScoped<QuizService>();
|
|
||||||
builder.Services.AddScoped<QuizQuestionService>();
|
|
||||||
builder.Services.AddScoped<LessonUnlockService>();
|
|
||||||
builder.Services.AddScoped<LevelCompletionCalculator>();
|
|
||||||
|
|
||||||
// Register AI higher-level services
|
|
||||||
builder.Services.AddScoped<StoryGenerationService>();
|
|
||||||
builder.Services.AddScoped<WritingFeedbackService>();
|
|
||||||
builder.Services.AddScoped<SpeechExerciseService>();
|
|
||||||
builder.Services.AddScoped<AudioGenerationService>();
|
|
||||||
builder.Services.AddScoped<AiFallbackService>();
|
|
||||||
|
|
||||||
// Register Story services
|
|
||||||
builder.Services.AddScoped<StoryService>();
|
|
||||||
builder.Services.AddScoped<StoryUnlockService>();
|
|
||||||
|
|
||||||
// Register Admin services
|
|
||||||
builder.Services.AddScoped<IAdminService, AdminService>();
|
|
||||||
builder.Services.AddScoped<IUserReportService, UserReportService>();
|
|
||||||
|
|
||||||
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
|
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
|
||||||
|
|
||||||
// Note: QuizQuestionService requires IQuizRepository, ILessonRepository, and ProgressService which are registered above
|
|
||||||
|
|
||||||
// Register Quiz command handlers
|
|
||||||
builder.Services.AddScoped<ICommandHandler<CreateQuizCommand, QuizDto>, CreateQuizCommandHandler>();
|
|
||||||
builder.Services.AddScoped<ICommandHandler<UpdateQuizCommand, QuizDto?>, UpdateQuizCommandHandler>();
|
|
||||||
builder.Services.AddScoped<ICommandHandler<DeleteQuizCommand, bool>, DeleteQuizCommandHandler>();
|
|
||||||
builder.Services.AddScoped<ICommandHandler<GetQuizWithQuestionsCommand, QuizWithQuestionsDto?>, GetQuizWithQuestionsCommandHandler>();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Create static file directories
|
|
||||||
bool isEfDesignTime = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_EF") == "true";
|
|
||||||
if (!isEfDesignTime)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(Path.Combine("wwwroot", "audio", "story"));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Skip during migrations
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
|
|
||||||
// Use exception middleware first (to catch all exceptions)
|
// Use exception middleware first (to catch all exceptions)
|
||||||
app.UseExceptionMiddleware();
|
app.UseExceptionMiddleware();
|
||||||
|
|
||||||
// Use CORS - must be early in the pipeline, before UseAuthorization
|
|
||||||
// In Docker, use "Docker" policy to allow credentials from localhost:3000
|
|
||||||
// In development without Docker, use "Development" policy
|
|
||||||
app.UseCors(app.Environment.IsDevelopment() ? "Development" : "Docker");
|
|
||||||
|
|
||||||
// Serve static files (audio, etc.)
|
|
||||||
app.UseStaticFiles();
|
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
|
|
@ -291,6 +150,9 @@ try
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
// Use CORS
|
||||||
|
app.UseCors("AllowAll");
|
||||||
|
|
||||||
// Use Health Checks
|
// Use Health Checks
|
||||||
app.MapHealthChecks("/health");
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
|
|
@ -303,20 +165,10 @@ try
|
||||||
app.MapLessonsEndpoints();
|
app.MapLessonsEndpoints();
|
||||||
|
|
||||||
// Seed database with initial data
|
// Seed database with initial data
|
||||||
// Run migrations and seed in Development, or just migrations in Production/Docker
|
if (app.Environment.IsDevelopment())
|
||||||
// In Docker, we can control seeding via environment variable
|
|
||||||
var shouldSeed = app.Environment.IsDevelopment() ||
|
|
||||||
(app.Configuration.GetValue<bool>("SeedDatabase"));
|
|
||||||
|
|
||||||
if (shouldSeed)
|
|
||||||
{
|
{
|
||||||
await app.SeedDatabaseAsync();
|
await app.SeedDatabaseAsync();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
// In Production/Docker, ensure migrations are applied with retry logic
|
|
||||||
await ApplyMigrationsWithRetry(app, maxRetries: 10, delaySeconds: 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep original WeatherForecast endpoint for reference
|
// Keep original WeatherForecast endpoint for reference
|
||||||
app.MapGet("/weatherforecast", () =>
|
app.MapGet("/weatherforecast", () =>
|
||||||
|
|
@ -339,69 +191,6 @@ try
|
||||||
.WithName("GetWeatherForecast");
|
.WithName("GetWeatherForecast");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
// Helper method to validate AI service configurations
|
|
||||||
static void ValidateAiConfigurations(IConfiguration configuration)
|
|
||||||
{
|
|
||||||
// Skip validation during EF migrations
|
|
||||||
if (Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_EF") == "true")
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Mistral configuration (only if API key is set)
|
|
||||||
var mistralConfig = configuration.GetSection("Mistral").Get<MistralConfig>() ?? new MistralConfig();
|
|
||||||
if (!string.IsNullOrWhiteSpace(mistralConfig.ApiKey))
|
|
||||||
{
|
|
||||||
mistralConfig.Validate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Vosk configuration (only if model path is set)
|
|
||||||
var voskConfig = configuration.GetSection("Vosk").Get<VoskConfig>() ?? new VoskConfig();
|
|
||||||
if (!string.IsNullOrWhiteSpace(voskConfig.ModelPath))
|
|
||||||
{
|
|
||||||
voskConfig.Validate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Coqui configuration (only if Python path is set)
|
|
||||||
var coquiConfig = configuration.GetSection("Coqui").Get<CoquiConfig>() ?? new CoquiConfig();
|
|
||||||
if (!string.IsNullOrWhiteSpace(coquiConfig.PythonPath))
|
|
||||||
{
|
|
||||||
coquiConfig.Validate();
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information("All AI service configurations validated successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to apply migrations with retry logic for Docker
|
|
||||||
static async Task ApplyMigrationsWithRetry(WebApplication app, int maxRetries, int delaySeconds)
|
|
||||||
{
|
|
||||||
int retryCount = 0;
|
|
||||||
while (retryCount < maxRetries)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var scope = app.Services.CreateScope();
|
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
||||||
dbContext.Database.Migrate();
|
|
||||||
Log.Information("Database migrations applied in Production environment");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
retryCount++;
|
|
||||||
Log.Warning(ex, "Failed to apply database migrations (attempt {Attempt}/{MaxAttempts}). Retrying in {DelaySeconds} seconds...",
|
|
||||||
retryCount, maxRetries, delaySeconds);
|
|
||||||
if (retryCount >= maxRetries)
|
|
||||||
{
|
|
||||||
Log.Fatal(ex, "Failed to apply database migrations after {MaxAttempts} attempts", maxRetries);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
11
GermanApp/appsettings.Development.json
Normal file
11
GermanApp/appsettings.Development.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Debug",
|
||||||
|
"Microsoft.AspNetCore": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=localhost;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
GermanApp/appsettings.Production.json
Normal file
11
GermanApp/appsettings.Production.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Warning",
|
||||||
|
"Microsoft.AspNetCore": "Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=prod-db;Port=5432;Database=DeutschLernen;Username=postgres;Password=${DB_PASSWORD}"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
GermanApp/appsettings.Staging.json
Normal file
11
GermanApp/appsettings.Staging.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=staging-db;Port=5432;Database=DeutschLernen;Username=postgres;Password=${DB_PASSWORD}"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
GermanApp/appsettings.json
Normal file
18
GermanApp/appsettings.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=localhost;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres"
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Key": "your-super-secret-key-at-least-32-characters-long",
|
||||||
|
"Issuer": "DeutschLernen",
|
||||||
|
"Audience": "DeutschLernen",
|
||||||
|
"ExpireHours": 24
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,372 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Application.Services;
|
|
||||||
using GermanApp.Presentation.Controllers;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
||||||
using Moq;
|
|
||||||
|
|
||||||
namespace GermanApp.Tests.Integration.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Integration tests for LessonsController.
|
|
||||||
/// Tests controller behavior with mocked services.
|
|
||||||
/// </summary>
|
|
||||||
[TestClass]
|
|
||||||
public class LessonsControllerTests
|
|
||||||
{
|
|
||||||
private Mock<LessonService>? _mockLessonService;
|
|
||||||
private Mock<ProgressService>? _mockProgressService;
|
|
||||||
private LessonsController? _controller;
|
|
||||||
|
|
||||||
[TestInitialize]
|
|
||||||
public void TestInitialize()
|
|
||||||
{
|
|
||||||
_mockLessonService = new Mock<LessonService>(null!, null!);
|
|
||||||
_mockProgressService = new Mock<ProgressService>(null!, null!, null!);
|
|
||||||
_controller = new LessonsController(_mockLessonService.Object, _mockProgressService.Object);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestCleanup]
|
|
||||||
public void TestCleanup()
|
|
||||||
{
|
|
||||||
_controller = null;
|
|
||||||
_mockLessonService = null;
|
|
||||||
_mockProgressService = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DateTime TestDate => new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
||||||
|
|
||||||
// ==================== GET ALL LESSONS TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LessonsController")]
|
|
||||||
[TestCategory("GetAllLessons")]
|
|
||||||
public async Task GetAllLessons_WithData_ReturnsOkWithLessons()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedLessons = new List<LessonDto>
|
|
||||||
{
|
|
||||||
new LessonDto(1, "Greetings", "Learn basic greetings", 1, "A1", "Beginner A1", 1, "Vocabulary", TestDate, null),
|
|
||||||
new LessonDto(2, "Introductions", "Learn to introduce yourself", 1, "A1", "Beginner A1", 2, "Vocabulary", TestDate, null)
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockLessonService!.Setup(s => s.GetAllLessonsAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLessons);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetAllLessonsAsync();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
var returnedLessons = okResult.Value as IReadOnlyList<LessonDto>;
|
|
||||||
Assert.IsNotNull(returnedLessons);
|
|
||||||
Assert.AreEqual(2, returnedLessons.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LessonsController")]
|
|
||||||
[TestCategory("GetAllLessons")]
|
|
||||||
public async Task GetAllLessons_WithNoData_ReturnsEmptyList()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedLessons = new List<LessonDto>();
|
|
||||||
|
|
||||||
_mockLessonService!.Setup(s => s.GetAllLessonsAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLessons);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetAllLessonsAsync();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
var returnedLessons = okResult.Value as IReadOnlyList<LessonDto>;
|
|
||||||
Assert.IsNotNull(returnedLessons);
|
|
||||||
Assert.AreEqual(0, returnedLessons.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== GET LESSON BY ID TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LessonsController")]
|
|
||||||
[TestCategory("GetLessonById")]
|
|
||||||
public async Task GetLessonById_WithExistingId_ReturnsOkWithLesson()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedLesson = new LessonDto(1, "Greetings", "Learn basic greetings", 1, "A1", "Beginner A1", 1, "Vocabulary", TestDate, null);
|
|
||||||
|
|
||||||
_mockLessonService!.Setup(s => s.GetLessonByIdAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLesson);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetLessonByIdAsync(1);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
var returnedLesson = okResult.Value as LessonDto;
|
|
||||||
Assert.IsNotNull(returnedLesson);
|
|
||||||
Assert.AreEqual(1, returnedLesson.Id);
|
|
||||||
Assert.AreEqual("Greetings", returnedLesson.Title);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LessonsController")]
|
|
||||||
[TestCategory("GetLessonById")]
|
|
||||||
public async Task GetLessonById_WithNonExistingId_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
_mockLessonService!.Setup(s => s.GetLessonByIdAsync(999, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((LessonDto?)null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetLessonByIdAsync(999);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== GET LESSONS BY LEVEL TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LessonsController")]
|
|
||||||
[TestCategory("GetLessonsByLevel")]
|
|
||||||
public async Task GetLessonsByLevel_WithExistingLevel_ReturnsOkWithLessons()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedLessons = new List<LessonDto>
|
|
||||||
{
|
|
||||||
new LessonDto(1, "Greetings", "Learn basic greetings", 1, "A1", "Beginner A1", 1, "Vocabulary", TestDate, null)
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockLessonService!.Setup(s => s.GetLessonsByLevelAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLessons);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetLessonsByLevelAsync(1);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
var returnedLessons = okResult.Value as IReadOnlyList<LessonDto>;
|
|
||||||
Assert.IsNotNull(returnedLessons);
|
|
||||||
Assert.AreEqual(1, returnedLessons.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LessonsController")]
|
|
||||||
[TestCategory("GetLessonsByLevel")]
|
|
||||||
public async Task GetLessonsByLevel_WithNonExistingLevel_ReturnsEmptyList()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedLessons = new List<LessonDto>();
|
|
||||||
|
|
||||||
_mockLessonService!.Setup(s => s.GetLessonsByLevelAsync(999, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLessons);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetLessonsByLevelAsync(999);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
var returnedLessons = okResult.Value as IReadOnlyList<LessonDto>;
|
|
||||||
Assert.IsNotNull(returnedLessons);
|
|
||||||
Assert.AreEqual(0, returnedLessons.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== CREATE LESSON TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LessonsController")]
|
|
||||||
[TestCategory("CreateLesson")]
|
|
||||||
public async Task CreateLesson_WithValidData_ReturnsCreatedAtAction()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var createDto = new CreateLessonDto("Greetings", "Learn basic greetings", 1, 1, "Vocabulary");
|
|
||||||
var expectedLesson = new LessonDto(1, "Greetings", "Learn basic greetings", 1, "A1", "Beginner A1", 1, "Vocabulary", TestDate, null);
|
|
||||||
|
|
||||||
_mockLessonService!.Setup(s => s.CreateLessonAsync(createDto, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLesson);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.CreateLessonAsync(createDto);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(CreatedAtActionResult));
|
|
||||||
var createdResult = result as CreatedAtActionResult;
|
|
||||||
Assert.IsNotNull(createdResult);
|
|
||||||
var returnedLesson = createdResult.Value as LessonDto;
|
|
||||||
Assert.IsNotNull(returnedLesson);
|
|
||||||
Assert.AreEqual(1, returnedLesson.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== UPDATE LESSON TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LessonsController")]
|
|
||||||
[TestCategory("UpdateLesson")]
|
|
||||||
public async Task UpdateLesson_WithExistingId_ReturnsOkWithUpdatedLesson()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var updateDto = new UpdateLessonDto("Updated Greetings", "Updated description", 1, 1, "Updated Topic");
|
|
||||||
var expectedLesson = new LessonDto(1, "Updated Greetings", "Updated description", 1, "A1", "Beginner A1", 1, "Updated Topic", TestDate, TestDate);
|
|
||||||
|
|
||||||
_mockLessonService!.Setup(s => s.UpdateLessonAsync(1, updateDto, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLesson);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.UpdateLessonAsync(1, updateDto);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
var returnedLesson = okResult.Value as LessonDto;
|
|
||||||
Assert.IsNotNull(returnedLesson);
|
|
||||||
Assert.AreEqual("Updated Greetings", returnedLesson.Title);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LessonsController")]
|
|
||||||
[TestCategory("UpdateLesson")]
|
|
||||||
public async Task UpdateLesson_WithNonExistingId_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var updateDto = new UpdateLessonDto("Updated Greetings", "Updated description", 1, 1, "Updated Topic");
|
|
||||||
|
|
||||||
_mockLessonService!.Setup(s => s.UpdateLessonAsync(999, updateDto, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((LessonDto?)null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.UpdateLessonAsync(999, updateDto);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== DELETE LESSON TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LessonsController")]
|
|
||||||
[TestCategory("DeleteLesson")]
|
|
||||||
public async Task DeleteLesson_WithExistingId_ReturnsNoContent()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
_mockLessonService!.Setup(s => s.DeleteLessonAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.DeleteLessonAsync(1);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(NoContentResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LessonsController")]
|
|
||||||
[TestCategory("DeleteLesson")]
|
|
||||||
public async Task DeleteLesson_WithNonExistingId_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
_mockLessonService!.Setup(s => s.DeleteLessonAsync(999, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(false);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.DeleteLessonAsync(999);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== GET FIRST LESSON IN LEVEL TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LessonsController")]
|
|
||||||
[TestCategory("GetFirstLessonInLevel")]
|
|
||||||
public async Task GetFirstLessonInLevel_WithExistingLevel_ReturnsOkWithLesson()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedLesson = new LessonDto(1, "Greetings", "Learn basic greetings", 1, "A1", "Beginner A1", 1, "Vocabulary", TestDate, null);
|
|
||||||
|
|
||||||
_mockLessonService!.Setup(s => s.GetFirstLessonInLevelAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLesson);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetFirstLessonInLevelAsync(1);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
var returnedLesson = okResult.Value as LessonDto;
|
|
||||||
Assert.IsNotNull(returnedLesson);
|
|
||||||
Assert.AreEqual(1, returnedLesson.Order);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LessonsController")]
|
|
||||||
[TestCategory("GetFirstLessonInLevel")]
|
|
||||||
public async Task GetFirstLessonInLevel_WithNonExistingLevel_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
_mockLessonService!.Setup(s => s.GetFirstLessonInLevelAsync(999, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((LessonDto?)null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetFirstLessonInLevelAsync(999);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== IS NEXT LESSON UNLOCKED TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LessonsController")]
|
|
||||||
[TestCategory("IsNextLessonUnlocked")]
|
|
||||||
public async Task IsNextLessonUnlocked_WithCompletedPrevious_ReturnsTrue()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
_mockProgressService!.Setup(s => s.IsNextLessonUnlockedAsync(1, 1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.IsNextLessonUnlockedAsync(1, 1);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
Assert.AreEqual(true, okResult.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LessonsController")]
|
|
||||||
[TestCategory("IsNextLessonUnlocked")]
|
|
||||||
public async Task IsNextLessonUnlocked_WithIncompletePrevious_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
_mockProgressService!.Setup(s => s.IsNextLessonUnlockedAsync(1, 1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(false);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.IsNextLessonUnlockedAsync(1, 1);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
Assert.AreEqual(false, okResult.Value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,358 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Application.Services;
|
|
||||||
using GermanApp.Presentation.Controllers;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
||||||
using Moq;
|
|
||||||
|
|
||||||
namespace GermanApp.Tests.Integration.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Integration tests for LevelsController.
|
|
||||||
/// Tests controller behavior with mocked services.
|
|
||||||
/// </summary>
|
|
||||||
[TestClass]
|
|
||||||
public class LevelsControllerTests
|
|
||||||
{
|
|
||||||
private Mock<LevelService>? _mockLevelService;
|
|
||||||
private LevelsController? _controller;
|
|
||||||
|
|
||||||
[TestInitialize]
|
|
||||||
public void TestInitialize()
|
|
||||||
{
|
|
||||||
_mockLevelService = new Mock<LevelService>(null!);
|
|
||||||
_controller = new LevelsController(_mockLevelService.Object);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestCleanup]
|
|
||||||
public void TestCleanup()
|
|
||||||
{
|
|
||||||
_controller = null;
|
|
||||||
_mockLevelService = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== GET ALL LEVELS TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LevelsController")]
|
|
||||||
[TestCategory("GetAllLevels")]
|
|
||||||
public async Task GetAllLevels_WithData_ReturnsOkWithLevels()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedLevels = new List<LevelDto>
|
|
||||||
{
|
|
||||||
new LevelDto(1, "Beginner A1", "A1", 1),
|
|
||||||
new LevelDto(2, "Elementary A2", "A2", 2)
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockLevelService!.Setup(s => s.GetAllLevelsAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLevels);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetAllLevelsAsync();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
var returnedLevels = okResult.Value as IReadOnlyList<LevelDto>;
|
|
||||||
Assert.IsNotNull(returnedLevels);
|
|
||||||
Assert.AreEqual(2, returnedLevels.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LevelsController")]
|
|
||||||
[TestCategory("GetAllLevels")]
|
|
||||||
public async Task GetAllLevels_WithNoData_ReturnsEmptyList()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedLevels = new List<LevelDto>();
|
|
||||||
|
|
||||||
_mockLevelService!.Setup(s => s.GetAllLevelsAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLevels);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetAllLevelsAsync();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
var returnedLevels = okResult.Value as IReadOnlyList<LevelDto>;
|
|
||||||
Assert.IsNotNull(returnedLevels);
|
|
||||||
Assert.AreEqual(0, returnedLevels.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== GET LEVEL BY ID TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LevelsController")]
|
|
||||||
[TestCategory("GetLevelById")]
|
|
||||||
public async Task GetLevelById_WithExistingId_ReturnsOkWithLevel()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedLevel = new LevelDto(1, "Beginner A1", "A1", 1);
|
|
||||||
|
|
||||||
_mockLevelService!.Setup(s => s.GetLevelByIdAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLevel);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetLevelByIdAsync(1);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
var returnedLevel = okResult.Value as LevelDto;
|
|
||||||
Assert.IsNotNull(returnedLevel);
|
|
||||||
Assert.AreEqual(1, returnedLevel.Id);
|
|
||||||
Assert.AreEqual("Beginner A1", returnedLevel.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LevelsController")]
|
|
||||||
[TestCategory("GetLevelById")]
|
|
||||||
public async Task GetLevelById_WithNonExistingId_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
_mockLevelService!.Setup(s => s.GetLevelByIdAsync(999, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((LevelDto?)null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetLevelByIdAsync(999);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== GET LEVEL BY CODE TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LevelsController")]
|
|
||||||
[TestCategory("GetLevelByCode")]
|
|
||||||
public async Task GetLevelByCode_WithExistingCode_ReturnsOkWithLevel()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedLevel = new LevelDto(1, "Beginner A1", "A1", 1);
|
|
||||||
|
|
||||||
_mockLevelService!.Setup(s => s.GetLevelByCodeAsync("A1", It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLevel);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetLevelByCodeAsync("A1");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
var returnedLevel = okResult.Value as LevelDto;
|
|
||||||
Assert.IsNotNull(returnedLevel);
|
|
||||||
Assert.AreEqual("A1", returnedLevel.Code);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LevelsController")]
|
|
||||||
[TestCategory("GetLevelByCode")]
|
|
||||||
public async Task GetLevelByCode_WithNonExistingCode_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
_mockLevelService!.Setup(s => s.GetLevelByCodeAsync("INVALID", It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((LevelDto?)null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetLevelByCodeAsync("INVALID");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== CREATE LEVEL TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LevelsController")]
|
|
||||||
[TestCategory("CreateLevel")]
|
|
||||||
public async Task CreateLevel_WithValidData_ReturnsCreatedAtAction()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var createDto = new CreateLevelDto("Beginner A1", "A1", 1);
|
|
||||||
var expectedLevel = new LevelDto(1, "Beginner A1", "A1", 1);
|
|
||||||
|
|
||||||
_mockLevelService!.Setup(s => s.CreateLevelAsync(createDto, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLevel);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.CreateLevelAsync(createDto);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(CreatedAtActionResult));
|
|
||||||
var createdResult = result as CreatedAtActionResult;
|
|
||||||
Assert.IsNotNull(createdResult);
|
|
||||||
var returnedLevel = createdResult.Value as LevelDto;
|
|
||||||
Assert.IsNotNull(returnedLevel);
|
|
||||||
Assert.AreEqual(1, returnedLevel.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== UPDATE LEVEL TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LevelsController")]
|
|
||||||
[TestCategory("UpdateLevel")]
|
|
||||||
public async Task UpdateLevel_WithExistingId_ReturnsOkWithUpdatedLevel()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var updateDto = new UpdateLevelDto("Updated A1", "A1", 1);
|
|
||||||
var expectedLevel = new LevelDto(1, "Updated A1", "A1", 1);
|
|
||||||
|
|
||||||
_mockLevelService!.Setup(s => s.UpdateLevelAsync(1, updateDto, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLevel);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.UpdateLevelAsync(1, updateDto);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
var returnedLevel = okResult.Value as LevelDto;
|
|
||||||
Assert.IsNotNull(returnedLevel);
|
|
||||||
Assert.AreEqual("Updated A1", returnedLevel.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LevelsController")]
|
|
||||||
[TestCategory("UpdateLevel")]
|
|
||||||
public async Task UpdateLevel_WithNonExistingId_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var updateDto = new UpdateLevelDto("Updated A1", "A1", 1);
|
|
||||||
|
|
||||||
_mockLevelService!.Setup(s => s.UpdateLevelAsync(999, updateDto, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((LevelDto?)null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.UpdateLevelAsync(999, updateDto);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== DELETE LEVEL TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LevelsController")]
|
|
||||||
[TestCategory("DeleteLevel")]
|
|
||||||
public async Task DeleteLevel_WithExistingId_ReturnsNoContent()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
_mockLevelService!.Setup(s => s.DeleteLevelAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.DeleteLevelAsync(1);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(NoContentResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LevelsController")]
|
|
||||||
[TestCategory("DeleteLevel")]
|
|
||||||
public async Task DeleteLevel_WithNonExistingId_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
_mockLevelService!.Setup(s => s.DeleteLevelAsync(999, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(false);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.DeleteLevelAsync(999);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== GET FIRST LEVEL TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LevelsController")]
|
|
||||||
[TestCategory("GetFirstLevel")]
|
|
||||||
public async Task GetFirstLevel_WithData_ReturnsOkWithLevel()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedLevel = new LevelDto(1, "Beginner A1", "A1", 1);
|
|
||||||
|
|
||||||
_mockLevelService!.Setup(s => s.GetFirstLevelAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLevel);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetFirstLevelAsync();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
var returnedLevel = okResult.Value as LevelDto;
|
|
||||||
Assert.IsNotNull(returnedLevel);
|
|
||||||
Assert.AreEqual(1, returnedLevel.Order);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LevelsController")]
|
|
||||||
[TestCategory("GetFirstLevel")]
|
|
||||||
public async Task GetFirstLevel_WithNoData_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
_mockLevelService!.Setup(s => s.GetFirstLevelAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((LevelDto?)null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetFirstLevelAsync();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== GET NEXT LEVEL TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LevelsController")]
|
|
||||||
[TestCategory("GetNextLevel")]
|
|
||||||
public async Task GetNextLevel_WithExistingCurrentLevel_ReturnsOkWithNextLevel()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedLevel = new LevelDto(2, "Elementary A2", "A2", 2);
|
|
||||||
|
|
||||||
_mockLevelService!.Setup(s => s.GetNextLevelAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedLevel);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetNextLevelAsync(1);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
var returnedLevel = okResult.Value as LevelDto;
|
|
||||||
Assert.IsNotNull(returnedLevel);
|
|
||||||
Assert.AreEqual(2, returnedLevel.Order);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("LevelsController")]
|
|
||||||
[TestCategory("GetNextLevel")]
|
|
||||||
public async Task GetNextLevel_WithNoNextLevel_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
_mockLevelService!.Setup(s => s.GetNextLevelAsync(5, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((LevelDto?)null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.GetNextLevelAsync(5);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(NotFoundResult));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Application.Services;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using GermanApp.Presentation.Controllers;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
||||||
using Moq;
|
|
||||||
|
|
||||||
namespace GermanApp.Tests.Integration.Presentation.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Integration tests for StoryController.
|
|
||||||
/// Tests the full controller -> service -> repository flow.
|
|
||||||
/// </summary>
|
|
||||||
[TestClass]
|
|
||||||
public class StoryControllerTests
|
|
||||||
{
|
|
||||||
private Mock<ILessonRepository> _lessonRepositoryMock;
|
|
||||||
private Mock<IStoryRepository> _storyRepositoryMock;
|
|
||||||
private Mock<StoryGenerationService> _generationServiceMock;
|
|
||||||
private Mock<StoryService> _storyServiceMock;
|
|
||||||
private Mock<StoryUnlockService> _unlockServiceMock;
|
|
||||||
private Mock<ILevelRepository> _levelRepositoryMock;
|
|
||||||
private Mock<ILogger<StoryController>> _loggerMock;
|
|
||||||
private StoryController _controller;
|
|
||||||
|
|
||||||
[TestInitialize]
|
|
||||||
public void TestInitialize()
|
|
||||||
{
|
|
||||||
_lessonRepositoryMock = new Mock<ILessonRepository>();
|
|
||||||
_storyRepositoryMock = new Mock<IStoryRepository>();
|
|
||||||
_generationServiceMock = new Mock<StoryGenerationService>();
|
|
||||||
_storyServiceMock = new Mock<StoryService>();
|
|
||||||
_unlockServiceMock = new Mock<StoryUnlockService>();
|
|
||||||
_levelRepositoryMock = new Mock<ILevelRepository>();
|
|
||||||
_loggerMock = new Mock<ILogger<StoryController>>();
|
|
||||||
|
|
||||||
_controller = new StoryController(
|
|
||||||
_storyServiceMock.Object,
|
|
||||||
_generationServiceMock.Object,
|
|
||||||
_unlockServiceMock.Object,
|
|
||||||
_levelRepositoryMock.Object,
|
|
||||||
_lessonRepositoryMock.Object,
|
|
||||||
_loggerMock.Object);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestCleanup]
|
|
||||||
public void TestCleanup()
|
|
||||||
{
|
|
||||||
_controller?.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Story Generation Tests
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateStoryAsync_ReturnsSuccess_WhenLessonsExistAndGenerationSucceeds()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
int levelId = 1;
|
|
||||||
string theme = "Adventure";
|
|
||||||
var request = new StoryGenerationRequestDto(theme);
|
|
||||||
|
|
||||||
var lessons = new List<Lesson>
|
|
||||||
{
|
|
||||||
Lesson.Create(1, "Greetings", 1, "Greetings", "Basic greetings"),
|
|
||||||
Lesson.Create(1, "Numbers", 2, "Numbers", "German numbers 1-100")
|
|
||||||
};
|
|
||||||
|
|
||||||
var expectedResponse = new StoryGenerationResponseDto(
|
|
||||||
levelId,
|
|
||||||
theme,
|
|
||||||
2,
|
|
||||||
"Full story text here",
|
|
||||||
new List<StorySegmentDto>());
|
|
||||||
|
|
||||||
_lessonRepositoryMock.Setup(r => r.GetByLevelAsync(levelId, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(lessons);
|
|
||||||
|
|
||||||
_generationServiceMock.Setup(s => s.GenerateStoryAsync(
|
|
||||||
levelId,
|
|
||||||
theme,
|
|
||||||
lessons,
|
|
||||||
It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedResponse);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller.GenerateStoryAsync(levelId, request, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result.Result, typeof(OkObjectResult));
|
|
||||||
var okResult = result.Result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
Assert.IsInstanceOfType(okResult.Value, typeof(StoryGenerationResponseDto));
|
|
||||||
var response = okResult.Value as StoryGenerationResponseDto;
|
|
||||||
|
|
||||||
Assert.AreEqual(levelId, response.LevelId);
|
|
||||||
Assert.AreEqual(theme, response.Theme);
|
|
||||||
Assert.AreEqual(2, response.SegmentCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateStoryAsync_ReturnsBadRequest_WhenNoLessonsFound()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
int levelId = 999; // Non-existent level
|
|
||||||
string theme = "Adventure";
|
|
||||||
var request = new StoryGenerationRequestDto(theme);
|
|
||||||
|
|
||||||
_lessonRepositoryMock.Setup(r => r.GetByLevelAsync(levelId, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(new List<Lesson>()); // Empty list
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller.GenerateStoryAsync(levelId, request, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result.Result, typeof(BadRequestObjectResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Story Segment Audio Generation Tests
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateSegmentAudioAsync_ReturnsSuccess_WhenSegmentExists()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
int segmentId = 1;
|
|
||||||
var expectedDto = new StorySegmentDto
|
|
||||||
{
|
|
||||||
Id = segmentId,
|
|
||||||
AudioUrl = "/audio/story/level1-segment1.wav"
|
|
||||||
};
|
|
||||||
|
|
||||||
_storyServiceMock.Setup(s => s.GenerateAudioAsync(segmentId, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedDto);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller.GenerateSegmentAudioAsync(segmentId, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result.Result, typeof(OkObjectResult));
|
|
||||||
var okResult = result.Result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
Assert.IsInstanceOfType(okResult.Value, typeof(StorySegmentDto));
|
|
||||||
var response = okResult.Value as StorySegmentDto;
|
|
||||||
|
|
||||||
Assert.AreEqual(segmentId, response.Id);
|
|
||||||
Assert.AreEqual(expectedDto.AudioUrl, response.AudioUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateSegmentAudioAsync_ReturnsNotFound_WhenSegmentDoesNotExist()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
int segmentId = 999; // Non-existent segment
|
|
||||||
|
|
||||||
_storyServiceMock.Setup(s => s.GenerateAudioAsync(segmentId, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((StorySegmentDto?)null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller.GenerateSegmentAudioAsync(segmentId, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result.Result, typeof(NotFoundResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Get Stories by Level Tests
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetByLevelAsync_ReturnsSuccess_WhenSegmentsExist()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
int levelId = 1;
|
|
||||||
var expectedSegments = new List<StorySegmentDto>
|
|
||||||
{
|
|
||||||
new StorySegmentDto { Id = 1, LevelId = levelId, Order = 1, Title = "Part 1" },
|
|
||||||
new StorySegmentDto { Id = 2, LevelId = levelId, Order = 2, Title = "Part 2" }
|
|
||||||
};
|
|
||||||
|
|
||||||
_storyServiceMock.Setup(s => s.GetByLevelAsync(levelId, false, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedSegments);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller.GetByLevelAsync(levelId, CancellationToken.None);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result.Result, typeof(OkObjectResult));
|
|
||||||
var okResult = result.Result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
Assert.IsInstanceOfType(okResult.Value, typeof(List<StorySegmentDto>));
|
|
||||||
var response = okResult.Value as List<StorySegmentDto>;
|
|
||||||
|
|
||||||
Assert.AreEqual(2, response.Count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,390 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Application.Services;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
||||||
using Moq;
|
|
||||||
|
|
||||||
namespace GermanApp.Tests.Unit.Application.Services;
|
|
||||||
|
|
||||||
[TestClass]
|
|
||||||
public class AiFallbackServiceTests
|
|
||||||
{
|
|
||||||
private Mock<IMistralService> _mockMistralService;
|
|
||||||
private Mock<IVoskService> _mockVoskService;
|
|
||||||
private Mock<ITtsService> _mockTtsService;
|
|
||||||
private Mock<ILogger<AiFallbackService>> _mockLogger;
|
|
||||||
private AiFallbackService _service;
|
|
||||||
private readonly byte[] _sampleAudio = new byte[100];
|
|
||||||
|
|
||||||
[TestInitialize]
|
|
||||||
public void Setup()
|
|
||||||
{
|
|
||||||
_mockMistralService = new Mock<IMistralService>();
|
|
||||||
_mockVoskService = new Mock<IVoskService>();
|
|
||||||
_mockTtsService = new Mock<ITtsService>();
|
|
||||||
_mockLogger = new Mock<ILogger<AiFallbackService>>();
|
|
||||||
_service = new AiFallbackService(
|
|
||||||
_mockMistralService.Object,
|
|
||||||
_mockVoskService.Object,
|
|
||||||
_mockTtsService.Object,
|
|
||||||
_mockLogger.Object);
|
|
||||||
|
|
||||||
// Initialize sample audio
|
|
||||||
for (int i = 0; i < _sampleAudio.Length; i++)
|
|
||||||
{
|
|
||||||
_sampleAudio[i] = (byte)(i % 256);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateStoryWithFallbackAsync_WhenPrimaryWorks_ReturnsPrimaryStory()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedStory = "Generated by AI...";
|
|
||||||
var level = "A1";
|
|
||||||
var topic = "Travel";
|
|
||||||
var vocabularyWords = new List<string> { "Bahn", "Reise" };
|
|
||||||
var length = 200;
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockMistralService.Setup(s => s.GenerateStoryAsync(
|
|
||||||
level, topic, vocabularyWords, length, cancellationToken))
|
|
||||||
.ReturnsAsync(expectedStory);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateStoryWithFallbackAsync(
|
|
||||||
level, topic, vocabularyWords, length, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(expectedStory, result);
|
|
||||||
_mockMistralService.Verify(s => s.GenerateStoryAsync(
|
|
||||||
level, topic, vocabularyWords, length, cancellationToken), Times.Once);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateStoryWithFallbackAsync_WhenPrimaryFails_ReturnsFallback()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var level = "A1";
|
|
||||||
var topic = "Travel";
|
|
||||||
var vocabularyWords = new List<string> { "Bahn", "Reise" };
|
|
||||||
var length = 200;
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockMistralService.Setup(s => s.GenerateStoryAsync(
|
|
||||||
level, topic, vocabularyWords, length, cancellationToken))
|
|
||||||
.ThrowsAsync(new Exception("AI Error"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateStoryWithFallbackAsync(
|
|
||||||
level, topic, vocabularyWords, length, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.IsTrue(result.Contains("einfacher")); // Level description for A1
|
|
||||||
Assert.IsTrue(result.Contains("Travel"));
|
|
||||||
Assert.IsTrue(result.Contains("Story"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateStoryWithFallbackAsync_WithNoMistralService_ReturnsFallback()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var level = "A1";
|
|
||||||
var topic = "Travel";
|
|
||||||
var vocabularyWords = new List<string> { "Bahn" };
|
|
||||||
var length = 200;
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
// Create service with null MistralService
|
|
||||||
var service = new AiFallbackService(
|
|
||||||
null, _mockVoskService.Object, _mockTtsService.Object, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await service.GenerateStoryWithFallbackAsync(
|
|
||||||
level, topic, vocabularyWords, length, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.IsTrue(result.Contains("einfacher")); // Level description for A1
|
|
||||||
Assert.IsTrue(result.Contains("Travel"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task ProvideFeedbackWithFallbackAsync_WhenPrimaryWorks_ReturnsPrimaryFeedback()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedFeedback = "Great job!";
|
|
||||||
var userText = "Ich heisse Anna.";
|
|
||||||
var level = "A1";
|
|
||||||
var customPrompt = "Focus on grammar";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockMistralService.Setup(s => s.GenerateWritingFeedbackAsync(
|
|
||||||
userText, level, customPrompt, cancellationToken))
|
|
||||||
.ReturnsAsync(expectedFeedback);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.ProvideFeedbackWithFallbackAsync(
|
|
||||||
userText, level, customPrompt, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(expectedFeedback, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task ProvideFeedbackWithFallbackAsync_WhenPrimaryFails_ReturnsFallback()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var userText = "Ich heisse Anna.";
|
|
||||||
var level = "A1";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockMistralService.Setup(s => s.GenerateWritingFeedbackAsync(
|
|
||||||
userText, level, It.IsAny<string?>(), cancellationToken))
|
|
||||||
.ThrowsAsync(new Exception("AI Error"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.ProvideFeedbackWithFallbackAsync(
|
|
||||||
userText, level, null, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.IsTrue(result.Contains("A1"));
|
|
||||||
Assert.IsTrue(result.Contains("feedback") || result.Contains("Feedback"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task RecognizeSpeechWithFallbackAsync_WhenPrimaryWorks_ReturnsPrimaryText()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedText = "Hallo Welt";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockVoskService.Setup(s => s.RecognizeSpeechAsync(
|
|
||||||
_sampleAudio, 16000, It.IsAny<string?>(), cancellationToken))
|
|
||||||
.ReturnsAsync(expectedText);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.RecognizeSpeechWithFallbackAsync(
|
|
||||||
_sampleAudio, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(expectedText, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task RecognizeSpeechWithFallbackAsync_WhenPrimaryFails_ReturnsFallback()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockVoskService.Setup(s => s.RecognizeSpeechAsync(
|
|
||||||
It.IsAny<byte[]>(), 16000, It.IsAny<string?>(), cancellationToken))
|
|
||||||
.ThrowsAsync(new Exception("Recognition Error"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.RecognizeSpeechWithFallbackAsync(
|
|
||||||
_sampleAudio, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.IsTrue(result.Contains("unavailable") || result.Contains("Unavailable"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateAudioWithFallbackAsync_WhenPrimaryWorks_ReturnsPrimaryAudio()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var text = "Hallo Welt";
|
|
||||||
var language = "de";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockTtsService.Setup(s => s.GenerateAudioAsync(
|
|
||||||
text, It.IsAny<string?>(), language, cancellationToken))
|
|
||||||
.ReturnsAsync(_sampleAudio);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateAudioWithFallbackAsync(
|
|
||||||
text, language, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(_sampleAudio.Length, result.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateAudioWithFallbackAsync_WhenPrimaryFails_ReturnsEmpty()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var text = "Hallo Welt";
|
|
||||||
var language = "de";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockTtsService.Setup(s => s.GenerateAudioAsync(
|
|
||||||
text, It.IsAny<string?>(), language, cancellationToken))
|
|
||||||
.ThrowsAsync(new Exception("TTS Error"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateAudioWithFallbackAsync(
|
|
||||||
text, language, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(0, result.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task CheckServiceHealthAsync_WithAllServicesHealthy_ReturnsAllTrue()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockMistralService.Setup(s => s.TestConnectionAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
_mockVoskService.Setup(s => s.TestModelAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
_mockTtsService.Setup(s => s.TestModelAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.CheckServiceHealthAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(3, result.Count);
|
|
||||||
Assert.IsTrue(result["Mistral"]);
|
|
||||||
Assert.IsTrue(result["Vosk"]);
|
|
||||||
Assert.IsTrue(result["CoquiTTS"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task CheckServiceHealthAsync_WithSomeServicesUnhealthy_ReturnsMixed()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockMistralService.Setup(s => s.TestConnectionAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
_mockVoskService.Setup(s => s.TestModelAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(false);
|
|
||||||
_mockTtsService.Setup(s => s.TestModelAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ThrowsAsync(new Exception("Error"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.CheckServiceHealthAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.IsTrue(result["Mistral"]);
|
|
||||||
Assert.IsFalse(result["Vosk"]);
|
|
||||||
Assert.IsFalse(result["CoquiTTS"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task CheckServiceHealthAsync_WithNullServices_ReturnsAllFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
var service = new AiFallbackService(
|
|
||||||
null, null, null, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await service.CheckServiceHealthAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.IsFalse(result["Mistral"]);
|
|
||||||
Assert.IsFalse(result["Vosk"]);
|
|
||||||
Assert.IsFalse(result["CoquiTTS"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetServiceStatusMessageAsync_WithAllHealthy_ReturnsAvailableMessage()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockMistralService.Setup(s => s.TestConnectionAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
_mockVoskService.Setup(s => s.TestModelAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
_mockTtsService.Setup(s => s.TestModelAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GetServiceStatusMessageAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.IsTrue(result.Contains("Available"));
|
|
||||||
Assert.IsTrue(result.Contains("Mistral"));
|
|
||||||
Assert.IsTrue(result.Contains("Vosk"));
|
|
||||||
Assert.IsTrue(result.Contains("CoquiTTS"));
|
|
||||||
Assert.IsFalse(result.Contains("Unavailable"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetServiceStatusMessageAsync_WithSomeUnhealthy_ReturnsMixedMessage()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockMistralService.Setup(s => s.TestConnectionAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
_mockVoskService.Setup(s => s.TestModelAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(false);
|
|
||||||
_mockTtsService.Setup(s => s.TestModelAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ThrowsAsync(new Exception("Error"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GetServiceStatusMessageAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.IsTrue(result.Contains("Available"));
|
|
||||||
Assert.IsTrue(result.Contains("Unavailable"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task TestServiceAsync_WithWorkingServices_ReturnsTrue()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockMistralService.Setup(s => s.TestConnectionAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
_mockVoskService.Setup(s => s.TestModelAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
_mockTtsService.Setup(s => s.TestModelAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.TestServiceAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsTrue(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task TestServiceAsync_WithAllNullServices_ReturnsTrue()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
var service = new AiFallbackService(null, null, null, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await service.TestServiceAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
// The fallback service's TestServiceAsync tests the fallback methods themselves
|
|
||||||
// which always work (they don't depend on external services)
|
|
||||||
// So it should return true even with null services
|
|
||||||
Assert.IsTrue(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,398 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Application.Services;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
||||||
using Moq;
|
|
||||||
|
|
||||||
namespace GermanApp.Tests.Unit.Application.Services;
|
|
||||||
|
|
||||||
[TestClass]
|
|
||||||
public class AudioGenerationServiceTests
|
|
||||||
{
|
|
||||||
private Mock<ITtsService> _mockTtsService;
|
|
||||||
private Mock<ILogger<AudioGenerationService>> _mockLogger;
|
|
||||||
private AudioGenerationService _service;
|
|
||||||
private readonly byte[] _sampleAudio = new byte[1000];
|
|
||||||
|
|
||||||
[TestInitialize]
|
|
||||||
public void Setup()
|
|
||||||
{
|
|
||||||
_mockTtsService = new Mock<ITtsService>();
|
|
||||||
_mockLogger = new Mock<ILogger<AudioGenerationService>>();
|
|
||||||
_service = new AudioGenerationService(
|
|
||||||
_mockTtsService.Object,
|
|
||||||
_mockLogger.Object);
|
|
||||||
|
|
||||||
// Initialize sample audio
|
|
||||||
for (int i = 0; i < _sampleAudio.Length; i++)
|
|
||||||
{
|
|
||||||
_sampleAudio[i] = (byte)(i % 256);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestCleanup]
|
|
||||||
public void Cleanup()
|
|
||||||
{
|
|
||||||
// Clean up any temp files
|
|
||||||
if (File.Exists("/tmp/test_output.wav"))
|
|
||||||
{
|
|
||||||
File.Delete("/tmp/test_output.wav");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateAudioAsync_WithValidText_ReturnsAudio()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var text = "Hallo Welt";
|
|
||||||
var language = "de";
|
|
||||||
var speaker = "default";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockTtsService.Setup(s => s.GenerateAudioAsync(
|
|
||||||
text, speaker, language, cancellationToken))
|
|
||||||
.ReturnsAsync(_sampleAudio);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateAudioAsync(
|
|
||||||
text, language, speaker, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(_sampleAudio.Length, result.Length);
|
|
||||||
_mockTtsService.Verify(s => s.GenerateAudioAsync(
|
|
||||||
text, speaker, language, cancellationToken), Times.Once);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateAudioAsync_WithDefaultLanguage_UsesGerman()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var text = "Hallo Welt";
|
|
||||||
var speaker = "default";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockTtsService.Setup(s => s.GenerateAudioAsync(
|
|
||||||
text, speaker, "de", cancellationToken))
|
|
||||||
.ReturnsAsync(_sampleAudio);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateAudioAsync(
|
|
||||||
text, speaker: speaker, cancellationToken: cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateAudioAsync_WhenTtsThrows_ThrowsAiServiceException()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var text = "Hallo Welt";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockTtsService.Setup(s => s.GenerateAudioAsync(
|
|
||||||
text, null, "de", cancellationToken))
|
|
||||||
.ThrowsAsync(new Exception("TTS Error"));
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _service.GenerateAudioAsync(text, cancellationToken: cancellationToken);
|
|
||||||
Assert.Fail("Expected AiServiceException was not thrown");
|
|
||||||
}
|
|
||||||
catch (AiServiceException)
|
|
||||||
{
|
|
||||||
// Expected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateAudioToFileAsync_WithValidText_SavesFile()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var text = "Hallo Welt";
|
|
||||||
var outputPath = "/tmp/test_output.wav";
|
|
||||||
var language = "de";
|
|
||||||
var speaker = "default";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockTtsService.Setup(s => s.GenerateAudioToFileAsync(
|
|
||||||
text, outputPath, speaker, language, cancellationToken))
|
|
||||||
.ReturnsAsync(outputPath);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateAudioToFileAsync(
|
|
||||||
text, outputPath, language, speaker, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(outputPath, result);
|
|
||||||
_mockTtsService.Verify(s => s.GenerateAudioToFileAsync(
|
|
||||||
text, outputPath, speaker, language, cancellationToken), Times.Once);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateAudioStreamAsync_WithValidText_ReturnsStream()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var text = "Hallo Welt";
|
|
||||||
var language = "de";
|
|
||||||
var speaker = "default";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
var stream = new MemoryStream(_sampleAudio);
|
|
||||||
|
|
||||||
_mockTtsService.Setup(s => s.GenerateAudioStreamAsync(
|
|
||||||
text, speaker, language, cancellationToken))
|
|
||||||
.ReturnsAsync(stream);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateAudioStreamAsync(
|
|
||||||
text, language, speaker, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.IsInstanceOfType(result, typeof(MemoryStream));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateVocabularyAudioAsync_WithValidWord_ReturnsAudio()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var word = "Apfel";
|
|
||||||
var language = "de";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockTtsService.Setup(s => s.GenerateAudioAsync(
|
|
||||||
word, null, language, cancellationToken))
|
|
||||||
.ReturnsAsync(_sampleAudio);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateVocabularyAudioAsync(
|
|
||||||
word, language, null, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(_sampleAudio.Length, result.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateLessonAudioAsync_WithValidParameters_ReturnsAudio()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var lessonText = "Heute lernen wir neue Wörter.";
|
|
||||||
var vocabularyWords = new List<string> { "lernen", "Wörter", "heute" };
|
|
||||||
var language = "de";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockTtsService.Setup(s => s.GenerateAudioAsync(
|
|
||||||
It.IsAny<string>(), null, language, cancellationToken))
|
|
||||||
.ReturnsAsync(_sampleAudio);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateLessonAudioAsync(
|
|
||||||
lessonText, vocabularyWords, language, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(_sampleAudio.Length, result.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateStoryAudioAsync_WithValidText_ReturnsAudio()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var storyText = "Es war einmal ein kleiner Junge...";
|
|
||||||
var language = "de";
|
|
||||||
var speaker = "story-teller";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockTtsService.Setup(s => s.GenerateAudioAsync(
|
|
||||||
storyText, speaker, language, cancellationToken))
|
|
||||||
.ReturnsAsync(_sampleAudio);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateStoryAudioAsync(
|
|
||||||
storyText, language, speaker, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(_sampleAudio.Length, result.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateQuizAudioAsync_WithValidParameters_ReturnsAudio()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var questionText = "Was ist die Hauptstadt von Deutschland?";
|
|
||||||
var options = new List<string> { "Berlin", "München", "Hamburg", "Köln" };
|
|
||||||
var language = "de";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockTtsService.Setup(s => s.GenerateAudioAsync(
|
|
||||||
It.IsAny<string>(), null, language, cancellationToken))
|
|
||||||
.ReturnsAsync(_sampleAudio);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateQuizAudioAsync(
|
|
||||||
questionText, options, language, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(_sampleAudio.Length, result.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateBatchAudioAsync_WithValidTexts_ReturnsAllFiles()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var texts = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["1"] = "Hallo",
|
|
||||||
["2"] = "Welt",
|
|
||||||
["3"] = "Test"
|
|
||||||
};
|
|
||||||
var outputDirectory = "/tmp/audio";
|
|
||||||
var language = "de";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockTtsService.Setup(s => s.GenerateAudioToFileAsync(
|
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((string text, string outputPath, string? speaker, string language, CancellationToken ct) => outputPath);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateBatchAudioAsync(
|
|
||||||
texts, outputDirectory, language, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(3, result.Count);
|
|
||||||
Assert.IsTrue(result.ContainsKey("1"));
|
|
||||||
Assert.IsTrue(result.ContainsKey("2"));
|
|
||||||
Assert.IsTrue(result.ContainsKey("3"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetAvailableVoicesAsync_WithVoices_ReturnsList()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedVoices = new List<string> { "de-female-1", "de-male-1", "default" };
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockTtsService.Setup(s => s.GetAvailableSpeakersAsync(cancellationToken))
|
|
||||||
.ReturnsAsync(expectedVoices);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GetAvailableVoicesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(3, result.Count);
|
|
||||||
CollectionAssert.AreEqual(expectedVoices, new List<string>(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetAvailableVoicesAsync_WhenServiceFails_ReturnsDefault()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockTtsService.Setup(s => s.GetAvailableSpeakersAsync(cancellationToken))
|
|
||||||
.ThrowsAsync(new Exception("Error"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GetAvailableVoicesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(1, result.Count);
|
|
||||||
Assert.AreEqual("default", result[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetModelInfoAsync_WithModelInfo_ReturnsTuple()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedModelName = "tts_models/de/deu/fairseq/vits";
|
|
||||||
var expectedModelPath = "/path/to/model";
|
|
||||||
|
|
||||||
_mockTtsService.Setup(s => s.GetModelInfoAsync())
|
|
||||||
.ReturnsAsync((expectedModelName, expectedModelPath));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GetModelInfoAsync();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(expectedModelName, result.Item1);
|
|
||||||
Assert.AreEqual(expectedModelPath, result.Item2);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetModelInfoAsync_WhenServiceFails_ReturnsDefault()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
_mockTtsService.Setup(s => s.GetModelInfoAsync())
|
|
||||||
.ThrowsAsync(new Exception("Error"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GetModelInfoAsync();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual("unknown", result.Item1);
|
|
||||||
Assert.IsNull(result.Item2);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task TestServiceAsync_WithWorkingService_ReturnsTrue()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
_mockTtsService.Setup(s => s.GenerateAudioAsync(
|
|
||||||
"Hallo Welt", null, "de", cancellationToken))
|
|
||||||
.ReturnsAsync(_sampleAudio);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.TestServiceAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsTrue(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task TestServiceAsync_WithEmptyAudio_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
_mockTtsService.Setup(s => s.GenerateAudioAsync(
|
|
||||||
"Hallo Welt", null, "de", cancellationToken))
|
|
||||||
.ReturnsAsync(new byte[0]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.TestServiceAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsFalse(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task TestServiceAsync_WhenServiceFails_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
_mockTtsService.Setup(s => s.GenerateAudioAsync(
|
|
||||||
"Hallo Welt", null, "de", cancellationToken))
|
|
||||||
.ThrowsAsync(new Exception("Error"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.TestServiceAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsFalse(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,543 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Application.Models;
|
|
||||||
using GermanApp.Application.Services;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using GermanApp.Infrastructure.Configuration;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
||||||
using Moq;
|
|
||||||
|
|
||||||
namespace GermanApp.Tests.Unit.Application.Services;
|
|
||||||
|
|
||||||
[TestClass]
|
|
||||||
public class MistralServiceTests
|
|
||||||
{
|
|
||||||
private Mock<IMistralConnector> _mockConnector;
|
|
||||||
private Mock<IOptions<MistralConfig>> _mockConfigOptions;
|
|
||||||
private MistralConfig _config;
|
|
||||||
private MistralService _service;
|
|
||||||
|
|
||||||
[TestInitialize]
|
|
||||||
public void Setup()
|
|
||||||
{
|
|
||||||
_mockConnector = new Mock<IMistralConnector>();
|
|
||||||
_mockConfigOptions = new Mock<IOptions<MistralConfig>>();
|
|
||||||
_config = new MistralConfig
|
|
||||||
{
|
|
||||||
ApiKey = "test-api-key",
|
|
||||||
BaseUrl = "https://api.mistral.ai/v1/",
|
|
||||||
DefaultModel = "mistral-medium",
|
|
||||||
TimeoutSeconds = 30,
|
|
||||||
MaxRetries = 3
|
|
||||||
};
|
|
||||||
_mockConfigOptions.Setup(c => c.Value).Returns(_config);
|
|
||||||
|
|
||||||
_service = new MistralService(_mockConnector.Object, _mockConfigOptions.Object);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateTextAsync_WithValidPrompt_ReturnsText()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var prompt = "Tell me a joke";
|
|
||||||
var expectedText = "Why did the chicken cross the road?";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
var response = new MistralResponse
|
|
||||||
{
|
|
||||||
Model = "mistral-medium",
|
|
||||||
Choices = new List<MistralChoice>
|
|
||||||
{
|
|
||||||
new MistralChoice { Text = expectedText, Index = 0 }
|
|
||||||
},
|
|
||||||
Usage = new MistralUsage()
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.CompleteAsync(
|
|
||||||
It.Is<MistralRequest>(r => r.Prompt == prompt && r.Model == "mistral-medium"),
|
|
||||||
cancellationToken))
|
|
||||||
.ReturnsAsync(response);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateTextAsync(prompt, null, 0.7f, null, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(expectedText, result);
|
|
||||||
_mockConnector.Verify(c => c.CompleteAsync(
|
|
||||||
It.IsAny<MistralRequest>(), cancellationToken), Times.Once);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateTextAsync_WithCustomModel_UsesCustomModel()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var prompt = "Tell me a story";
|
|
||||||
var customModel = "mistral-small";
|
|
||||||
var expectedText = "Once upon a time...";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
var response = new MistralResponse
|
|
||||||
{
|
|
||||||
Choices = new List<MistralChoice> { new MistralChoice { Text = expectedText } }
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.CompleteAsync(
|
|
||||||
It.Is<MistralRequest>(r => r.Model == customModel),
|
|
||||||
cancellationToken))
|
|
||||||
.ReturnsAsync(response);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateTextAsync(prompt, customModel, 0.7f, null, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(expectedText, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateTextAsync_WithCustomParameters_UsesParameters()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var prompt = "Test";
|
|
||||||
var temperature = 0.9f;
|
|
||||||
var maxTokens = 100;
|
|
||||||
var expectedText = "Test response";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
var response = new MistralResponse
|
|
||||||
{
|
|
||||||
Choices = new List<MistralChoice> { new MistralChoice { Text = expectedText } }
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.CompleteAsync(
|
|
||||||
It.Is<MistralRequest>(r =>
|
|
||||||
r.Temperature == temperature &&
|
|
||||||
r.MaxTokens == maxTokens),
|
|
||||||
cancellationToken))
|
|
||||||
.ReturnsAsync(response);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateTextAsync(
|
|
||||||
prompt, null, temperature, maxTokens, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(expectedText, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateTextAsync_WithNoChoices_ThrowsInvalidOperationException()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var prompt = "Test";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
var response = new MistralResponse
|
|
||||||
{
|
|
||||||
Choices = new List<MistralChoice>()
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.CompleteAsync(
|
|
||||||
It.IsAny<MistralRequest>(),
|
|
||||||
cancellationToken))
|
|
||||||
.ReturnsAsync(response);
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _service.GenerateTextAsync(prompt, null, 0.7f, null, cancellationToken);
|
|
||||||
Assert.Fail("Expected InvalidOperationException was not thrown");
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
// Expected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateTextAsync_WithNullTextInChoice_ReturnsEmptyString()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var prompt = "Test";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
var response = new MistralResponse
|
|
||||||
{
|
|
||||||
Choices = new List<MistralChoice> { new MistralChoice { Text = null } }
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.CompleteAsync(
|
|
||||||
It.IsAny<MistralRequest>(),
|
|
||||||
cancellationToken))
|
|
||||||
.ReturnsAsync(response);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateTextAsync(prompt, null, 0.7f, null, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(string.Empty, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateTextAsync_WhenConnectorThrows_ThrowsException()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var prompt = "Test";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.CompleteAsync(
|
|
||||||
It.IsAny<MistralRequest>(),
|
|
||||||
cancellationToken))
|
|
||||||
.ThrowsAsync(new Exception("API Error"));
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _service.GenerateTextAsync(prompt, null, 0.7f, null, cancellationToken);
|
|
||||||
Assert.Fail("Expected Exception was not thrown");
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
// Expected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateChatAsync_WithValidMessages_ReturnsResponse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var messages = new List<(string role, string content)>
|
|
||||||
{
|
|
||||||
("system", "You are a helpful assistant"),
|
|
||||||
("user", "Hello")
|
|
||||||
};
|
|
||||||
var expectedResponse = "Hi there! How can I help you?";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
var response = new MistralResponse
|
|
||||||
{
|
|
||||||
Model = "mistral-medium",
|
|
||||||
Choices = new List<MistralChoice>
|
|
||||||
{
|
|
||||||
new MistralChoice
|
|
||||||
{
|
|
||||||
Index = 0,
|
|
||||||
Message = new MistralChatMessage { Role = "assistant", Content = expectedResponse }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Usage = new MistralUsage()
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.ChatAsync(
|
|
||||||
It.Is<MistralChatRequest>(r => r.Messages.Count == 2),
|
|
||||||
cancellationToken))
|
|
||||||
.ReturnsAsync(response);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateChatAsync(messages, null, 0.7f, null, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(expectedResponse, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateChatAsync_WithNoChoices_ThrowsInvalidOperationException()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var messages = new List<(string role, string content)>
|
|
||||||
{
|
|
||||||
("user", "Hello")
|
|
||||||
};
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
var response = new MistralResponse
|
|
||||||
{
|
|
||||||
Choices = new List<MistralChoice>()
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.ChatAsync(
|
|
||||||
It.IsAny<MistralChatRequest>(),
|
|
||||||
cancellationToken))
|
|
||||||
.ReturnsAsync(response);
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _service.GenerateChatAsync(messages, null, 0.7f, null, cancellationToken);
|
|
||||||
Assert.Fail("Expected InvalidOperationException was not thrown");
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
// Expected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateChatAsync_WithNullMessage_ReturnsEmpty()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var messages = new List<(string role, string content)>
|
|
||||||
{
|
|
||||||
("user", "Hello")
|
|
||||||
};
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
var response = new MistralResponse
|
|
||||||
{
|
|
||||||
Choices = new List<MistralChoice>
|
|
||||||
{
|
|
||||||
new MistralChoice { Message = null }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.ChatAsync(
|
|
||||||
It.IsAny<MistralChatRequest>(),
|
|
||||||
cancellationToken))
|
|
||||||
.ReturnsAsync(response);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateChatAsync(messages, null, 0.7f, null, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(string.Empty, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateStoryAsync_WithValidParameters_ReturnsStory()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var level = "A1";
|
|
||||||
var topic = "Travel";
|
|
||||||
var vocabularyWords = new List<string> { "Bahn", "Reise", "Stadt" };
|
|
||||||
var length = 200;
|
|
||||||
var expectedStory = "Es war einmal eine Reise mit der Bahn...";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
var response = new MistralResponse
|
|
||||||
{
|
|
||||||
Choices = new List<MistralChoice> { new MistralChoice { Text = expectedStory } }
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.CompleteAsync(
|
|
||||||
It.Is<MistralRequest>(r =>
|
|
||||||
r.Model == "mistral-medium" &&
|
|
||||||
r.Temperature == 0.8f &&
|
|
||||||
r.MaxTokens == 1000),
|
|
||||||
cancellationToken))
|
|
||||||
.ReturnsAsync(response);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateStoryAsync(
|
|
||||||
level, topic, vocabularyWords, length, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(expectedStory, result);
|
|
||||||
_mockConnector.Verify(c => c.CompleteAsync(
|
|
||||||
It.IsAny<MistralRequest>(), cancellationToken), Times.Once);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateStoryAsync_WithEmptyVocabulary_ReturnsStory()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var level = "A1";
|
|
||||||
var topic = "Travel";
|
|
||||||
var vocabularyWords = new List<string>();
|
|
||||||
var length = 200;
|
|
||||||
var expectedStory = "A story without specific vocabulary...";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
var response = new MistralResponse
|
|
||||||
{
|
|
||||||
Choices = new List<MistralChoice> { new MistralChoice { Text = expectedStory } }
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.CompleteAsync(
|
|
||||||
It.IsAny<MistralRequest>(),
|
|
||||||
cancellationToken))
|
|
||||||
.ReturnsAsync(response);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateStoryAsync(
|
|
||||||
level, topic, vocabularyWords, length, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(expectedStory, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateWritingFeedbackAsync_WithValidParameters_ReturnsFeedback()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var userText = "Ich heisse Anna und wohne in Berlin.";
|
|
||||||
var level = "A1";
|
|
||||||
var expectedFeedback = "Great job! Your sentence structure is correct.";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
var response = new MistralResponse
|
|
||||||
{
|
|
||||||
Choices = new List<MistralChoice>
|
|
||||||
{
|
|
||||||
new MistralChoice
|
|
||||||
{
|
|
||||||
Message = new MistralChatMessage { Content = expectedFeedback }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.ChatAsync(
|
|
||||||
It.Is<MistralChatRequest>(r =>
|
|
||||||
r.Model == "mistral-medium" &&
|
|
||||||
r.Temperature == 0.3f &&
|
|
||||||
r.MaxTokens == 800 &&
|
|
||||||
r.Messages.Count == 3),
|
|
||||||
cancellationToken))
|
|
||||||
.ReturnsAsync(response);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateWritingFeedbackAsync(
|
|
||||||
userText, level, null, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(expectedFeedback, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateWritingFeedbackAsync_WithCustomPrompt_ReturnsFeedback()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var userText = "Ich heisse Anna.";
|
|
||||||
var level = "A1";
|
|
||||||
var customPrompt = "Focus on grammar mistakes";
|
|
||||||
var expectedFeedback = "Grammar: You should use 'heiße' instead of 'heisse'";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
var response = new MistralResponse
|
|
||||||
{
|
|
||||||
Choices = new List<MistralChoice>
|
|
||||||
{
|
|
||||||
new MistralChoice
|
|
||||||
{
|
|
||||||
Message = new MistralChatMessage { Content = expectedFeedback }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.ChatAsync(
|
|
||||||
It.IsAny<MistralChatRequest>(),
|
|
||||||
cancellationToken))
|
|
||||||
.ReturnsAsync(response);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateWritingFeedbackAsync(
|
|
||||||
userText, level, customPrompt, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(expectedFeedback, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task TestConnectionAsync_WithSuccessfulResponse_ReturnsTrue()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedText = "test successful";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
var response = new MistralResponse
|
|
||||||
{
|
|
||||||
Choices = new List<MistralChoice> { new MistralChoice { Text = expectedText } }
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.CompleteAsync(
|
|
||||||
It.Is<MistralRequest>(r => r.MaxTokens == 10),
|
|
||||||
cancellationToken))
|
|
||||||
.ReturnsAsync(response);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.TestConnectionAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsTrue(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task TestConnectionAsync_WithDifferentCaseResponse_ReturnsTrue()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedText = "TEST SUCCESSFUL";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
var response = new MistralResponse
|
|
||||||
{
|
|
||||||
Choices = new List<MistralChoice> { new MistralChoice { Text = expectedText } }
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.CompleteAsync(
|
|
||||||
It.IsAny<MistralRequest>(),
|
|
||||||
cancellationToken))
|
|
||||||
.ReturnsAsync(response);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.TestConnectionAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsTrue(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task TestConnectionAsync_WithWrongResponse_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedText = "Wrong response";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
var response = new MistralResponse
|
|
||||||
{
|
|
||||||
Choices = new List<MistralChoice> { new MistralChoice { Text = expectedText } }
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.CompleteAsync(
|
|
||||||
It.IsAny<MistralRequest>(),
|
|
||||||
cancellationToken))
|
|
||||||
.ReturnsAsync(response);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.TestConnectionAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsFalse(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task TestConnectionAsync_WhenConnectorThrows_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockConnector.Setup(c => c.CompleteAsync(
|
|
||||||
It.IsAny<MistralRequest>(),
|
|
||||||
cancellationToken))
|
|
||||||
.ThrowsAsync(new Exception("API Error"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.TestConnectionAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsFalse(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public void BuildStoryPrompt_WithValidParameters_ReturnsPrompt()
|
|
||||||
{
|
|
||||||
// This is a private method, but we can test it indirectly through GenerateStoryAsync
|
|
||||||
// The test above already verifies the prompt building works
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public void BuildFeedbackSystemPrompt_WithValidParameters_ReturnsPrompt()
|
|
||||||
{
|
|
||||||
// This is a private method, but we can test it indirectly through GenerateWritingFeedbackAsync
|
|
||||||
// The test above already verifies the prompt building works
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Application.Services;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
||||||
using Moq;
|
|
||||||
|
|
||||||
namespace GermanApp.Tests.Unit.Application.Services;
|
|
||||||
|
|
||||||
[TestClass]
|
|
||||||
public class QuizQuestionServiceTests
|
|
||||||
{
|
|
||||||
private Mock<IQuizQuestionRepository> _mockQuizQuestionRepo;
|
|
||||||
private Mock<IQuizOptionRepository> _mockQuizOptionRepo;
|
|
||||||
private Mock<IQuizRepository> _mockQuizRepo;
|
|
||||||
private Mock<ILessonRepository> _mockLessonRepo;
|
|
||||||
private Mock<ProgressService> _mockProgressService;
|
|
||||||
private QuizQuestionService _service;
|
|
||||||
|
|
||||||
[TestInitialize]
|
|
||||||
public void Setup()
|
|
||||||
{
|
|
||||||
_mockQuizQuestionRepo = new Mock<IQuizQuestionRepository>();
|
|
||||||
_mockQuizOptionRepo = new Mock<IQuizOptionRepository>();
|
|
||||||
_mockQuizRepo = new Mock<IQuizRepository>();
|
|
||||||
_mockLessonRepo = new Mock<ILessonRepository>();
|
|
||||||
_mockProgressService = new Mock<ProgressService>(null!, null!, null!);
|
|
||||||
_service = new QuizQuestionService(
|
|
||||||
_mockQuizQuestionRepo.Object,
|
|
||||||
_mockQuizOptionRepo.Object,
|
|
||||||
_mockQuizRepo.Object,
|
|
||||||
_mockLessonRepo.Object,
|
|
||||||
_mockProgressService.Object);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetAllQuizQuestionsAsync_ReturnsMappedDtoList()
|
|
||||||
{
|
|
||||||
var lesson = Lesson.Create(1, "Test Lesson", 1, "Test Topic", "Test Description");
|
|
||||||
var questions = new List<QuizQuestion>
|
|
||||||
{
|
|
||||||
QuizQuestion.Create(1, "Question 1", QuestionType.MultipleChoice, "Answer 1", 3, 1)
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockQuizQuestionRepo.Setup(r => r.GetAllOrderedAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(questions);
|
|
||||||
|
|
||||||
var result = await _service.GetAllQuizQuestionsAsync();
|
|
||||||
|
|
||||||
Assert.AreEqual(1, result.Count);
|
|
||||||
Assert.AreEqual("Question 1", result[0].QuestionText);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetQuizQuestionByIdAsync_WithExistingId_ReturnsQuestion()
|
|
||||||
{
|
|
||||||
var question = QuizQuestion.Create(1, "Test Question", QuestionType.MultipleChoice, "Test Answer", 3, 1);
|
|
||||||
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(question);
|
|
||||||
|
|
||||||
var result = await _service.GetQuizQuestionByIdAsync(1);
|
|
||||||
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual("Test Question", result.QuestionText);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetQuizQuestionByIdAsync_WithNonExistingId_ReturnsNull()
|
|
||||||
{
|
|
||||||
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(999, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((QuizQuestion?)null);
|
|
||||||
|
|
||||||
var result = await _service.GetQuizQuestionByIdAsync(999);
|
|
||||||
|
|
||||||
Assert.IsNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetQuizQuestionsByLessonAsync_ReturnsFilteredQuestions()
|
|
||||||
{
|
|
||||||
var questions = new List<QuizQuestion>
|
|
||||||
{
|
|
||||||
QuizQuestion.Create(1, "Q1", QuestionType.MultipleChoice, "A1", 3, 1)
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockQuizQuestionRepo.Setup(r => r.GetByLessonAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(questions);
|
|
||||||
|
|
||||||
var result = await _service.GetQuizQuestionsByLessonAsync(1);
|
|
||||||
|
|
||||||
Assert.AreEqual(1, result.Count);
|
|
||||||
Assert.AreEqual("Q1", result[0].QuestionText);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetActiveQuizQuestionsByLessonAsync_ReturnsOnlyActive()
|
|
||||||
{
|
|
||||||
var activeQuestion = QuizQuestion.Create(1, "Active", QuestionType.MultipleChoice, "A1", 3, 1);
|
|
||||||
var inactiveQuestion = QuizQuestion.Create(1, "Inactive", QuestionType.MultipleChoice, "A2", 3, 2);
|
|
||||||
inactiveQuestion.Deactivate();
|
|
||||||
|
|
||||||
var questions = new List<QuizQuestion> { activeQuestion, inactiveQuestion };
|
|
||||||
|
|
||||||
_mockQuizQuestionRepo.Setup(r => r.GetActiveByLessonAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(new List<QuizQuestion> { activeQuestion });
|
|
||||||
|
|
||||||
var result = await _service.GetActiveQuizQuestionsByLessonAsync(1);
|
|
||||||
|
|
||||||
Assert.AreEqual(1, result.Count);
|
|
||||||
Assert.AreEqual("Active", result[0].QuestionText);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commented out - requires QuizQuestion entity with specific ID setup
|
|
||||||
// [TestMethod]
|
|
||||||
// public async Task CreateQuizQuestionAsync_WithValidDtoAndValidLesson_CreatesQuestion()
|
|
||||||
// {
|
|
||||||
// ...
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Commented out - requires QuizQuestion entity with specific ID setup
|
|
||||||
// [TestMethod]
|
|
||||||
// public async Task UpdateQuizQuestionAsync_WithExistingQuestion_UpdatesQuestion()
|
|
||||||
// {
|
|
||||||
// ...
|
|
||||||
// }
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task DeleteQuizQuestionAsync_WithExistingQuestion_ReturnsTrue()
|
|
||||||
{
|
|
||||||
var question = QuizQuestion.Create(1, "Test Question", QuestionType.MultipleChoice, "Answer", 3, 1);
|
|
||||||
|
|
||||||
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(question);
|
|
||||||
_mockQuizOptionRepo.Setup(r => r.DeleteByQuestionAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.Returns(Task.CompletedTask);
|
|
||||||
_mockQuizQuestionRepo.Setup(r => r.DeleteAsync(question, It.IsAny<CancellationToken>()))
|
|
||||||
.Returns(Task.CompletedTask);
|
|
||||||
|
|
||||||
var result = await _service.DeleteQuizQuestionAsync(1);
|
|
||||||
|
|
||||||
Assert.IsTrue(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetQuizOptionsByQuestionAsync_ReturnsOptions()
|
|
||||||
{
|
|
||||||
var options = new List<QuizOption>
|
|
||||||
{
|
|
||||||
QuizOption.Create(1, "Option 1", true, 1),
|
|
||||||
QuizOption.Create(1, "Option 2", false, 2)
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockQuizOptionRepo.Setup(r => r.GetByQuestionAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(options);
|
|
||||||
|
|
||||||
var result = await _service.GetQuizOptionsByQuestionAsync(1);
|
|
||||||
|
|
||||||
Assert.AreEqual(2, result.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetCorrectOptionAsync_ReturnsCorrectOption()
|
|
||||||
{
|
|
||||||
var option = QuizOption.Create(1, "Correct Option", true, 1);
|
|
||||||
|
|
||||||
_mockQuizOptionRepo.Setup(r => r.GetCorrectOptionAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(option);
|
|
||||||
|
|
||||||
var result = await _service.GetCorrectOptionAsync(1);
|
|
||||||
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.IsTrue(result.IsCorrect);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task CreateQuizOptionAsync_WithValidData_CreatesOption()
|
|
||||||
{
|
|
||||||
var question = QuizQuestion.Create(1, "Test Question", QuestionType.MultipleChoice, "Answer", 3, 1);
|
|
||||||
var dto = new CreateQuizOptionDto("New Option", true, 1);
|
|
||||||
var createdOption = QuizOption.Create(1, "New Option", true, 1);
|
|
||||||
|
|
||||||
_mockQuizQuestionRepo.Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(question);
|
|
||||||
_mockQuizOptionRepo.Setup(r => r.OrderExistsForQuestionAsync(1, 1, null, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(false);
|
|
||||||
_mockQuizOptionRepo.Setup(r => r.AddAsync(It.IsAny<QuizOption>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(createdOption);
|
|
||||||
|
|
||||||
var result = await _service.CreateQuizOptionAsync(1, dto);
|
|
||||||
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual("New Option", result.Text);
|
|
||||||
}
|
|
||||||
|
|
||||||
// These tests require creating Quiz and QuizQuestion entities with specific IDs
|
|
||||||
// which is not straightforward due to private setters.
|
|
||||||
// For now, these tests are commented out as they are not critical to Lesson Management feature.
|
|
||||||
// They can be revisited when Quiz system is fully implemented.
|
|
||||||
|
|
||||||
// [TestMethod]
|
|
||||||
// public async Task SubmitQuizAnswersAsync_WithCorrectAnswers_ReturnsPassedResult()
|
|
||||||
// {
|
|
||||||
// ...
|
|
||||||
// }
|
|
||||||
|
|
||||||
// [TestMethod]
|
|
||||||
// public async Task SubmitQuizAnswersAsync_WithIncorrectAnswers_ReturnsFailedResult()
|
|
||||||
// {
|
|
||||||
// ...
|
|
||||||
// }
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetQuizQuestionCountForLessonAsync_ReturnsCount()
|
|
||||||
{
|
|
||||||
var questions = new List<QuizQuestion>
|
|
||||||
{
|
|
||||||
QuizQuestion.Create(1, "Q1", QuestionType.MultipleChoice, "A1", 3, 1),
|
|
||||||
QuizQuestion.Create(1, "Q2", QuestionType.MultipleChoice, "A2", 3, 2)
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockQuizQuestionRepo.Setup(r => r.GetActiveByLessonAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(questions);
|
|
||||||
|
|
||||||
var result = await _service.GetQuizQuestionCountForLessonAsync(1);
|
|
||||||
|
|
||||||
Assert.AreEqual(2, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GetDifficultyDistributionAsync_ReturnsDistribution()
|
|
||||||
{
|
|
||||||
var questions = new List<QuizQuestion>
|
|
||||||
{
|
|
||||||
QuizQuestion.Create(1, "Easy", QuestionType.MultipleChoice, "A1", 1, 1),
|
|
||||||
QuizQuestion.Create(1, "Medium", QuestionType.MultipleChoice, "A2", 3, 3),
|
|
||||||
QuizQuestion.Create(1, "Hard", QuestionType.MultipleChoice, "A3", 5, 5)
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockQuizQuestionRepo.Setup(r => r.GetActiveByLessonAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(questions);
|
|
||||||
|
|
||||||
var result = await _service.GetDifficultyDistributionAsync(1);
|
|
||||||
|
|
||||||
Assert.AreEqual(1, result[1]); // Easy (difficulty 1)
|
|
||||||
Assert.AreEqual(0, result[2]); // No questions with difficulty 2
|
|
||||||
Assert.AreEqual(1, result[3]); // Medium (difficulty 3)
|
|
||||||
Assert.AreEqual(0, result[4]); // No questions with difficulty 4
|
|
||||||
Assert.AreEqual(1, result[5]); // Hard (difficulty 5)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Application.Services;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
||||||
using Moq;
|
|
||||||
|
|
||||||
namespace GermanApp.Tests.Unit.Application.Services;
|
|
||||||
|
|
||||||
[TestClass]
|
|
||||||
public class SpeechExerciseServiceTests
|
|
||||||
{
|
|
||||||
private Mock<IVoskService> _mockVoskService;
|
|
||||||
private Mock<ILogger<SpeechExerciseService>> _mockLogger;
|
|
||||||
private SpeechExerciseService _service;
|
|
||||||
private readonly byte[] _sampleAudio = new byte[100];
|
|
||||||
|
|
||||||
[TestInitialize]
|
|
||||||
public void Setup()
|
|
||||||
{
|
|
||||||
_mockVoskService = new Mock<IVoskService>();
|
|
||||||
_mockLogger = new Mock<ILogger<SpeechExerciseService>>();
|
|
||||||
_service = new SpeechExerciseService(
|
|
||||||
_mockVoskService.Object,
|
|
||||||
_mockLogger.Object);
|
|
||||||
|
|
||||||
// Initialize sample audio
|
|
||||||
for (int i = 0; i < _sampleAudio.Length; i++)
|
|
||||||
{
|
|
||||||
_sampleAudio[i] = (byte)(i % 256);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task RecognizeSpeechAsync_WithValidAudio_ReturnsResult()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedText = "Hallo Welt";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockVoskService.Setup(s => s.RecognizeSpeechAsync(
|
|
||||||
_sampleAudio, 16000, null, cancellationToken))
|
|
||||||
.ReturnsAsync(expectedText);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.RecognizeSpeechAsync(
|
|
||||||
_sampleAudio, null, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(expectedText, result.RecognizedText);
|
|
||||||
Assert.IsNull(result.ExpectedText);
|
|
||||||
Assert.AreEqual(0.0, result.Accuracy);
|
|
||||||
Assert.IsFalse(result.IsCorrect);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task RecognizeSpeechAsync_WithExpectedText_CalculatesAccuracy()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedText = "Hallo Welt";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockVoskService.Setup(s => s.RecognizeSpeechAsync(
|
|
||||||
_sampleAudio, 16000, null, cancellationToken))
|
|
||||||
.ReturnsAsync(expectedText);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.RecognizeSpeechAsync(
|
|
||||||
_sampleAudio, expectedText, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(expectedText, result.RecognizedText);
|
|
||||||
Assert.AreEqual(expectedText, result.ExpectedText);
|
|
||||||
Assert.AreEqual(1.0, result.Accuracy);
|
|
||||||
Assert.IsTrue(result.IsCorrect);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task RecognizeSpeechAsync_WithDifferentText_CalculatesPartialAccuracy()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var recognizedText = "Hallo";
|
|
||||||
var expectedText = "Hallo Welt";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockVoskService.Setup(s => s.RecognizeSpeechAsync(
|
|
||||||
_sampleAudio, 16000, null, cancellationToken))
|
|
||||||
.ReturnsAsync(recognizedText);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.RecognizeSpeechAsync(
|
|
||||||
_sampleAudio, expectedText, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(recognizedText, result.RecognizedText);
|
|
||||||
Assert.AreEqual(expectedText, result.ExpectedText);
|
|
||||||
Assert.IsTrue(result.Accuracy > 0.0 && result.Accuracy < 1.0);
|
|
||||||
Assert.IsFalse(result.IsCorrect);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task RecognizeSpeechAsync_WhenVoskThrows_ThrowsAiServiceException()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockVoskService.Setup(s => s.RecognizeSpeechAsync(
|
|
||||||
_sampleAudio, 16000, null, cancellationToken))
|
|
||||||
.ThrowsAsync(new Exception("Recognition Error"));
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _service.RecognizeSpeechAsync(_sampleAudio, null, cancellationToken);
|
|
||||||
Assert.Fail("Expected AiServiceException was not thrown");
|
|
||||||
}
|
|
||||||
catch (AiServiceException)
|
|
||||||
{
|
|
||||||
// Expected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task RecognizeSpeechFromFileAsync_WithValidFile_ReturnsResult()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var filePath = "/tmp/test.wav";
|
|
||||||
var expectedText = "Hallo Welt";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockVoskService.Setup(s => s.RecognizeSpeechFromFileAsync(
|
|
||||||
filePath, cancellationToken))
|
|
||||||
.ReturnsAsync(expectedText);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.RecognizeSpeechFromFileAsync(
|
|
||||||
filePath, null, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(expectedText, result.RecognizedText);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task VerifySpeechAsync_WithValidAudio_ReturnsResultWithScores()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var expectedText = "Hallo Welt";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockVoskService.Setup(s => s.RecognizeSpeechAsync(
|
|
||||||
_sampleAudio, 16000, null, cancellationToken))
|
|
||||||
.ReturnsAsync(expectedText);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.VerifySpeechAsync(
|
|
||||||
_sampleAudio, expectedText, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(expectedText, result.RecognizedText);
|
|
||||||
Assert.AreEqual(expectedText, result.ExpectedText);
|
|
||||||
Assert.AreEqual(1.0, result.Accuracy);
|
|
||||||
Assert.IsTrue(result.IsCorrect);
|
|
||||||
Assert.AreEqual(1.0, result.PronunciationScore);
|
|
||||||
Assert.IsTrue(result.FluencyScore > 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public void CreateExercise_WithValidParameters_CreatesExercise()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var phrase = "Guten Morgen";
|
|
||||||
var difficulty = "easy";
|
|
||||||
var hints = new List<string> { "Pronounce clearly", "Slow down" };
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = _service.CreateExercise(phrase, difficulty, hints);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.IsTrue(!string.IsNullOrEmpty(result.Id));
|
|
||||||
Assert.AreEqual(phrase, result.Phrase);
|
|
||||||
Assert.AreEqual(difficulty, result.Difficulty);
|
|
||||||
Assert.AreEqual(2, result.Hints.Count);
|
|
||||||
Assert.IsTrue(DateTime.UtcNow - result.CreatedAt < TimeSpan.FromSeconds(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public void CreateExercise_WithNoHints_UsesEmptyList()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var phrase = "Guten Morgen";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = _service.CreateExercise(phrase);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(0, result.Hints.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task EvaluateExerciseAttemptAsync_WithValidAttempt_ReturnsEvaluation()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var exerciseId = "exercise-123";
|
|
||||||
var expectedPhrase = "Guten Morgen";
|
|
||||||
var recognizedText = "Guten Morgen";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockVoskService.Setup(s => s.RecognizeSpeechAsync(
|
|
||||||
_sampleAudio, 16000, null, cancellationToken))
|
|
||||||
.ReturnsAsync(recognizedText);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.EvaluateExerciseAttemptAsync(
|
|
||||||
exerciseId, _sampleAudio, expectedPhrase, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(exerciseId, result.ExerciseId);
|
|
||||||
Assert.AreEqual(expectedPhrase, result.ExpectedPhrase);
|
|
||||||
Assert.AreEqual(recognizedText, result.RecognizedText);
|
|
||||||
Assert.AreEqual(1.0, result.Accuracy);
|
|
||||||
Assert.IsTrue(result.IsPassed);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task EvaluateExerciseAttemptAsync_WithIncorrectPhrase_ReturnsFailedEvaluation()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var exerciseId = "exercise-123";
|
|
||||||
var expectedPhrase = "Guten Morgen";
|
|
||||||
var recognizedText = "Guten Tag";
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
|
|
||||||
_mockVoskService.Setup(s => s.RecognizeSpeechAsync(
|
|
||||||
_sampleAudio, 16000, null, cancellationToken))
|
|
||||||
.ReturnsAsync(recognizedText);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.EvaluateExerciseAttemptAsync(
|
|
||||||
exerciseId, _sampleAudio, expectedPhrase, cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.IsFalse(result.IsPassed);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task TestServiceAsync_WithWorkingService_ReturnsTrue()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
_mockVoskService.Setup(s => s.GetModelInfoAsync())
|
|
||||||
.ReturnsAsync(("vosk-model-de-0.22", "/path/to/model"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.TestServiceAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsTrue(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task TestServiceAsync_WhenServiceFails_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var cancellationToken = CancellationToken.None;
|
|
||||||
_mockVoskService.Setup(s => s.GetModelInfoAsync())
|
|
||||||
.ThrowsAsync(new Exception("Error"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.TestServiceAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsFalse(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public void CalculateAccuracy_WithExactMatch_ReturnsOne()
|
|
||||||
{
|
|
||||||
// This is a private method, so we test it through the public API
|
|
||||||
// Already tested in RecognizeSpeechAsync_WithExpectedText_CalculatesAccuracy
|
|
||||||
// Just ensuring the method exists and works
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public void LevenshteinDistance_WithIdenticalStrings_ReturnsZero()
|
|
||||||
{
|
|
||||||
// This is a private static method, tested through public API
|
|
||||||
// The implementation should handle identical strings correctly
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,469 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Application.Services;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Domain.Interfaces;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
||||||
using Moq;
|
|
||||||
|
|
||||||
namespace GermanApp.Tests.Unit.Application.Services;
|
|
||||||
|
|
||||||
[TestClass]
|
|
||||||
public class StoryGenerationServiceTests
|
|
||||||
{
|
|
||||||
private Mock<IMistralService> _mockMistralService;
|
|
||||||
private Mock<IStoryRepository> _mockStoryRepository;
|
|
||||||
private Mock<ITtsService> _mockTtsService;
|
|
||||||
private Mock<ILogger<StoryGenerationService>> _mockLogger;
|
|
||||||
private StoryGenerationService _service;
|
|
||||||
|
|
||||||
[TestInitialize]
|
|
||||||
public void Setup()
|
|
||||||
{
|
|
||||||
_mockMistralService = new Mock<IMistralService>();
|
|
||||||
_mockStoryRepository = new Mock<IStoryRepository>();
|
|
||||||
_mockTtsService = new Mock<ITtsService>();
|
|
||||||
_mockLogger = new Mock<ILogger<StoryGenerationService>>();
|
|
||||||
|
|
||||||
_service = new StoryGenerationService(
|
|
||||||
_mockMistralService.Object,
|
|
||||||
_mockStoryRepository.Object,
|
|
||||||
_mockTtsService.Object,
|
|
||||||
_mockLogger.Object);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Lesson CreateTestLesson(int id, int levelId, string title, string topic, int order)
|
|
||||||
{
|
|
||||||
var lesson = Lesson.Create(levelId, title, order, topic);
|
|
||||||
// Use reflection to set the Id since it's private set
|
|
||||||
typeof(Lesson).GetProperty("Id")?.SetValue(lesson, id);
|
|
||||||
return lesson;
|
|
||||||
}
|
|
||||||
|
|
||||||
private StorySegment CreateTestSegment(int id, int levelId, int? lessonId, string content, int order, string title, string theme)
|
|
||||||
{
|
|
||||||
var segment = StorySegment.Create(levelId, lessonId, content, order, title, theme);
|
|
||||||
typeof(StorySegment).GetProperty("Id")?.SetValue(segment, id);
|
|
||||||
return segment;
|
|
||||||
}
|
|
||||||
|
|
||||||
#region GenerateStoryAsync Tests
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateStoryAsync_WithValidData_ReturnsResponse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var levelId = 1;
|
|
||||||
var theme = "Abenteuer";
|
|
||||||
var lesson1 = CreateTestLesson(1, 1, "Lektion 1", "Einfuehrung", 1);
|
|
||||||
var lesson2 = CreateTestLesson(2, 1, "Lektion 2", "Fortsetzung", 2);
|
|
||||||
var lessons = new List<Lesson> { lesson1, lesson2 };
|
|
||||||
var expectedStory = "Es war einmal ein Abenteuer...";
|
|
||||||
|
|
||||||
_mockMistralService.Setup(m => m.GenerateStoryAsync(
|
|
||||||
"A1", theme, It.IsAny<IReadOnlyList<string>>(), 500, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedStory);
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.AddAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((StorySegment s, CancellationToken ct) => s);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateStoryAsync(levelId, theme, lessons);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(levelId, result.LevelId);
|
|
||||||
Assert.AreEqual(theme, result.Theme);
|
|
||||||
Assert.AreEqual(2, result.SegmentCount);
|
|
||||||
Assert.AreEqual(expectedStory, result.FullStoryText);
|
|
||||||
Assert.AreEqual(2, result.Segments.Count);
|
|
||||||
_mockMistralService.Verify(m => m.GenerateStoryAsync(
|
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateStoryAsync_WithNoVocabulary_ThrowsException()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var levelId = 1;
|
|
||||||
var theme = "Abenteuer";
|
|
||||||
var lessons = new List<Lesson>(); // Empty list = no vocabulary
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _service.GenerateStoryAsync(levelId, theme, lessons);
|
|
||||||
Assert.Fail("Expected InvalidOperationException was not thrown");
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
Assert.IsTrue(ex.Message.Contains("No vocabulary"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateStoryAsync_WithEmptyResponse_ThrowsException()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var levelId = 1;
|
|
||||||
var theme = "Abenteuer";
|
|
||||||
var lesson1 = CreateTestLesson(1, 1, "Lektion 1", "Test", 1);
|
|
||||||
var lessons = new List<Lesson> { lesson1 };
|
|
||||||
|
|
||||||
_mockMistralService.Setup(m => m.GenerateStoryAsync(
|
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(string.Empty);
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _service.GenerateStoryAsync(levelId, theme, lessons);
|
|
||||||
Assert.Fail("Expected InvalidOperationException was not thrown");
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
Assert.IsTrue(ex.Message.Contains("Empty response"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateStoryAsync_WithNullResponse_ThrowsException()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var levelId = 1;
|
|
||||||
var theme = "Abenteuer";
|
|
||||||
var lesson1 = CreateTestLesson(1, 1, "Lektion 1", "Test", 1);
|
|
||||||
var lessons = new List<Lesson> { lesson1 };
|
|
||||||
|
|
||||||
_mockMistralService.Setup(m => m.GenerateStoryAsync(
|
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((string?)null);
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _service.GenerateStoryAsync(levelId, theme, lessons);
|
|
||||||
Assert.Fail("Expected InvalidOperationException was not thrown");
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
Assert.IsTrue(ex.Message.Contains("Empty response"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateStoryAsync_WithLevelA2_UsesCorrectLevelCode()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var levelId = 2;
|
|
||||||
var theme = "Abenteuer";
|
|
||||||
var lesson1 = CreateTestLesson(1, 2, "Lektion 1", "Test", 1);
|
|
||||||
var lessons = new List<Lesson> { lesson1 };
|
|
||||||
var expectedStory = "A2 Story";
|
|
||||||
|
|
||||||
_mockMistralService.Setup(m => m.GenerateStoryAsync(
|
|
||||||
"A2", theme, It.IsAny<IReadOnlyList<string>>(), 500, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedStory);
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.AddAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((StorySegment s, CancellationToken ct) => s);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateStoryAsync(levelId, theme, lessons);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
_mockMistralService.Verify(m => m.GenerateStoryAsync(
|
|
||||||
"A2", It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateStoryAsync_WithLevelB1_UsesCorrectLevelCode()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var levelId = 3;
|
|
||||||
var theme = "Abenteuer";
|
|
||||||
var lesson1 = CreateTestLesson(1, 3, "Lektion 1", "Test", 1);
|
|
||||||
var lessons = new List<Lesson> { lesson1 };
|
|
||||||
var expectedStory = "B1 Story";
|
|
||||||
|
|
||||||
_mockMistralService.Setup(m => m.GenerateStoryAsync(
|
|
||||||
"B1", theme, It.IsAny<IReadOnlyList<string>>(), 500, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedStory);
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.AddAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((StorySegment s, CancellationToken ct) => s);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateStoryAsync(levelId, theme, lessons);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
_mockMistralService.Verify(m => m.GenerateStoryAsync(
|
|
||||||
"B1", It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region GenerateSegmentAsync Tests
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateSegmentAsync_WithValidData_ReturnsSegment()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var levelId = 1;
|
|
||||||
var lessonId = 1;
|
|
||||||
var theme = "Abenteuer";
|
|
||||||
var vocabulary = new List<string> { "Haus", "Hund", "Katze" };
|
|
||||||
var order = 1;
|
|
||||||
var expectedContent = "Ein kurzer Text...";
|
|
||||||
|
|
||||||
_mockMistralService.Setup(m => m.GenerateStoryAsync(
|
|
||||||
"A1", theme, vocabulary, 100, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(expectedContent);
|
|
||||||
|
|
||||||
var createdSegment = CreateTestSegment(100, levelId, lessonId, expectedContent, order,
|
|
||||||
$"{theme} - Part {order}", theme);
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.AddAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(createdSegment);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateSegmentAsync(levelId, lessonId, theme, vocabulary, order);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual(100, result.Id);
|
|
||||||
Assert.AreEqual(expectedContent, result.Content);
|
|
||||||
Assert.AreEqual($"{theme} - Part {order}", result.Title);
|
|
||||||
_mockMistralService.Verify(m => m.GenerateStoryAsync(
|
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateSegmentAsync_WithNoVocabulary_ThrowsException()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var levelId = 1;
|
|
||||||
var lessonId = 1;
|
|
||||||
var theme = "Abenteuer";
|
|
||||||
var vocabulary = new List<string>();
|
|
||||||
var order = 1;
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _service.GenerateSegmentAsync(levelId, lessonId, theme, vocabulary, order);
|
|
||||||
Assert.Fail("Expected InvalidOperationException was not thrown");
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
Assert.IsTrue(ex.Message.Contains("No vocabulary"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateSegmentAsync_WithEmptyResponse_ThrowsException()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var levelId = 1;
|
|
||||||
var lessonId = 1;
|
|
||||||
var theme = "Abenteuer";
|
|
||||||
var vocabulary = new List<string> { "Test" };
|
|
||||||
var order = 1;
|
|
||||||
|
|
||||||
_mockMistralService.Setup(m => m.GenerateStoryAsync(
|
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(string.Empty);
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _service.GenerateSegmentAsync(levelId, lessonId, theme, vocabulary, order);
|
|
||||||
Assert.Fail("Expected InvalidOperationException was not thrown");
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
Assert.IsTrue(ex.Message.Contains("Empty response"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region GenerateAudioAsync Tests
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateAudioAsync_WithExistingSegmentAndNoAudio_GeneratesAudio()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var segmentId = 1;
|
|
||||||
var segment = CreateTestSegment(segmentId, 1, 1, "Test content", 1, "Test Title", "Test Theme");
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.GetByIdAsync(segmentId, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(segment);
|
|
||||||
|
|
||||||
_mockTtsService.Setup(t => t.GenerateAudioToFileAsync(
|
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync("/audio/story/level1-segment1.wav");
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
||||||
.Returns(Task.CompletedTask);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateAudioAsync(segmentId);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual("/audio/story/level1-segment1.wav", result.AudioUrl);
|
|
||||||
_mockTtsService.Verify(t => t.GenerateAudioToFileAsync(
|
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateAudioAsync_WithNonExistingSegment_ReturnsNull()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var segmentId = 999;
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.GetByIdAsync(segmentId, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((StorySegment?)null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateAudioAsync(segmentId);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateAudioAsync_WithExistingAudio_ReturnsExisting()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var segmentId = 1;
|
|
||||||
var segment = CreateTestSegment(segmentId, 1, 1, "Test content", 1, "Test Title", "Test Theme");
|
|
||||||
segment.UpdateAudioUrl("/audio/existing.wav");
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.GetByIdAsync(segmentId, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(segment);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GenerateAudioAsync(segmentId);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(result);
|
|
||||||
Assert.AreEqual("/audio/existing.wav", result.AudioUrl);
|
|
||||||
_mockTtsService.Verify(t => t.GenerateAudioToFileAsync(
|
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region GenerateAudioForAllSegmentsAsync Tests
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateAudioForAllSegmentsAsync_WithSegmentsNeedingAudio_GeneratesAll()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var segment1 = CreateTestSegment(1, 1, 1, "Content 1", 1, "Title 1", "Theme");
|
|
||||||
var segment2 = CreateTestSegment(2, 1, 2, "Content 2", 2, "Title 2", "Theme");
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.GetSegmentsNeedingAudioAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(new List<StorySegment> { segment1, segment2 });
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.GetByIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((int id, CancellationToken ct) => id == 1 ? segment1 : segment2);
|
|
||||||
|
|
||||||
_mockTtsService.Setup(t => t.GenerateAudioToFileAsync(
|
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((string text, string outputPath, string? speaker, string language, CancellationToken ct) => outputPath);
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
||||||
.Returns(Task.CompletedTask);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var results = await _service.GenerateAudioForAllSegmentsAsync();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(results);
|
|
||||||
Assert.AreEqual(2, results.Count);
|
|
||||||
_mockTtsService.Verify(t => t.GenerateAudioToFileAsync(
|
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateAudioForAllSegmentsAsync_WithLevelFilter_FiltersByLevel()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var segment1 = CreateTestSegment(1, 1, 1, "Content 1", 1, "Title 1", "Theme");
|
|
||||||
var segment2 = CreateTestSegment(2, 2, 1, "Content 2", 1, "Title 2", "Theme");
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.GetSegmentsNeedingAudioAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(new List<StorySegment> { segment1, segment2 });
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.GetByIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((int id, CancellationToken ct) => id == 1 ? segment1 : segment2);
|
|
||||||
|
|
||||||
_mockTtsService.Setup(t => t.GenerateAudioToFileAsync(
|
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((string text, string outputPath, string? speaker, string language, CancellationToken ct) => outputPath);
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
||||||
.Returns(Task.CompletedTask);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var results = await _service.GenerateAudioForAllSegmentsAsync(levelId: 1);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(results);
|
|
||||||
Assert.AreEqual(1, results.Count);
|
|
||||||
Assert.AreEqual(1, results[0].LevelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateAudioForAllSegmentsAsync_WithNoSegments_ReturnsEmpty()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
_mockStoryRepository.Setup(s => s.GetSegmentsNeedingAudioAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(new List<StorySegment>());
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var results = await _service.GenerateAudioForAllSegmentsAsync();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(results);
|
|
||||||
Assert.AreEqual(0, results.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
public async Task GenerateAudioForAllSegmentsAsync_WhenOneFails_ContinuesWithOthers()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var segment1 = CreateTestSegment(1, 1, 1, "Content 1", 1, "Title 1", "Theme");
|
|
||||||
var segment2 = CreateTestSegment(2, 1, 2, "Content 2", 2, "Title 2", "Theme");
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.GetSegmentsNeedingAudioAsync(It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(new List<StorySegment> { segment1, segment2 });
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.GetByIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((int id, CancellationToken ct) => id == 1 ? segment1 : segment2);
|
|
||||||
|
|
||||||
// First call succeeds, second throws
|
|
||||||
_mockTtsService.SetupSequence(t => t.GenerateAudioToFileAsync(
|
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync("/audio/test1.wav")
|
|
||||||
.ThrowsAsync(new Exception("TTS Error"));
|
|
||||||
|
|
||||||
_mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
||||||
.Returns(Task.CompletedTask);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var results = await _service.GenerateAudioForAllSegmentsAsync();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(results);
|
|
||||||
Assert.AreEqual(1, results.Count); // Only first one succeeded
|
|
||||||
Assert.AreEqual(1, results[0].Id);
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue