DeutschLernen/GermanApp/Application/Services/UserReportService.cs
Lasse Rune Hansen 50f1b8a8dc feat(backend): Implement mandatory authentication and admin module
- Add Role property to User entity with migration
- Create BootstrapController for first admin user creation
- Remove [AllowAnonymous] from all learning content controllers
- Create AdminController with admin-only endpoints
- Create AdminService for user management
- Create UserReportService for progress reports
- Add UserRepository implementation
- Update AuthService with role support

feat(frontend): Implement authentication system
- Add AuthStore with React context for auth state management
- Create Login, Register, Landing, and Home pages
- Add ProtectedRoute and AdminRoute components
- Create Auth API types and client
- Configure Vite with @/ path alias
- Add comprehensive CSS styles for auth and landing pages

BREAKING CHANGE: All learning content now requires authentication.
Users must register and sign in before accessing lessons, quizzes, and stories.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-14 12:42:15 +02:00

189 lines
7.5 KiB
C#

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