Compare commits

..

1 commit

Author SHA1 Message Date
Lasse Rune Hansen
0b61a0d35a docs(roadmap): note that Phase 2 changes merged to main
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-09 07:16:27 +02:00
153 changed files with 363 additions and 30139 deletions

View file

@ -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
View file

@ -1,10 +1,9 @@
# Dependencies
# Create .gitignore (if not already present)
echo "# Dependencies
node_modules/
dist/
build/
*.log
# Environment files
.env
.env.local
.env.*.local
@ -26,9 +25,6 @@ obj/
*.suo
*.cache
# Configuration files (contain secrets)
appsettings.json
appsettings.*.json
# Frontend
.coverage
" > .gitignore

View file

@ -65,21 +65,6 @@ steps:
- docker pull registry.lrhdev.dk/lasserh/deutschlernen-frontend:latest
- cd /opt/deutschlernen
- 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 image prune -f
environment:
@ -87,35 +72,5 @@ steps:
from_secret: REGISTRY_USERNAME
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:
- branch: main

View file

@ -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);

View file

@ -8,7 +8,6 @@ public record AuthResponse
public int UserId { get; init; }
public string Username { 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 RefreshToken { get; init; } = string.Empty;
public DateTime ExpiresAt { get; init; }

View file

@ -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();
}
}
}

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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);
}

View file

@ -42,18 +42,4 @@ public interface IAuthService
/// </summary>
/// <param name="refreshToken">The refresh token to revoke</param>
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();
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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>();
}

View file

@ -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);
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -22,7 +22,7 @@ public class LessonService
/// <summary>
/// Gets all lessons.
/// </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);
return lessons.Select(l => l.ToDto()).ToList();
@ -31,7 +31,7 @@ public class LessonService
/// <summary>
/// Gets all lessons ordered by level and lesson order.
/// </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);
return lessons.Select(l => l.ToDto()).ToList();
@ -40,7 +40,7 @@ public class LessonService
/// <summary>
/// Gets a lesson by its ID.
/// </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);
return lesson?.ToDto();
@ -49,7 +49,7 @@ public class LessonService
/// <summary>
/// Gets lessons by level ID.
/// </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);
return lessons.Select(l => l.ToDto()).ToList();
@ -58,7 +58,7 @@ public class LessonService
/// <summary>
/// Gets beginner lessons (A1 and A2 - levels 1 and 2).
/// </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 level2Lessons = await _lessonRepository.GetByLevelAsync(2, cancellationToken);
@ -72,7 +72,7 @@ public class LessonService
/// <summary>
/// Gets advanced lessons (B2 and C1 - levels 4 and 5).
/// </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 level5Lessons = await _lessonRepository.GetByLevelAsync(5, cancellationToken);
@ -86,7 +86,7 @@ public class LessonService
/// <summary>
/// Creates a new lesson.
/// </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
var level = await _levelRepository.GetByIdAsync(dto.LevelId, cancellationToken);
@ -101,7 +101,7 @@ public class LessonService
/// <summary>
/// Updates an existing lesson.
/// </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);
if (lesson == null)
@ -120,7 +120,7 @@ public class LessonService
/// <summary>
/// Deletes a lesson by its ID.
/// </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);
if (lesson == null)
@ -133,7 +133,7 @@ public class LessonService
/// <summary>
/// Gets the first lesson in a level.
/// </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);
return lesson?.ToDto();
@ -142,7 +142,7 @@ public class LessonService
/// <summary>
/// Gets the next lesson after the specified one.
/// </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);
return lesson?.ToDto();
@ -151,7 +151,7 @@ public class LessonService
/// <summary>
/// Gets lessons that the user can access (based on completion of previous lessons).
/// </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);
return lessons.Select(l => l.ToDto()).ToList();

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -20,7 +20,7 @@ public class LevelService
/// <summary>
/// Gets all CEFR levels ordered by their sort order.
/// </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);
return levels.Select(l => l.ToDto()).ToList();
@ -29,7 +29,7 @@ public class LevelService
/// <summary>
/// Gets a level by its ID.
/// </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);
return level?.ToDto();
@ -38,7 +38,7 @@ public class LevelService
/// <summary>
/// Gets a level by its code (e.g., "A1", "B2").
/// </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);
return level?.ToDto();
@ -47,7 +47,7 @@ public class LevelService
/// <summary>
/// Creates a new CEFR level.
/// </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 createdLevel = await _levelRepository.AddAsync(level, cancellationToken);
@ -57,7 +57,7 @@ public class LevelService
/// <summary>
/// Updates an existing CEFR level.
/// </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);
if (level == null)
@ -71,7 +71,7 @@ public class LevelService
/// <summary>
/// Deletes a CEFR level by its ID.
/// </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);
if (level == null)
@ -84,7 +84,7 @@ public class LevelService
/// <summary>
/// Gets the first level (lowest order number) - typically A1.
/// </summary>
public virtual async Task<LevelDto?> GetFirstLevelAsync(CancellationToken cancellationToken = default)
public async Task<LevelDto?> GetFirstLevelAsync(CancellationToken cancellationToken = default)
{
var level = await _levelRepository.GetFirstLevelAsync(cancellationToken);
return level?.ToDto();
@ -93,7 +93,7 @@ public class LevelService
/// <summary>
/// Gets the next level after the specified one.
/// </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);
return level?.ToDto();

View file

@ -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");
}
}

View file

@ -27,7 +27,7 @@ public class ProgressService
/// <summary>
/// Gets user progress for a specific lesson.
/// </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);
return progress?.ToDto();
@ -36,7 +36,7 @@ public class ProgressService
/// <summary>
/// Gets all progress records for a user.
/// </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);
return progressRecords.Select(p => p.ToDto()).ToList();
@ -45,7 +45,7 @@ public class ProgressService
/// <summary>
/// Gets progress for all lessons in a specific level for a user.
/// </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);
return progressRecords.Select(p => p.ToDto()).ToList();
@ -54,7 +54,7 @@ public class ProgressService
/// <summary>
/// Checks if a user has completed a lesson.
/// </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);
}
@ -62,7 +62,7 @@ public class ProgressService
/// <summary>
/// Marks a lesson as completed for a user with a quiz score.
/// </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);
@ -86,7 +86,7 @@ public class ProgressService
/// <summary>
/// Updates user progress for a lesson without marking as completed.
/// </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);
@ -109,7 +109,7 @@ public class ProgressService
/// <summary>
/// Resets user progress for a lesson.
/// </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);
if (progress == null)
@ -123,7 +123,7 @@ public class ProgressService
/// <summary>
/// Gets the user's average score for a level.
/// </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);
}
@ -131,7 +131,7 @@ public class ProgressService
/// <summary>
/// Gets the percentage of lessons completed in a level.
/// </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);
}
@ -139,7 +139,7 @@ public class ProgressService
/// <summary>
/// Gets level completion summaries for a user.
/// </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 completions = new List<LevelCompletionDto>();
@ -176,7 +176,7 @@ public class ProgressService
/// <summary>
/// Gets lessons that the user can access (completed previous lessons or first in level).
/// </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);
return lessons.Select(l => l.ToDto()).ToList();
@ -185,7 +185,7 @@ public class ProgressService
/// <summary>
/// Checks if the next lesson in a level is unlocked for the user.
/// </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);
if (currentLesson == null)

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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;
}

View file

@ -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"
};
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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();
}
}

View file

@ -107,7 +107,4 @@ public class Lesson
IsActive = false;
UpdatedAt = DateTime.UtcNow;
}
// Navigation properties
public virtual ICollection<StorySegment> StorySegments { get; private set; } = new List<StorySegment>();
}

View file

@ -54,8 +54,4 @@ public class Level
{
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>();
}

View file

@ -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;
}
}

View file

@ -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
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -10,7 +10,6 @@ public class User
public string Email { get; private set; } = string.Empty;
public string PasswordHash { get; private set; } = string.Empty;
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 TotalPoints { get; private set; }
public DateTime CreatedAt { get; private set; }
@ -31,7 +30,6 @@ public class User
Email = email.ToLowerInvariant(),
PasswordHash = passwordHash,
CurrentLevel = "A1",
Role = "User", // Explicitly set default role
Streak = 0,
TotalPoints = 0,
CreatedAt = DateTime.UtcNow
@ -77,31 +75,4 @@ public class User
{
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>();
}

View file

@ -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
}

View file

@ -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);
}

View file

@ -133,145 +133,3 @@ public interface IUserProgressRepository : IRepository<UserProgress, int>
/// </summary>
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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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();
}

View file

@ -13,9 +13,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<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="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />

View file

@ -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");
}
}
}

View file

@ -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");
}
}
}

View file

@ -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");
}
}
}

View file

@ -14,16 +14,11 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
}
// DbSets for domain entities
public virtual DbSet<Level> Levels { get; set; } = null!;
public virtual DbSet<Lesson> Lessons { get; set; } = null!;
public virtual DbSet<User> Users { get; set; } = null!;
public virtual DbSet<UserProgress> UserProgress { get; set; } = null!;
public virtual 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!;
public DbSet<Level> Levels { get; set; } = null!;
public DbSet<Lesson> Lessons { get; set; } = null!;
public DbSet<User> Users { get; set; } = null!;
public DbSet<UserProgress> UserProgress { get; set; } = null!;
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
// Note: Value objects are not stored directly as entities.
// 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.PasswordHash).IsRequired().HasMaxLength(255);
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.TotalPoints).HasDefaultValue(0);
builder.Property(u => u.CreatedAt).IsRequired();
@ -138,150 +132,6 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
.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
// In a real application, use migrations or a separate seeding mechanism
// modelBuilder.Entity<Lesson>().HasData(

View file

@ -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);
}
}

View file

@ -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
}
}
}

View file

@ -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");
}
}
}

View file

@ -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
}
}
}

View file

@ -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);
}
}
}

View file

@ -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
}
}
}

View file

@ -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");
}
}
}

View file

@ -46,9 +46,6 @@ namespace GermanApp.Migrations
b.Property<int>("LevelId")
.HasColumnType("integer");
b.Property<int?>("LevelId1")
.HasColumnType("integer");
b.Property<int>("Order")
.HasColumnType("integer");
@ -67,8 +64,6 @@ namespace GermanApp.Migrations
b.HasKey("Id");
b.HasIndex("LevelId1");
b.HasIndex("LevelId", "Order")
.IsUnique();
@ -107,150 +102,6 @@ namespace GermanApp.Migrations
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")
@ -288,128 +139,6 @@ namespace GermanApp.Migrations
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")
@ -438,13 +167,6 @@ namespace GermanApp.Migrations
.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")
@ -516,46 +238,9 @@ namespace GermanApp.Migrations
.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)
@ -565,63 +250,6 @@ namespace GermanApp.Migrations
.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")
@ -640,33 +268,6 @@ namespace GermanApp.Migrations
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
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -1,6 +1,5 @@
using GermanApp.Domain.Entities;
using GermanApp.Infrastructure.Data.DbContext;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace GermanApp.Infrastructure.Data.SeedData;
@ -25,17 +24,6 @@ public static class SeedDataExtension
// Only seed if there are no users
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
var levels = new[]
{
@ -48,14 +36,21 @@ public static class SeedDataExtension
dbContext.Levels.AddRange(levels);
await dbContext.SaveChangesAsync();
// Seed an admin user with properly hashed password
var adminUser = User.Create("admin", "admin@deutschlernen.com", adminPasswordHash);
adminUser.AssignAdminRole(); // Set role to Admin
// Seed an admin user
var adminUser = User.Create(
"admin",
"admin@deutschlernen.com",
"$2a$11$N9qo8uLOickgx2ZMRZoMy.Mrq9Hq4yVJyX2sX7z3J0J9z6J9Z2K4a" // bcrypt hash of "Admin@123!"
);
adminUser.UpdateLevel("C1");
dbContext.Users.Add(adminUser);
// Seed a test user with properly hashed password
var testUser = User.Create("testuser", "test@deutschlernen.com", testPasswordHash);
// Seed a test user
var testUser = User.Create(
"testuser",
"test@deutschlernen.com",
"$2a$11$N9qo8uLOickgx2ZMRZoMy.Mrq9Hq4yVJyX2sX7z3J0J9z6J9Z2K4a" // bcrypt hash of "Test@123!"
);
dbContext.Users.Add(testUser);
// Seed some lessons
@ -71,47 +66,7 @@ public static class SeedDataExtension
await dbContext.SaveChangesAsync();
// Seed quiz questions for the 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.");
Console.WriteLine("Database seeded with levels, admin user, test user, and sample lessons.");
}
}
}

View file

@ -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);
}
}

View file

@ -49,12 +49,11 @@ public class AuthService : IAuthService
/// </summary>
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))
throw new InvalidOperationException("Username already taken");
var normalizedEmail = registerDto.Email?.ToLowerInvariant() ?? string.Empty;
if (await _dbContext.Users.AnyAsync(u => u.Email == normalizedEmail))
if (await _dbContext.Users.AnyAsync(u => u.Email == registerDto.Email))
throw new InvalidOperationException("Email already in use");
// Hash password and create user
@ -79,7 +78,6 @@ public class AuthService : IAuthService
UserId = user.Id,
Username = user.Username,
Email = user.Email,
Role = user.Role,
Token = token,
RefreshToken = refreshTokenString,
ExpiresAt = DateTime.UtcNow.AddHours(24)
@ -91,10 +89,7 @@ public class AuthService : IAuthService
/// </summary>
public async Task<AuthResponse> LoginAsync(LoginDto loginDto)
{
// Normalize email to lowercase for case-insensitive matching
// 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);
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Email == loginDto.Email);
if (user == null)
throw new UnauthorizedAccessException("Invalid email or password");
@ -128,7 +123,6 @@ public class AuthService : IAuthService
UserId = user.Id,
Username = user.Username,
Email = user.Email,
Role = user.Role,
Token = token,
RefreshToken = refreshTokenString,
ExpiresAt = DateTime.UtcNow.AddHours(24)
@ -153,15 +147,12 @@ public class AuthService : IAuthService
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[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), // JWT standard: sub for subject/user ID
new Claim(JwtRegisteredClaimNames.Name, user.Username),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(JwtRegisteredClaimNames.UniqueName, user.Username), // Additional: unique name
new Claim(ClaimTypes.Role, user.Role) // Use user's actual role (User or Admin)
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Role, "User")
};
var token = new JwtSecurityToken(
@ -236,66 +227,4 @@ public class AuthService : IAuthService
storedToken.Revoke();
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");
}
}

View file

@ -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;
}
}
}

View file

@ -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);
}
}
}

View file

@ -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();
}
}
}
}

View file

@ -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;
}
}

View file

@ -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));
}
}

View file

@ -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);
}
}
}

View file

@ -2,9 +2,7 @@ using GermanApp.Application.DTOs.Auth;
using GermanApp.Application.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Security.Claims;
namespace GermanApp.Presentation.Controllers;
@ -29,7 +27,6 @@ public class AuthController : ControllerBase
/// <param name="registerDto">Registration data</param>
/// <returns>Authentication response with JWT token</returns>
[HttpPost("register")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
public async Task<IActionResult> Register([FromBody] RegisterDto registerDto)
@ -55,7 +52,6 @@ public class AuthController : ControllerBase
/// <param name="loginDto">Login data</param>
/// <returns>Authentication response with JWT token</returns>
[HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> Login([FromBody] LoginDto loginDto)
@ -81,33 +77,25 @@ public class AuthController : ControllerBase
/// <returns>Current user information</returns>
[HttpGet("me")]
[Authorize]
[ProducesResponseType((int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> GetCurrentUser()
{
try
{
// Get user ID from JWT claim
// Note: JWT "sub" claim is mapped by ASP.NET Core JWT middleware to ClaimTypes.NameIdentifier
// (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)
var userId = int.Parse(User.FindFirst("nameid")?.Value ?? "0");
if (userId == 0)
return Unauthorized();
var user = await _authService.GetCurrentUserAsync(userId);
if (user == null)
return Unauthorized();
// Return full user profile including role, level, streak, and points
return Ok(new
return Ok(new AuthResponse
{
userId = user.Id,
username = user.Username,
email = user.Email,
role = user.Role,
currentLevel = user.CurrentLevel,
streak = user.Streak,
totalPoints = user.TotalPoints
UserId = user.Id,
Username = user.Username,
Email = user.Email
});
}
catch (Exception ex)

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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 });
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -23,7 +23,6 @@ public static class LessonsEndpoints
var lessons = await repository.GetAllAsync();
return Results.Ok(lessons.Select(l => l.ToDto()));
})
.RequireAuthorization()
.WithName("GetAllLessons")
.WithOpenApi(operation => new(operation)
{
@ -37,7 +36,6 @@ public static class LessonsEndpoints
var lesson = await repository.GetByIdAsync(id);
return lesson is null ? Results.NotFound() : Results.Ok(lesson.ToDto());
})
.RequireAuthorization()
.WithName("GetLessonById")
.WithOpenApi(operation => new(operation)
{
@ -56,7 +54,6 @@ public static class LessonsEndpoints
beginnerLessons.AddRange(level2Lessons);
return Results.Ok(beginnerLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto()));
})
.RequireAuthorization()
.WithName("GetBeginnerLessons")
.WithOpenApi(operation => new(operation)
{
@ -75,7 +72,6 @@ public static class LessonsEndpoints
advancedLessons.AddRange(level5Lessons);
return Results.Ok(advancedLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto()));
})
.RequireAuthorization()
.WithName("GetAdvancedLessons")
.WithOpenApi(operation => new(operation)
{
@ -89,7 +85,6 @@ public static class LessonsEndpoints
var lessons = await repository.GetByLevelAsync(level);
return Results.Ok(lessons.Select(l => l.ToDto()));
})
.RequireAuthorization()
.WithName("GetLessonsByLevel")
.WithOpenApi(operation => new(operation)
{

View file

@ -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.");
}
}

View file

@ -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);
}
}

View file

@ -1,5 +1,3 @@
using FluentValidation;
using FluentValidation.AspNetCore;
using GermanApp.Application.DTOs;
using GermanApp.Application.Services;
using GermanApp.Application.Interfaces;
@ -10,19 +8,14 @@ using GermanApp.Infrastructure.Data.DbContext;
using GermanApp.Infrastructure.Data.Repositories;
using GermanApp.Infrastructure.Data.SeedData;
using GermanApp.Infrastructure.Services;
using GermanApp.Infrastructure.Configuration;
using GermanApp.Presentation.Controllers;
using GermanApp.Presentation.Validators;
using GermanApp.Presentation.Endpoints;
using GermanApp.Shared.Middleware;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Serilog;
using System.IO;
using System.Text;
// Configure Serilog
@ -31,7 +24,7 @@ Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
try
{
var builder = WebApplication.CreateBuilder(args);
@ -50,8 +43,7 @@ try
// Add Health Checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>()
.AddCheck<AiServicesHealthCheck>("ai_services");
.AddDbContextCheck<AppDbContext>();
// Add Password Hasher for custom User entity
builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
@ -78,7 +70,7 @@ try
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
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
builder.Services.AddCors(options =>
{
// AllowAll policy - for production without credentials
// Note: Cannot use AllowAnyOrigin() with AllowCredentials()
options.AddPolicy("AllowAll", builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.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();
// ============================================
// PRESENTATION LAYER - Validation
// ============================================
// Add FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<CreateLevelValidator>();
builder.Services.AddFluentValidationAutoValidation();
// Add services for Minimal APIs
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
@ -154,64 +116,11 @@ try
});
// Register repositories (Infrastructure implementations of Domain interfaces)
builder.Services.AddScoped<IRepository<User, int>, UserRepository>();
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
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
// ============================================
@ -221,65 +130,15 @@ try
builder.Services.AddScoped<LevelService>();
builder.Services.AddScoped<LessonService>();
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>();
// 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();
// 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.
// Use exception middleware first (to catch all exceptions)
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())
{
app.MapOpenApi();
@ -291,6 +150,9 @@ try
app.UseAuthentication();
app.UseAuthorization();
// Use CORS
app.UseCors("AllowAll");
// Use Health Checks
app.MapHealthChecks("/health");
@ -303,20 +165,10 @@ try
app.MapLessonsEndpoints();
// Seed database with initial data
// Run migrations and seed in Development, or just migrations in Production/Docker
// In Docker, we can control seeding via environment variable
var shouldSeed = app.Environment.IsDevelopment() ||
(app.Configuration.GetValue<bool>("SeedDatabase"));
if (shouldSeed)
if (app.Environment.IsDevelopment())
{
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
app.MapGet("/weatherforecast", () =>
@ -339,69 +191,6 @@ try
.WithName("GetWeatherForecast");
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)
{

View file

@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres"
}
}

View 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}"
}
}

View 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}"
}
}

View 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
}
}

View file

@ -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);
}
}

View file

@ -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));
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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