Compare commits

..

No commits in common. "a9e9a6e457479106be01a4ade5bb2ef091a7305f" and "8e05c2b6e2cc5c517ccd85f02d9125c665f38c1a" have entirely different histories.

8 changed files with 8 additions and 618 deletions

View file

@ -1,51 +0,0 @@
using GermanApp.Domain.Entities;
namespace GermanApp.Application.DTOs;
/// <summary>
/// Data Transfer Object for Level - used for API responses.
/// This is a read-only representation of a Level entity.
/// </summary>
public record LevelDto(
int Id,
string Name,
string Code,
int Order);
/// <summary>
/// Data Transfer Object for creating a new Level.
/// </summary>
public record CreateLevelDto(
string Name,
string Code,
int Order);
/// <summary>
/// Data Transfer Object for updating an existing Level.
/// </summary>
public record UpdateLevelDto(
string Name,
string Code,
int Order);
/// <summary>
/// Extension methods for mapping between Level entity and DTOs.
/// </summary>
public static class LevelDtoExtensions
{
public static LevelDto ToDto(this Level level) => new(
level.Id,
level.Name,
level.Code,
level.Order);
public static Level ToEntity(this CreateLevelDto dto) =>
Level.Create(dto.Name, dto.Code, dto.Order);
public static void UpdateFromDto(this Level level, UpdateLevelDto dto)
{
level.UpdateName(dto.Name);
level.UpdateCode(dto.Code);
level.UpdateOrder(dto.Order);
}
}

View file

@ -1,80 +0,0 @@
using GermanApp.Domain.Entities;
namespace GermanApp.Application.DTOs;
/// <summary>
/// Data Transfer Object for UserProgress - used for API responses.
/// This is a read-only representation of a UserProgress entity.
/// </summary>
public record UserProgressDto(
int Id,
int UserId,
int LessonId,
bool IsCompleted,
int QuizScore,
DateTime LastAttemptDate,
string? LessonTitle = null,
string? LevelName = null,
string? LevelCode = null);
/// <summary>
/// Data Transfer Object for creating/updating user progress.
/// </summary>
public record UpdateUserProgressDto(
int LessonId,
bool IsCompleted,
int QuizScore);
/// <summary>
/// Data Transfer Object for level completion summary.
/// </summary>
public record LevelCompletionDto(
int LevelId,
string LevelName,
string LevelCode,
int TotalLessons,
int CompletedLessons,
double CompletionPercentage);
/// <summary>
/// Extension methods for mapping between UserProgress entity and DTOs.
/// </summary>
public static class UserProgressDtoExtensions
{
public static UserProgressDto ToDto(this UserProgress userProgress) => new(
userProgress.Id,
userProgress.UserId,
userProgress.LessonId,
userProgress.IsCompleted,
userProgress.QuizScore,
userProgress.LastAttemptDate,
userProgress.Lesson?.Title,
userProgress.Lesson?.Level?.Name,
userProgress.Lesson?.Level?.Code);
public static UserProgress ToEntity(this UpdateUserProgressDto dto, int userId)
{
var progress = UserProgress.Create(userId, dto.LessonId);
if (dto.IsCompleted)
{
progress.MarkAsCompleted(dto.QuizScore);
}
else
{
progress.UpdateQuizScore(dto.QuizScore);
}
return progress;
}
public static void UpdateFromDto(this UserProgress userProgress, UpdateUserProgressDto dto)
{
if (dto.IsCompleted)
{
userProgress.MarkAsCompleted(dto.QuizScore);
}
else
{
userProgress.UpdateQuizScore(dto.QuizScore);
}
}
}

View file

@ -1,159 +0,0 @@
using GermanApp.Application.DTOs;
using GermanApp.Domain.Entities;
using GermanApp.Domain.Interfaces;
namespace GermanApp.Application.Services;
/// <summary>
/// Application service for managing lessons.
/// This is part of the Application layer.
/// </summary>
public class LessonService
{
private readonly ILessonRepository _lessonRepository;
private readonly ILevelRepository _levelRepository;
public LessonService(ILessonRepository lessonRepository, ILevelRepository levelRepository)
{
_lessonRepository = lessonRepository;
_levelRepository = levelRepository;
}
/// <summary>
/// Gets all lessons.
/// </summary>
public async Task<IReadOnlyList<LessonDto>> GetAllLessonsAsync(CancellationToken cancellationToken = default)
{
var lessons = await _lessonRepository.GetAllAsync(cancellationToken);
return lessons.Select(l => l.ToDto()).ToList();
}
/// <summary>
/// Gets all lessons ordered by level and lesson order.
/// </summary>
public async Task<IReadOnlyList<LessonDto>> GetAllOrderedLessonsAsync(CancellationToken cancellationToken = default)
{
var lessons = await _lessonRepository.GetAllOrderedAsync(cancellationToken);
return lessons.Select(l => l.ToDto()).ToList();
}
/// <summary>
/// Gets a lesson by its ID.
/// </summary>
public async Task<LessonDto?> GetLessonByIdAsync(int id, CancellationToken cancellationToken = default)
{
var lesson = await _lessonRepository.GetByIdAsync(id, cancellationToken);
return lesson?.ToDto();
}
/// <summary>
/// Gets lessons by level ID.
/// </summary>
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();
}
/// <summary>
/// Gets beginner lessons (A1 and A2 - levels 1 and 2).
/// </summary>
public async Task<IReadOnlyList<LessonDto>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
{
var level1Lessons = await _lessonRepository.GetByLevelAsync(1, cancellationToken);
var level2Lessons = await _lessonRepository.GetByLevelAsync(2, cancellationToken);
var allLessons = level1Lessons.Concat(level2Lessons)
.OrderBy(l => l.LevelId)
.ThenBy(l => l.Order)
.ToList();
return allLessons.Select(l => l.ToDto()).ToList();
}
/// <summary>
/// Gets advanced lessons (B2 and C1 - levels 4 and 5).
/// </summary>
public async Task<IReadOnlyList<LessonDto>> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
{
var level4Lessons = await _lessonRepository.GetByLevelAsync(4, cancellationToken);
var level5Lessons = await _lessonRepository.GetByLevelAsync(5, cancellationToken);
var allLessons = level4Lessons.Concat(level5Lessons)
.OrderBy(l => l.LevelId)
.ThenBy(l => l.Order)
.ToList();
return allLessons.Select(l => l.ToDto()).ToList();
}
/// <summary>
/// Creates a new lesson.
/// </summary>
public async Task<LessonDto> CreateLessonAsync(CreateLessonDto dto, CancellationToken cancellationToken = default)
{
// Validate that the level exists
var level = await _levelRepository.GetByIdAsync(dto.LevelId, cancellationToken);
if (level == null)
throw new ArgumentException("Level does not exist");
var lesson = dto.ToEntity();
var createdLesson = await _lessonRepository.AddAsync(lesson, cancellationToken);
return createdLesson.ToDto();
}
/// <summary>
/// Updates an existing lesson.
/// </summary>
public async Task<LessonDto?> UpdateLessonAsync(int id, UpdateLessonDto dto, CancellationToken cancellationToken = default)
{
var lesson = await _lessonRepository.GetByIdAsync(id, cancellationToken);
if (lesson == null)
return null;
// Validate that the level exists
var level = await _levelRepository.GetByIdAsync(dto.LevelId, cancellationToken);
if (level == null)
throw new ArgumentException("Level does not exist");
lesson.UpdateFromDto(dto);
await _lessonRepository.UpdateAsync(lesson, cancellationToken);
return lesson.ToDto();
}
/// <summary>
/// Deletes a lesson by its ID.
/// </summary>
public async Task<bool> DeleteLessonAsync(int id, CancellationToken cancellationToken = default)
{
var lesson = await _lessonRepository.GetByIdAsync(id, cancellationToken);
if (lesson == null)
return false;
await _lessonRepository.DeleteAsync(lesson, cancellationToken);
return true;
}
/// <summary>
/// Gets the first lesson in a level.
/// </summary>
public async Task<LessonDto?> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
{
var lesson = await _lessonRepository.GetFirstLessonInLevelAsync(levelId, cancellationToken);
return lesson?.ToDto();
}
/// <summary>
/// Gets the next lesson after the specified one.
/// </summary>
public async Task<LessonDto?> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default)
{
var lesson = await _lessonRepository.GetNextLessonAsync(currentLessonId, cancellationToken);
return lesson?.ToDto();
}
/// <summary>
/// Gets lessons that the user can access (based on completion of previous lessons).
/// </summary>
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,101 +0,0 @@
using GermanApp.Application.DTOs;
using GermanApp.Domain.Entities;
using GermanApp.Domain.Interfaces;
namespace GermanApp.Application.Services;
/// <summary>
/// Application service for managing CEFR levels.
/// This is part of the Application layer.
/// </summary>
public class LevelService
{
private readonly ILevelRepository _levelRepository;
public LevelService(ILevelRepository levelRepository)
{
_levelRepository = levelRepository;
}
/// <summary>
/// Gets all CEFR levels ordered by their sort order.
/// </summary>
public async Task<IReadOnlyList<LevelDto>> GetAllLevelsAsync(CancellationToken cancellationToken = default)
{
var levels = await _levelRepository.GetAllOrderedAsync(cancellationToken);
return levels.Select(l => l.ToDto()).ToList();
}
/// <summary>
/// Gets a level by its ID.
/// </summary>
public async Task<LevelDto?> GetLevelByIdAsync(int id, CancellationToken cancellationToken = default)
{
var level = await _levelRepository.GetByIdAsync(id, cancellationToken);
return level?.ToDto();
}
/// <summary>
/// Gets a level by its code (e.g., "A1", "B2").
/// </summary>
public async Task<LevelDto?> GetLevelByCodeAsync(string code, CancellationToken cancellationToken = default)
{
var level = await _levelRepository.GetByCodeAsync(code, cancellationToken);
return level?.ToDto();
}
/// <summary>
/// Creates a new CEFR level.
/// </summary>
public async Task<LevelDto> CreateLevelAsync(CreateLevelDto dto, CancellationToken cancellationToken = default)
{
var level = dto.ToEntity();
var createdLevel = await _levelRepository.AddAsync(level, cancellationToken);
return createdLevel.ToDto();
}
/// <summary>
/// Updates an existing CEFR level.
/// </summary>
public async Task<LevelDto?> UpdateLevelAsync(int id, UpdateLevelDto dto, CancellationToken cancellationToken = default)
{
var level = await _levelRepository.GetByIdAsync(id, cancellationToken);
if (level == null)
return null;
level.UpdateFromDto(dto);
await _levelRepository.UpdateAsync(level, cancellationToken);
return level.ToDto();
}
/// <summary>
/// Deletes a CEFR level by its ID.
/// </summary>
public async Task<bool> DeleteLevelAsync(int id, CancellationToken cancellationToken = default)
{
var level = await _levelRepository.GetByIdAsync(id, cancellationToken);
if (level == null)
return false;
await _levelRepository.DeleteAsync(level, cancellationToken);
return true;
}
/// <summary>
/// Gets the first level (lowest order number) - typically A1.
/// </summary>
public async Task<LevelDto?> GetFirstLevelAsync(CancellationToken cancellationToken = default)
{
var level = await _levelRepository.GetFirstLevelAsync(cancellationToken);
return level?.ToDto();
}
/// <summary>
/// Gets the next level after the specified one.
/// </summary>
public async Task<LevelDto?> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
{
var level = await _levelRepository.GetNextLevelAsync(currentLevelId, cancellationToken);
return level?.ToDto();
}
}

View file

@ -1,206 +0,0 @@
using GermanApp.Application.DTOs;
using GermanApp.Domain.Entities;
using GermanApp.Domain.Interfaces;
namespace GermanApp.Application.Services;
/// <summary>
/// Application service for managing user progress through lessons.
/// This is part of the Application layer.
/// </summary>
public class ProgressService
{
private readonly IUserProgressRepository _userProgressRepository;
private readonly ILessonRepository _lessonRepository;
private readonly ILevelRepository _levelRepository;
public ProgressService(
IUserProgressRepository userProgressRepository,
ILessonRepository lessonRepository,
ILevelRepository levelRepository)
{
_userProgressRepository = userProgressRepository;
_lessonRepository = lessonRepository;
_levelRepository = levelRepository;
}
/// <summary>
/// Gets user progress for a specific lesson.
/// </summary>
public async Task<UserProgressDto?> GetUserProgressAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
{
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, lessonId, cancellationToken);
return progress?.ToDto();
}
/// <summary>
/// Gets all progress records for a user.
/// </summary>
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();
}
/// <summary>
/// Gets progress for all lessons in a specific level for a user.
/// </summary>
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();
}
/// <summary>
/// Checks if a user has completed a lesson.
/// </summary>
public async Task<bool> HasUserCompletedLessonAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
{
return await _userProgressRepository.HasUserCompletedLessonAsync(userId, lessonId, cancellationToken);
}
/// <summary>
/// Marks a lesson as completed for a user with a quiz score.
/// </summary>
public async Task<UserProgressDto> MarkLessonAsCompletedAsync(int userId, int lessonId, int quizScore, CancellationToken cancellationToken = default)
{
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, lessonId, cancellationToken);
if (progress == null)
{
// Create new progress record
progress = UserProgress.Create(userId, lessonId);
progress.MarkAsCompleted(quizScore);
await _userProgressRepository.AddAsync(progress, cancellationToken);
}
else
{
// Update existing progress record
progress.MarkAsCompleted(quizScore);
await _userProgressRepository.UpdateAsync(progress, cancellationToken);
}
return progress.ToDto();
}
/// <summary>
/// Updates user progress for a lesson without marking as completed.
/// </summary>
public async Task<UserProgressDto> UpdateUserProgressAsync(int userId, UpdateUserProgressDto dto, CancellationToken cancellationToken = default)
{
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, dto.LessonId, cancellationToken);
if (progress == null)
{
// Create new progress record
progress = dto.ToEntity(userId);
await _userProgressRepository.AddAsync(progress, cancellationToken);
}
else
{
// Update existing progress record
progress.UpdateFromDto(dto);
await _userProgressRepository.UpdateAsync(progress, cancellationToken);
}
return progress.ToDto();
}
/// <summary>
/// Resets user progress for a lesson.
/// </summary>
public async Task<bool> ResetLessonProgressAsync(int userId, int lessonId, CancellationToken cancellationToken = default)
{
var progress = await _userProgressRepository.GetByUserAndLessonAsync(userId, lessonId, cancellationToken);
if (progress == null)
return false;
progress.Reset();
await _userProgressRepository.UpdateAsync(progress, cancellationToken);
return true;
}
/// <summary>
/// Gets the user's average score for a level.
/// </summary>
public async Task<double> GetAverageScoreForLevelAsync(int userId, int levelId, CancellationToken cancellationToken = default)
{
return await _userProgressRepository.GetAverageScoreForLevelAsync(userId, levelId, cancellationToken);
}
/// <summary>
/// Gets the percentage of lessons completed in a level.
/// </summary>
public async Task<double> GetLevelCompletionPercentageAsync(int userId, int levelId, CancellationToken cancellationToken = default)
{
return await _userProgressRepository.GetLevelCompletionPercentageAsync(userId, levelId, cancellationToken);
}
/// <summary>
/// Gets level completion summaries for a user.
/// </summary>
public async Task<IReadOnlyList<LevelCompletionDto>> GetLevelCompletionsAsync(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 completedCount = 0;
foreach (var lesson in lessons)
{
var hasCompleted = await _userProgressRepository.HasUserCompletedLessonAsync(userId, lesson.Id, cancellationToken);
if (hasCompleted)
completedCount++;
}
var percentage = lessons.Count > 0
? (double)completedCount / lessons.Count * 100
: 0;
completions.Add(new LevelCompletionDto(
level.Id,
level.Name,
level.Code,
lessons.Count,
completedCount,
percentage
));
}
return completions;
}
/// <summary>
/// Gets lessons that the user can access (completed previous lessons or first in level).
/// </summary>
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();
}
/// <summary>
/// Checks if the next lesson in a level is unlocked for the user.
/// </summary>
public 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;
}
}

View file

@ -1,5 +1,4 @@
using GermanApp.Application.DTOs;
using GermanApp.Application.Services;
using GermanApp.Application.Interfaces;
using GermanApp.Application.UseCases.Commands;
using GermanApp.Domain.Entities;
@ -116,20 +115,13 @@ try
});
// Register repositories (Infrastructure implementations of Domain interfaces)
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
// ============================================
// APPLICATION LAYER - Use Cases & Services
// ============================================
// Register command handlers
// Register application services
builder.Services.AddScoped<LevelService>();
builder.Services.AddScoped<LessonService>();
builder.Services.AddScoped<ProgressService>();
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
var app = builder.Build();

View file

@ -91,7 +91,7 @@ Implement the core backend functionality, including lesson management, AI servic
### Features
| # | Feature | Description | Hours | Status | Dependencies |
|---|---------|-------------|-------|--------|--------------|
| 2.1 | [Lesson Management](features/lesson-management.md) | Lessons, levels, progress tracking | 10-16h | 🚀 In Progress (Phase 1 & 2 ✅) | Phase 1 |
| 2.1 | [Lesson Management](features/lesson-management.md) | Lessons, levels, progress tracking | 10-16h | 🚀 In Progress (Phase 1: DB & Models ✅) | Phase 1 |
| 2.2 | [AI Services](features/ai-services.md) | Mistral, Vosk, Coqui TTS integration | 10-16h | ⏳ Planned | Phase 1 |
| 2.3 | [Vocabulary System](features/vocabulary-system.md) | Word storage, audio, import | 8-12h | ⏳ Planned | Phase 1, 2.1 |
| 2.4 | [Quiz System](features/quiz-system.md) | Multiple question types, scoring | 6-10h | ⏳ Planned | Phase 1, 2.1 |

View file

@ -12,8 +12,6 @@
**Phase 1: Database & Models - COMPLETED ✅**
**Phase 2: Backend Services - COMPLETED ✅**
---
## 📌 Overview
@ -144,12 +142,12 @@ Each lesson contains:
- [x] Seed initial A1 level and lessons
### Phase 2: Backend Services (3-5 hours)
- [x] Create LevelService with CRUD operations
- [x] Create LessonService with CRUD operations
- [x] Create ProgressService to track user progress
- [x] Implement sequential unlocking logic
- [x] Create DTOs for Level, Lesson, UserProgress
- [x] Create mapping profiles (manual extension methods)
- [ ] Create LevelService with CRUD operations
- [ ] Create LessonService with CRUD operations
- [ ] Create ProgressService to track user progress
- [ ] Implement sequential unlocking logic
- [ ] Create DTOs for Level, Lesson, UserProgress
- [ ] Create mapping profiles (AutoMapper or manual)
### Phase 3: API Controllers (2-3 hours)
- [ ] Create LevelsController
@ -194,10 +192,7 @@ Each lesson contains:
- [x] Create Infrastructure/Data/Repositories/UserProgressRepository.cs
- [x] Create Application/DTOs/LevelDto.cs
- [x] Create Application/DTOs/LessonDto.cs
- [x] Create Application/DTOs/UserProgressDto.cs
- [x] Create Application/Services/LevelService.cs
- [x] Create Application/Services/LessonService.cs
- [x] Create Application/Services/ProgressService.cs
- [ ] Create Application/DTOs/UserProgressDto.cs
- [ ] Create Application/Services/LevelService.cs
- [ ] Create Application/Services/LessonService.cs
- [ ] Create Application/Services/ProgressService.cs