- 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>
189 lines
7.5 KiB
C#
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;
|
|
}
|
|
}
|