Compare commits
No commits in common. "f1ed8a1a7a10f2ded852ac5131040922fb9d9f13" and "536382641d2fe5a4c725813a6daa11d1894be6a8" have entirely different histories.
f1ed8a1a7a
...
536382641d
68 changed files with 149 additions and 10807 deletions
|
|
@ -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);
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Interface for admin services.
|
|
||||||
/// Part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public interface IAdminService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all users for admin view.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of all users</returns>
|
|
||||||
Task<IReadOnlyList<AdminUserListItemDto>> GetAllUsersAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a specific user by ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The user DTO</returns>
|
|
||||||
Task<AdminUserDto?> GetUserByIdAsync(int userId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates a user's role.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="newRole">The new role</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The updated user DTO</returns>
|
|
||||||
Task<AdminUserDto?> UpdateUserRoleAsync(int userId, string newRole, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a user (admin only, cannot delete self).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID to delete</param>
|
|
||||||
/// <param name="adminUserId">The admin user ID making the request</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>True if successful</returns>
|
|
||||||
Task<bool> DeleteUserAsync(int userId, int adminUserId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets admin dashboard statistics.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Dashboard statistics</returns>
|
|
||||||
Task<AdminDashboardStatsDto> GetDashboardStatsAsync(CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
@ -42,18 +42,4 @@ public interface IAuthService
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="refreshToken">The refresh token to revoke</param>
|
/// <param name="refreshToken">The refresh token to revoke</param>
|
||||||
Task RevokeRefreshTokenAsync(string refreshToken);
|
Task RevokeRefreshTokenAsync(string refreshToken);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates the first admin user (bootstrap).
|
|
||||||
/// This is a special method that creates an admin user without requiring authentication.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="registerDto">Admin user registration data</param>
|
|
||||||
/// <returns>Authentication response with token</returns>
|
|
||||||
Task<AuthResponse> CreateAdminUserAsync(RegisterDto registerDto);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if an admin user already exists.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>True if admin user exists, false otherwise</returns>
|
|
||||||
Task<bool> AdminUserExistsAsync();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace GermanApp.Application.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Interface for user progress report services.
|
|
||||||
/// Part of the Application layer.
|
|
||||||
/// </summary>
|
|
||||||
public interface IUserReportService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a progress report for a specific user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>User progress report</returns>
|
|
||||||
Task<UserProgressReportDto> GenerateUserReportAsync(int userId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates progress reports for all users.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of all user progress reports</returns>
|
|
||||||
Task<IReadOnlyList<UserProgressReportDto>> GenerateAllUserReportsAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets lesson completion data for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of lesson completion data</returns>
|
|
||||||
Task<IReadOnlyList<LessonCompletionDto>> GetUserLessonProgressAsync(int userId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz results for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of quiz results</returns>
|
|
||||||
Task<IReadOnlyList<UserQuizResultDto>> GetUserQuizResultsAsync(int userId, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exports user reports as CSV.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>CSV content as string</returns>
|
|
||||||
Task<string> ExportReportsAsCsvAsync(CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
@ -1,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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -227,31 +227,25 @@ public class StoryGenerationService
|
||||||
return StorySegmentDto.FromEntity(segment);
|
return StorySegmentDto.FromEntity(segment);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate file system path for audio file (relative to current directory)
|
// Generate audio file path
|
||||||
var audioDirectory = Path.Combine("wwwroot", "audio", "story");
|
var audioPath = $"/audio/story/level{segment.LevelId}-segment{segment.Order}.wav";
|
||||||
var filename = $"level{segment.LevelId}-segment{segment.Order}.wav";
|
|
||||||
var audioFilePath = Path.Combine(audioDirectory, filename);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Ensure directory exists
|
// Generate audio using the TTS service directly
|
||||||
Directory.CreateDirectory(audioDirectory);
|
|
||||||
|
|
||||||
// Generate audio using the TTS service
|
|
||||||
await _ttsService.GenerateAudioToFileAsync(
|
await _ttsService.GenerateAudioToFileAsync(
|
||||||
segment.Content,
|
segment.Content,
|
||||||
audioFilePath,
|
audioPath,
|
||||||
null,
|
null,
|
||||||
"de",
|
"de",
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
// Convert to URL path for storage
|
// Update segment with audio URL
|
||||||
var audioUrl = $"/audio/story/{filename}";
|
segment.UpdateAudioUrl(audioPath);
|
||||||
segment.UpdateAudioUrl(audioUrl);
|
|
||||||
await _storyRepository.UpdateAsync(segment, cancellationToken);
|
await _storyRepository.UpdateAsync(segment, cancellationToken);
|
||||||
|
|
||||||
_logger.LogInformation("Generated audio for segment {SegmentId}: {AudioUrl}",
|
_logger.LogInformation("Generated audio for segment {SegmentId}: {AudioPath}",
|
||||||
segmentId, audioUrl);
|
segmentId, audioPath);
|
||||||
|
|
||||||
return StorySegmentDto.FromEntity(segment);
|
return StorySegmentDto.FromEntity(segment);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,6 @@ public class User
|
||||||
public string Email { get; private set; } = string.Empty;
|
public string Email { get; private set; } = string.Empty;
|
||||||
public string PasswordHash { get; private set; } = string.Empty;
|
public string PasswordHash { get; private set; } = string.Empty;
|
||||||
public string CurrentLevel { get; private set; } = "A1";
|
public string CurrentLevel { get; private set; } = "A1";
|
||||||
public string Role { get; private set; } = "User"; // Default role is "User", "Admin" for administrators
|
|
||||||
public int Streak { get; private set; }
|
public int Streak { get; private set; }
|
||||||
public int TotalPoints { get; private set; }
|
public int TotalPoints { get; private set; }
|
||||||
public DateTime CreatedAt { get; private set; }
|
public DateTime CreatedAt { get; private set; }
|
||||||
|
|
@ -31,7 +30,6 @@ public class User
|
||||||
Email = email.ToLowerInvariant(),
|
Email = email.ToLowerInvariant(),
|
||||||
PasswordHash = passwordHash,
|
PasswordHash = passwordHash,
|
||||||
CurrentLevel = "A1",
|
CurrentLevel = "A1",
|
||||||
Role = "User", // Explicitly set default role
|
|
||||||
Streak = 0,
|
Streak = 0,
|
||||||
TotalPoints = 0,
|
TotalPoints = 0,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
|
|
@ -78,30 +76,6 @@ public class User
|
||||||
PasswordHash = newPasswordHash;
|
PasswordHash = newPasswordHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Assigns admin role to user (bootstrap only).
|
|
||||||
/// </summary>
|
|
||||||
public void AssignAdminRole()
|
|
||||||
{
|
|
||||||
Role = "Admin";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sets the user's role.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="role">The role to assign (User or Admin)</param>
|
|
||||||
public void SetRole(string role)
|
|
||||||
{
|
|
||||||
if (role != "Admin" && role != "User")
|
|
||||||
throw new ArgumentException("Role must be 'Admin' or 'User'.", nameof(role));
|
|
||||||
Role = role;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if user has admin role.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsAdmin() => Role == "Admin";
|
|
||||||
|
|
||||||
// Navigation properties
|
// Navigation properties
|
||||||
public virtual ICollection<StoryProgress> StoryProgress { get; private set; } = new List<StoryProgress>();
|
public virtual ICollection<StoryProgress> StoryProgress { get; private set; } = new List<StoryProgress>();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,6 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
|
||||||
builder.Property(u => u.Email).IsRequired().HasMaxLength(100);
|
builder.Property(u => u.Email).IsRequired().HasMaxLength(100);
|
||||||
builder.Property(u => u.PasswordHash).IsRequired().HasMaxLength(255);
|
builder.Property(u => u.PasswordHash).IsRequired().HasMaxLength(255);
|
||||||
builder.Property(u => u.CurrentLevel).HasMaxLength(10).HasDefaultValue("A1");
|
builder.Property(u => u.CurrentLevel).HasMaxLength(10).HasDefaultValue("A1");
|
||||||
builder.Property(u => u.Role).HasMaxLength(20).HasDefaultValue("User");
|
|
||||||
builder.Property(u => u.Streak).HasDefaultValue(0);
|
builder.Property(u => u.Streak).HasDefaultValue(0);
|
||||||
builder.Property(u => u.TotalPoints).HasDefaultValue(0);
|
builder.Property(u => u.TotalPoints).HasDefaultValue(0);
|
||||||
builder.Property(u => u.CreatedAt).IsRequired();
|
builder.Property(u => u.CreatedAt).IsRequired();
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Design;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.DbContext;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Factory for creating AppDbContext instances during design-time (e.g., EF migrations).
|
|
||||||
/// This is used by EF Core tools to create the DbContext without running the full application startup.
|
|
||||||
/// </summary>
|
|
||||||
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
|
||||||
{
|
|
||||||
public AppDbContext CreateDbContext(string[] args)
|
|
||||||
{
|
|
||||||
// Build configuration from appsettings.json
|
|
||||||
var configuration = new ConfigurationBuilder()
|
|
||||||
.SetBasePath(Directory.GetCurrentDirectory())
|
|
||||||
.AddJsonFile("appsettings.json")
|
|
||||||
.AddJsonFile("appsettings.Development.json", optional: true)
|
|
||||||
.AddEnvironmentVariables()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var builder = new DbContextOptionsBuilder<AppDbContext>();
|
|
||||||
|
|
||||||
// Use PostgreSQL
|
|
||||||
var connectionString = configuration.GetConnectionString("DefaultConnection")
|
|
||||||
?? "Host=localhost;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres";
|
|
||||||
|
|
||||||
builder.UseNpgsql(connectionString);
|
|
||||||
|
|
||||||
return new AppDbContext(builder.Options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,669 +0,0 @@
|
||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using GermanApp.Infrastructure.Data.DbContext;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AppDbContext))]
|
|
||||||
[Migration("20260613125706_AddStorySegmentAndStoryProgressTables")]
|
|
||||||
partial class AddStorySegmentAndStoryProgressTables
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "9.0.0")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LevelId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<string>("Topic")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Lessons");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("Code")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(10)
|
|
||||||
.HasColumnType("character varying(10)");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Code")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Levels");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("PassingScore")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(80);
|
|
||||||
|
|
||||||
b.Property<bool>("ShuffleQuestions")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("TimeLimitMinutes")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.ToTable("Quizzes");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<bool>("IsCorrect")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<int>("QuizQuestionId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Text")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(1000)
|
|
||||||
.HasColumnType("character varying(1000)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("QuizQuestionId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("QuizOptions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("CorrectAnswer")
|
|
||||||
.HasMaxLength(1000)
|
|
||||||
.HasColumnType("character varying(1000)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("Difficulty")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(3);
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<int>("Points")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<string>("QuestionText")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<int>("QuizId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Type")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("QuizId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("QuizQuestions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime>("ExpiresAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<DateTime?>("RevokedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Token")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("RefreshTokens");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StoryProgress", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime?>("CompletedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsCompleted")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("StorySegmentId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UnlockedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("UserId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId");
|
|
||||||
|
|
||||||
b.HasIndex("StorySegmentId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId1");
|
|
||||||
|
|
||||||
b.HasIndex("UserId", "StorySegmentId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("StoryProgress");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StorySegment", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("AudioUrl")
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<string>("Content")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("EstimatedReadingMinutes")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(2);
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int?>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LessonId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LevelId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Theme")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("CurrentLevel")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasMaxLength(10)
|
|
||||||
.HasColumnType("character varying(10)")
|
|
||||||
.HasDefaultValue("A1");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<int>("Streak")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<int>("TotalPoints")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<string>("Username")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Email")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("Username")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Users");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<bool>("IsCompleted")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<DateTime>("LastAttemptDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("QuizScore")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId", "LessonId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("UserProgress");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", null)
|
|
||||||
.WithMany("Lessons")
|
|
||||||
.HasForeignKey("LevelId1");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.QuizQuestion", "QuizQuestion")
|
|
||||||
.WithMany("Options")
|
|
||||||
.HasForeignKey("QuizQuestionId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("QuizQuestion");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Quiz", "Quiz")
|
|
||||||
.WithMany("Questions")
|
|
||||||
.HasForeignKey("QuizId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Quiz");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StoryProgress", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.StorySegment", "StorySegment")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("StorySegmentId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", "User")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
|
||||||
.WithMany("StoryProgress")
|
|
||||||
.HasForeignKey("UserId1");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
|
|
||||||
b.Navigation("StorySegment");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StorySegment", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", null)
|
|
||||||
.WithMany("StorySegments")
|
|
||||||
.HasForeignKey("LessonId1");
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", null)
|
|
||||||
.WithMany("StorySegments")
|
|
||||||
.HasForeignKey("LevelId1");
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", "User")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Lessons");
|
|
||||||
|
|
||||||
b.Navigation("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Questions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Options");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("StoryProgress");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddStorySegmentAndStoryProgressTables : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropForeignKey(
|
|
||||||
name: "FK_QuizQuestions_Lessons_LessonId",
|
|
||||||
table: "QuizQuestions");
|
|
||||||
|
|
||||||
migrationBuilder.RenameColumn(
|
|
||||||
name: "LessonId",
|
|
||||||
table: "QuizQuestions",
|
|
||||||
newName: "QuizId");
|
|
||||||
|
|
||||||
migrationBuilder.RenameIndex(
|
|
||||||
name: "IX_QuizQuestions_LessonId_Order",
|
|
||||||
table: "QuizQuestions",
|
|
||||||
newName: "IX_QuizQuestions_QuizId_Order");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "Points",
|
|
||||||
table: "QuizQuestions",
|
|
||||||
type: "integer",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: 1);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "LevelId1",
|
|
||||||
table: "Lessons",
|
|
||||||
type: "integer",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "Quizzes",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
LessonId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
|
||||||
Description = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
|
|
||||||
PassingScore = table.Column<int>(type: "integer", nullable: false, defaultValue: 80),
|
|
||||||
TimeLimitMinutes = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
|
|
||||||
IsActive = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
|
||||||
ShuffleQuestions = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_Quizzes", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Quizzes_Lessons_LessonId",
|
|
||||||
column: x => x.LessonId,
|
|
||||||
principalTable: "Lessons",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "StorySegments",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
LevelId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
LessonId = table.Column<int>(type: "integer", nullable: true),
|
|
||||||
Content = table.Column<string>(type: "text", nullable: false),
|
|
||||||
AudioUrl = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
|
|
||||||
Order = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
|
||||||
Theme = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
|
||||||
EstimatedReadingMinutes = table.Column<int>(type: "integer", nullable: false, defaultValue: 2),
|
|
||||||
IsActive = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
|
||||||
LessonId1 = table.Column<int>(type: "integer", nullable: true),
|
|
||||||
LevelId1 = table.Column<int>(type: "integer", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_StorySegments", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StorySegments_Lessons_LessonId",
|
|
||||||
column: x => x.LessonId,
|
|
||||||
principalTable: "Lessons",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.SetNull);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StorySegments_Lessons_LessonId1",
|
|
||||||
column: x => x.LessonId1,
|
|
||||||
principalTable: "Lessons",
|
|
||||||
principalColumn: "Id");
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StorySegments_Levels_LevelId",
|
|
||||||
column: x => x.LevelId,
|
|
||||||
principalTable: "Levels",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StorySegments_Levels_LevelId1",
|
|
||||||
column: x => x.LevelId1,
|
|
||||||
principalTable: "Levels",
|
|
||||||
principalColumn: "Id");
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "StoryProgress",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
UserId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
LevelId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
StorySegmentId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
IsCompleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
|
||||||
UnlockedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
|
||||||
UserId1 = table.Column<int>(type: "integer", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_StoryProgress", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StoryProgress_Levels_LevelId",
|
|
||||||
column: x => x.LevelId,
|
|
||||||
principalTable: "Levels",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StoryProgress_StorySegments_StorySegmentId",
|
|
||||||
column: x => x.StorySegmentId,
|
|
||||||
principalTable: "StorySegments",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StoryProgress_Users_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "Users",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_StoryProgress_Users_UserId1",
|
|
||||||
column: x => x.UserId1,
|
|
||||||
principalTable: "Users",
|
|
||||||
principalColumn: "Id");
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Lessons_LevelId1",
|
|
||||||
table: "Lessons",
|
|
||||||
column: "LevelId1");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Quizzes_LessonId",
|
|
||||||
table: "Quizzes",
|
|
||||||
column: "LessonId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StoryProgress_LevelId",
|
|
||||||
table: "StoryProgress",
|
|
||||||
column: "LevelId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StoryProgress_StorySegmentId",
|
|
||||||
table: "StoryProgress",
|
|
||||||
column: "StorySegmentId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StoryProgress_UserId_StorySegmentId",
|
|
||||||
table: "StoryProgress",
|
|
||||||
columns: new[] { "UserId", "StorySegmentId" },
|
|
||||||
unique: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StoryProgress_UserId1",
|
|
||||||
table: "StoryProgress",
|
|
||||||
column: "UserId1");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StorySegments_LessonId",
|
|
||||||
table: "StorySegments",
|
|
||||||
column: "LessonId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StorySegments_LessonId1",
|
|
||||||
table: "StorySegments",
|
|
||||||
column: "LessonId1");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StorySegments_LevelId_Order",
|
|
||||||
table: "StorySegments",
|
|
||||||
columns: new[] { "LevelId", "Order" },
|
|
||||||
unique: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_StorySegments_LevelId1",
|
|
||||||
table: "StorySegments",
|
|
||||||
column: "LevelId1");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_Lessons_Levels_LevelId1",
|
|
||||||
table: "Lessons",
|
|
||||||
column: "LevelId1",
|
|
||||||
principalTable: "Levels",
|
|
||||||
principalColumn: "Id");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_QuizQuestions_Quizzes_QuizId",
|
|
||||||
table: "QuizQuestions",
|
|
||||||
column: "QuizId",
|
|
||||||
principalTable: "Quizzes",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropForeignKey(
|
|
||||||
name: "FK_Lessons_Levels_LevelId1",
|
|
||||||
table: "Lessons");
|
|
||||||
|
|
||||||
migrationBuilder.DropForeignKey(
|
|
||||||
name: "FK_QuizQuestions_Quizzes_QuizId",
|
|
||||||
table: "QuizQuestions");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "Quizzes");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "StoryProgress");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "StorySegments");
|
|
||||||
|
|
||||||
migrationBuilder.DropIndex(
|
|
||||||
name: "IX_Lessons_LevelId1",
|
|
||||||
table: "Lessons");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "Points",
|
|
||||||
table: "QuizQuestions");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "LevelId1",
|
|
||||||
table: "Lessons");
|
|
||||||
|
|
||||||
migrationBuilder.RenameColumn(
|
|
||||||
name: "QuizId",
|
|
||||||
table: "QuizQuestions",
|
|
||||||
newName: "LessonId");
|
|
||||||
|
|
||||||
migrationBuilder.RenameIndex(
|
|
||||||
name: "IX_QuizQuestions_QuizId_Order",
|
|
||||||
table: "QuizQuestions",
|
|
||||||
newName: "IX_QuizQuestions_LessonId_Order");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_QuizQuestions_Lessons_LessonId",
|
|
||||||
table: "QuizQuestions",
|
|
||||||
column: "LessonId",
|
|
||||||
principalTable: "Lessons",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,676 +0,0 @@
|
||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using GermanApp.Infrastructure.Data.DbContext;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AppDbContext))]
|
|
||||||
[Migration("20260614101057_AddRoleToUser")]
|
|
||||||
partial class AddRoleToUser
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "9.0.0")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LevelId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<string>("Topic")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Lessons");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("Code")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(10)
|
|
||||||
.HasColumnType("character varying(10)");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Code")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Levels");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("PassingScore")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(80);
|
|
||||||
|
|
||||||
b.Property<bool>("ShuffleQuestions")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("TimeLimitMinutes")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.ToTable("Quizzes");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<bool>("IsCorrect")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<int>("QuizQuestionId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Text")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(1000)
|
|
||||||
.HasColumnType("character varying(1000)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("QuizQuestionId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("QuizOptions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("CorrectAnswer")
|
|
||||||
.HasMaxLength(1000)
|
|
||||||
.HasColumnType("character varying(1000)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("Difficulty")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(3);
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<int>("Points")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<string>("QuestionText")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<int>("QuizId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Type")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("QuizId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("QuizQuestions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime>("ExpiresAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<DateTime?>("RevokedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Token")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("RefreshTokens");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StoryProgress", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime?>("CompletedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsCompleted")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("StorySegmentId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UnlockedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("UserId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId");
|
|
||||||
|
|
||||||
b.HasIndex("StorySegmentId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId1");
|
|
||||||
|
|
||||||
b.HasIndex("UserId", "StorySegmentId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("StoryProgress");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StorySegment", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("AudioUrl")
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<string>("Content")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("EstimatedReadingMinutes")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(2);
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int?>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LessonId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LevelId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Theme")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("CurrentLevel")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasMaxLength(10)
|
|
||||||
.HasColumnType("character varying(10)")
|
|
||||||
.HasDefaultValue("A1");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<string>("Role")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasMaxLength(20)
|
|
||||||
.HasColumnType("character varying(20)")
|
|
||||||
.HasDefaultValue("User");
|
|
||||||
|
|
||||||
b.Property<int>("Streak")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<int>("TotalPoints")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<string>("Username")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Email")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("Username")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Users");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<bool>("IsCompleted")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<DateTime>("LastAttemptDate")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("QuizScore")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId", "LessonId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("UserProgress");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", null)
|
|
||||||
.WithMany("Lessons")
|
|
||||||
.HasForeignKey("LevelId1");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.QuizQuestion", "QuizQuestion")
|
|
||||||
.WithMany("Options")
|
|
||||||
.HasForeignKey("QuizQuestionId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("QuizQuestion");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Quiz", "Quiz")
|
|
||||||
.WithMany("Questions")
|
|
||||||
.HasForeignKey("QuizId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Quiz");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StoryProgress", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.StorySegment", "StorySegment")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("StorySegmentId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", "User")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
|
||||||
.WithMany("StoryProgress")
|
|
||||||
.HasForeignKey("UserId1");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
|
|
||||||
b.Navigation("StorySegment");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StorySegment", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", null)
|
|
||||||
.WithMany("StorySegments")
|
|
||||||
.HasForeignKey("LessonId1");
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", null)
|
|
||||||
.WithMany("StorySegments")
|
|
||||||
.HasForeignKey("LevelId1");
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", "User")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Lessons");
|
|
||||||
|
|
||||||
b.Navigation("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Questions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Options");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("StoryProgress");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddRoleToUser : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "Role",
|
|
||||||
table: "Users",
|
|
||||||
type: "character varying(20)",
|
|
||||||
maxLength: 20,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "User");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "Role",
|
|
||||||
table: "Users");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -46,9 +46,6 @@ namespace GermanApp.Migrations
|
||||||
b.Property<int>("LevelId")
|
b.Property<int>("LevelId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int?>("LevelId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
b.Property<int>("Order")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
|
@ -67,8 +64,6 @@ namespace GermanApp.Migrations
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("LevelId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId", "Order")
|
b.HasIndex("LevelId", "Order")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
|
@ -107,59 +102,6 @@ namespace GermanApp.Migrations
|
||||||
b.ToTable("Levels");
|
b.ToTable("Levels");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("PassingScore")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(80);
|
|
||||||
|
|
||||||
b.Property<bool>("ShuffleQuestions")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int>("TimeLimitMinutes")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.ToTable("Quizzes");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -219,12 +161,10 @@ namespace GermanApp.Migrations
|
||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasDefaultValue(true);
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
b.Property<int>("Order")
|
b.Property<int>("LessonId")
|
||||||
.ValueGeneratedOnAdd()
|
.HasColumnType("integer");
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(1);
|
|
||||||
|
|
||||||
b.Property<int>("Points")
|
b.Property<int>("Order")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasDefaultValue(1);
|
.HasDefaultValue(1);
|
||||||
|
|
@ -234,9 +174,6 @@ namespace GermanApp.Migrations
|
||||||
.HasMaxLength(2000)
|
.HasMaxLength(2000)
|
||||||
.HasColumnType("character varying(2000)");
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
b.Property<int>("QuizId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Type")
|
b.Property<int>("Type")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
|
@ -245,7 +182,7 @@ namespace GermanApp.Migrations
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("QuizId", "Order")
|
b.HasIndex("LessonId", "Order")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("QuizQuestions");
|
b.ToTable("QuizQuestions");
|
||||||
|
|
@ -288,128 +225,6 @@ namespace GermanApp.Migrations
|
||||||
b.ToTable("RefreshTokens");
|
b.ToTable("RefreshTokens");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StoryProgress", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime?>("CompletedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsCompleted")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(false);
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("StorySegmentId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UnlockedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("UserId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId");
|
|
||||||
|
|
||||||
b.HasIndex("StorySegmentId");
|
|
||||||
|
|
||||||
b.HasIndex("UserId1");
|
|
||||||
|
|
||||||
b.HasIndex("UserId", "StorySegmentId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("StoryProgress");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StorySegment", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("AudioUrl")
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<string>("Content")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("EstimatedReadingMinutes")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(2);
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<int?>("LessonId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LessonId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("LevelId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("LevelId1")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Order")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Theme")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId");
|
|
||||||
|
|
||||||
b.HasIndex("LessonId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId1");
|
|
||||||
|
|
||||||
b.HasIndex("LevelId", "Order")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -438,13 +253,6 @@ namespace GermanApp.Migrations
|
||||||
.HasMaxLength(255)
|
.HasMaxLength(255)
|
||||||
.HasColumnType("character varying(255)");
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
b.Property<string>("Role")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasMaxLength(20)
|
|
||||||
.HasColumnType("character varying(20)")
|
|
||||||
.HasDefaultValue("User");
|
|
||||||
|
|
||||||
b.Property<int>("Streak")
|
b.Property<int>("Streak")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
|
|
@ -516,24 +324,9 @@ namespace GermanApp.Migrations
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", null)
|
|
||||||
.WithMany("Lessons")
|
|
||||||
.HasForeignKey("LevelId1");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
b.Navigation("Level");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.QuizOption", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("GermanApp.Domain.Entities.QuizQuestion", "QuizQuestion")
|
b.HasOne("GermanApp.Domain.Entities.QuizQuestion", "QuizQuestion")
|
||||||
|
|
@ -547,13 +340,13 @@ namespace GermanApp.Migrations
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("GermanApp.Domain.Entities.Quiz", "Quiz")
|
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
||||||
.WithMany("Questions")
|
.WithMany()
|
||||||
.HasForeignKey("QuizId")
|
.HasForeignKey("LessonId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Quiz");
|
b.Navigation("Lesson");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
||||||
|
|
@ -565,63 +358,6 @@ namespace GermanApp.Migrations
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StoryProgress", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.StorySegment", "StorySegment")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("StorySegmentId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", "User")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
|
||||||
.WithMany("StoryProgress")
|
|
||||||
.HasForeignKey("UserId1");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
|
|
||||||
b.Navigation("StorySegment");
|
|
||||||
|
|
||||||
b.Navigation("User");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.StorySegment", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LessonId")
|
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", null)
|
|
||||||
.WithMany("StorySegments")
|
|
||||||
.HasForeignKey("LessonId1");
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", "Level")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("LevelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.Level", null)
|
|
||||||
.WithMany("StorySegments")
|
|
||||||
.HasForeignKey("LevelId1");
|
|
||||||
|
|
||||||
b.Navigation("Lesson");
|
|
||||||
|
|
||||||
b.Navigation("Level");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.UserProgress", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
b.HasOne("GermanApp.Domain.Entities.Lesson", "Lesson")
|
||||||
|
|
@ -641,32 +377,10 @@ namespace GermanApp.Migrations
|
||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Level", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Lessons");
|
|
||||||
|
|
||||||
b.Navigation("StorySegments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Quiz", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Questions");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.QuizQuestion", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Options");
|
b.Navigation("Options");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("StoryProgress");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -42,7 +42,6 @@ public static class SeedDataExtension
|
||||||
"admin@deutschlernen.com",
|
"admin@deutschlernen.com",
|
||||||
"$2a$11$N9qo8uLOickgx2ZMRZoMy.Mrq9Hq4yVJyX2sX7z3J0J9z6J9Z2K4a" // bcrypt hash of "Admin@123!"
|
"$2a$11$N9qo8uLOickgx2ZMRZoMy.Mrq9Hq4yVJyX2sX7z3J0J9z6J9Z2K4a" // bcrypt hash of "Admin@123!"
|
||||||
);
|
);
|
||||||
adminUser.AssignAdminRole(); // Set role to Admin
|
|
||||||
adminUser.UpdateLevel("C1");
|
adminUser.UpdateLevel("C1");
|
||||||
dbContext.Users.Add(adminUser);
|
dbContext.Users.Add(adminUser);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,12 +49,11 @@ public class AuthService : IAuthService
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<AuthResponse> RegisterAsync(RegisterDto registerDto)
|
public async Task<AuthResponse> RegisterAsync(RegisterDto registerDto)
|
||||||
{
|
{
|
||||||
// Check if username or email already exists (case-insensitive for email)
|
// Check if username or email already exists
|
||||||
if (await _dbContext.Users.AnyAsync(u => u.Username == registerDto.Username))
|
if (await _dbContext.Users.AnyAsync(u => u.Username == registerDto.Username))
|
||||||
throw new InvalidOperationException("Username already taken");
|
throw new InvalidOperationException("Username already taken");
|
||||||
|
|
||||||
var normalizedEmail = registerDto.Email?.ToLowerInvariant() ?? string.Empty;
|
if (await _dbContext.Users.AnyAsync(u => u.Email == registerDto.Email))
|
||||||
if (await _dbContext.Users.AnyAsync(u => u.Email == normalizedEmail))
|
|
||||||
throw new InvalidOperationException("Email already in use");
|
throw new InvalidOperationException("Email already in use");
|
||||||
|
|
||||||
// Hash password and create user
|
// Hash password and create user
|
||||||
|
|
@ -90,10 +89,7 @@ public class AuthService : IAuthService
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<AuthResponse> LoginAsync(LoginDto loginDto)
|
public async Task<AuthResponse> LoginAsync(LoginDto loginDto)
|
||||||
{
|
{
|
||||||
// Normalize email to lowercase for case-insensitive matching
|
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Email == loginDto.Email);
|
||||||
// Emails are stored in lowercase in the database
|
|
||||||
var normalizedEmail = loginDto.Email?.ToLowerInvariant() ?? string.Empty;
|
|
||||||
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Email == normalizedEmail);
|
|
||||||
|
|
||||||
if (user == null)
|
if (user == null)
|
||||||
throw new UnauthorizedAccessException("Invalid email or password");
|
throw new UnauthorizedAccessException("Invalid email or password");
|
||||||
|
|
@ -151,15 +147,12 @@ public class AuthService : IAuthService
|
||||||
|
|
||||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
// Use JWT standard claims for better compatibility
|
|
||||||
// "sub" = subject (user identifier), "name" = username, "email" = email, "role" = user role
|
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), // JWT standard: sub for subject/user ID
|
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
new Claim(JwtRegisteredClaimNames.Name, user.Username),
|
new Claim(ClaimTypes.Name, user.Username),
|
||||||
new Claim(JwtRegisteredClaimNames.Email, user.Email),
|
new Claim(ClaimTypes.Email, user.Email),
|
||||||
new Claim(JwtRegisteredClaimNames.UniqueName, user.Username), // Additional: unique name
|
new Claim(ClaimTypes.Role, "User")
|
||||||
new Claim(ClaimTypes.Role, user.Role) // Use user's actual role (User or Admin)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var token = new JwtSecurityToken(
|
var token = new JwtSecurityToken(
|
||||||
|
|
@ -234,65 +227,4 @@ public class AuthService : IAuthService
|
||||||
storedToken.Revoke();
|
storedToken.Revoke();
|
||||||
await _dbContext.SaveChangesAsync();
|
await _dbContext.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates the first admin user (bootstrap).
|
|
||||||
/// This is a special method that creates an admin user without requiring authentication.
|
|
||||||
/// Only works if no admin user exists yet.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="registerDto">Admin user registration data</param>
|
|
||||||
/// <returns>Authentication response with token</returns>
|
|
||||||
public async Task<AuthResponse> CreateAdminUserAsync(RegisterDto registerDto)
|
|
||||||
{
|
|
||||||
// Check if any admin user already exists
|
|
||||||
if (await _dbContext.Users.AnyAsync(u => u.Role == "Admin"))
|
|
||||||
throw new InvalidOperationException("Admin user already exists. Bootstrap endpoint can only be used once.");
|
|
||||||
|
|
||||||
// Check if username or email already exists (case-insensitive for email)
|
|
||||||
if (await _dbContext.Users.AnyAsync(u => u.Username == registerDto.Username))
|
|
||||||
throw new InvalidOperationException("Username already taken");
|
|
||||||
|
|
||||||
var normalizedEmail = registerDto.Email?.ToLowerInvariant() ?? string.Empty;
|
|
||||||
if (await _dbContext.Users.AnyAsync(u => u.Email == normalizedEmail))
|
|
||||||
throw new InvalidOperationException("Email already in use");
|
|
||||||
|
|
||||||
// Hash password and create user
|
|
||||||
var user = User.Create(registerDto.Username, registerDto.Email.ToLowerInvariant(), string.Empty);
|
|
||||||
var passwordHash = _passwordHasher.HashPassword(user, registerDto.Password);
|
|
||||||
user.ChangePassword(passwordHash);
|
|
||||||
|
|
||||||
// Assign admin role
|
|
||||||
user.AssignAdminRole();
|
|
||||||
|
|
||||||
_dbContext.Users.Add(user);
|
|
||||||
await _dbContext.SaveChangesAsync();
|
|
||||||
|
|
||||||
// Generate JWT token
|
|
||||||
var token = GenerateJwtToken(user);
|
|
||||||
|
|
||||||
// Generate and store refresh token
|
|
||||||
var refreshTokenString = GenerateRefreshTokenString();
|
|
||||||
var refreshToken = RefreshToken.Create(user.Id, refreshTokenString);
|
|
||||||
_dbContext.RefreshTokens.Add(refreshToken);
|
|
||||||
await _dbContext.SaveChangesAsync();
|
|
||||||
|
|
||||||
return new AuthResponse
|
|
||||||
{
|
|
||||||
UserId = user.Id,
|
|
||||||
Username = user.Username,
|
|
||||||
Email = user.Email,
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,6 @@ public class MistralConnector : IMistralConnector
|
||||||
_config = config;
|
_config = config;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
|
||||||
// Skip validation and initialization during EF migrations or when configs are not set
|
|
||||||
// (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);
|
|
||||||
|
|
||||||
if (!isEfDesignTime && hasApiKey)
|
|
||||||
{
|
|
||||||
_config.Validate();
|
_config.Validate();
|
||||||
|
|
||||||
_httpClient.BaseAddress = new Uri(_config.BaseUrl);
|
_httpClient.BaseAddress = new Uri(_config.BaseUrl);
|
||||||
|
|
@ -56,7 +48,6 @@ public class MistralConnector : IMistralConnector
|
||||||
_config.CircuitBreakerFailureThreshold,
|
_config.CircuitBreakerFailureThreshold,
|
||||||
TimeSpan.FromMinutes(_config.CircuitBreakerResetMinutes));
|
TimeSpan.FromMinutes(_config.CircuitBreakerResetMinutes));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<MistralResponse> CompleteAsync(MistralRequest request,
|
public async Task<MistralResponse> CompleteAsync(MistralRequest request,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
|
|
||||||
|
|
@ -25,17 +25,12 @@ public class TtsService : ITtsService
|
||||||
_config = config.Value;
|
_config = config.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
// Skip validation during EF migrations or when configs are not set
|
// Validate configuration
|
||||||
bool isEfDesignTime = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_EF") == "true";
|
|
||||||
bool hasPythonPath = !string.IsNullOrWhiteSpace(_config?.PythonPath);
|
|
||||||
|
|
||||||
if (!isEfDesignTime && hasPythonPath)
|
|
||||||
{
|
|
||||||
ValidateConfiguration();
|
ValidateConfiguration();
|
||||||
|
|
||||||
// Ensure audio storage directory exists
|
// Ensure audio storage directory exists
|
||||||
Directory.CreateDirectory(_config.AudioStoragePath);
|
Directory.CreateDirectory(_config.AudioStoragePath);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates TTS configuration on startup.
|
/// Validates TTS configuration on startup.
|
||||||
|
|
|
||||||
|
|
@ -25,15 +25,9 @@ public class VoskService : IVoskService
|
||||||
_config = config.Value;
|
_config = config.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
// Skip validation during EF migrations or when configs are not set
|
// Validate model path on startup
|
||||||
bool isEfDesignTime = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_EF") == "true";
|
|
||||||
bool hasModelPath = !string.IsNullOrWhiteSpace(_config?.ModelPath);
|
|
||||||
|
|
||||||
if (!isEfDesignTime && hasModelPath)
|
|
||||||
{
|
|
||||||
ValidateModelPath();
|
ValidateModelPath();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates that the Vosk model directory exists and is accessible.
|
/// Validates that the Vosk model directory exists and is accessible.
|
||||||
|
|
|
||||||
|
|
@ -1,276 +0,0 @@
|
||||||
using GermanApp.Application.DTOs;
|
|
||||||
using GermanApp.Application.Interfaces;
|
|
||||||
using GermanApp.Application.DTOs.Auth;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Net;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// API controller for admin operations.
|
|
||||||
/// All endpoints require Admin role.
|
|
||||||
/// This is part of the Presentation layer.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public class AdminController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IAdminService _adminService;
|
|
||||||
private readonly IUserReportService _reportService;
|
|
||||||
|
|
||||||
public AdminController(IAdminService adminService, IUserReportService reportService)
|
|
||||||
{
|
|
||||||
_adminService = adminService;
|
|
||||||
_reportService = reportService;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// USER MANAGEMENT ENDPOINTS
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all users (Admin only).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of all users</returns>
|
|
||||||
[HttpGet("users")]
|
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<AdminUserListItemDto>), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetAllUsersAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var users = await _adminService.GetAllUsersAsync(cancellationToken);
|
|
||||||
return Ok(users);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a specific user by ID (Admin only).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The user DTO</returns>
|
|
||||||
[HttpGet("users/{userId}")]
|
|
||||||
[ProducesResponseType(typeof(AdminUserDto), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetUserByIdAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var user = await _adminService.GetUserByIdAsync(userId, cancellationToken);
|
|
||||||
|
|
||||||
if (user == null)
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
return Ok(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates a user's role (Admin only).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="dto">The role update data</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>The updated user DTO</returns>
|
|
||||||
[HttpPut("users/{userId}/role")]
|
|
||||||
[ProducesResponseType(typeof(AdminUserDto), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> UpdateUserRoleAsync(
|
|
||||||
int userId,
|
|
||||||
[FromBody] UpdateUserRoleDto dto,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var user = await _adminService.UpdateUserRoleAsync(userId, dto.Role, cancellationToken);
|
|
||||||
|
|
||||||
if (user == null)
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
return Ok(user);
|
|
||||||
}
|
|
||||||
catch (ArgumentException ex)
|
|
||||||
{
|
|
||||||
return BadRequest(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a user (Admin only, cannot delete self).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID to delete</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Success or error</returns>
|
|
||||||
[HttpDelete("users/{userId}")]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NoContent)]
|
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> DeleteUserAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Get admin user ID from JWT claim
|
|
||||||
// Note: JWT "sub" claim is mapped by ASP.NET Core JWT middleware to ClaimTypes.NameIdentifier
|
|
||||||
var adminUserIdClaim = User.FindFirst(ClaimTypes.NameIdentifier) ?? User.FindFirst("sub");
|
|
||||||
if (adminUserIdClaim == null || !int.TryParse(adminUserIdClaim.Value, out var adminUserId) || adminUserId == 0)
|
|
||||||
return Unauthorized();
|
|
||||||
|
|
||||||
var result = await _adminService.DeleteUserAsync(userId, adminUserId, cancellationToken);
|
|
||||||
|
|
||||||
if (!result)
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
return BadRequest(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// DASHBOARD ENDPOINTS
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets admin dashboard statistics.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Dashboard statistics</returns>
|
|
||||||
[HttpGet("dashboard/stats")]
|
|
||||||
[ProducesResponseType(typeof(AdminDashboardStatsDto), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetDashboardStatsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var stats = await _adminService.GetDashboardStatsAsync(cancellationToken);
|
|
||||||
return Ok(stats);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// REPORT ENDPOINTS
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a progress report for a specific user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>User progress report</returns>
|
|
||||||
[HttpGet("reports/users/{userId}")]
|
|
||||||
[ProducesResponseType(typeof(UserProgressReportDto), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetUserReportAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var report = await _reportService.GenerateUserReportAsync(userId, cancellationToken);
|
|
||||||
return Ok(report);
|
|
||||||
}
|
|
||||||
catch (ArgumentException ex)
|
|
||||||
{
|
|
||||||
return BadRequest(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates progress reports for all users.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of all user progress reports</returns>
|
|
||||||
[HttpGet("reports/all-users")]
|
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<UserProgressReportDto>), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetAllUserReportsAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var reports = await _reportService.GenerateAllUserReportsAsync(cancellationToken);
|
|
||||||
return Ok(reports);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets lesson completion data for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of lesson completion data</returns>
|
|
||||||
[HttpGet("reports/users/{userId}/lessons")]
|
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<LessonCompletionDto>), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetUserLessonProgressAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var progress = await _reportService.GetUserLessonProgressAsync(userId, cancellationToken);
|
|
||||||
return Ok(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets quiz results for a user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>List of quiz results</returns>
|
|
||||||
[HttpGet("reports/users/{userId}/quizzes")]
|
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<UserQuizResultDto>), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetUserQuizResultsAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var results = await _reportService.GetUserQuizResultsAsync(userId, cancellationToken);
|
|
||||||
return Ok(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exports all user reports as CSV.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>CSV content</returns>
|
|
||||||
[HttpGet("reports/export/csv")]
|
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> ExportReportsAsCsvAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var csv = await _reportService.ExportReportsAsCsvAsync(cancellationToken);
|
|
||||||
return Ok(new { CsvContent = csv });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a specific user's full report (combined data).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userId">The user ID</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>Combined user report data</returns>
|
|
||||||
[HttpGet("reports/users/{userId}/full")]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.Unauthorized)]
|
|
||||||
public async Task<IActionResult> GetUserFullReportAsync(int userId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var progressReportTask = _reportService.GenerateUserReportAsync(userId, cancellationToken);
|
|
||||||
var lessonProgressTask = _reportService.GetUserLessonProgressAsync(userId, cancellationToken);
|
|
||||||
var quizResultsTask = _reportService.GetUserQuizResultsAsync(userId, cancellationToken);
|
|
||||||
|
|
||||||
await Task.WhenAll(progressReportTask, lessonProgressTask, quizResultsTask);
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
ProgressReport = await progressReportTask,
|
|
||||||
LessonProgress = await lessonProgressTask,
|
|
||||||
QuizResults = await quizResultsTask
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (ArgumentException ex)
|
|
||||||
{
|
|
||||||
return BadRequest(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,9 +2,7 @@ using GermanApp.Application.DTOs.Auth;
|
||||||
using GermanApp.Application.Interfaces;
|
using GermanApp.Application.Interfaces;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Security.Claims;
|
|
||||||
|
|
||||||
namespace GermanApp.Presentation.Controllers;
|
namespace GermanApp.Presentation.Controllers;
|
||||||
|
|
||||||
|
|
@ -29,7 +27,6 @@ public class AuthController : ControllerBase
|
||||||
/// <param name="registerDto">Registration data</param>
|
/// <param name="registerDto">Registration data</param>
|
||||||
/// <returns>Authentication response with JWT token</returns>
|
/// <returns>Authentication response with JWT token</returns>
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
[AllowAnonymous]
|
|
||||||
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
|
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
||||||
public async Task<IActionResult> Register([FromBody] RegisterDto registerDto)
|
public async Task<IActionResult> Register([FromBody] RegisterDto registerDto)
|
||||||
|
|
@ -55,7 +52,6 @@ public class AuthController : ControllerBase
|
||||||
/// <param name="loginDto">Login data</param>
|
/// <param name="loginDto">Login data</param>
|
||||||
/// <returns>Authentication response with JWT token</returns>
|
/// <returns>Authentication response with JWT token</returns>
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
[AllowAnonymous]
|
|
||||||
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
|
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.Unauthorized)]
|
[ProducesResponseType(typeof(string), (int)HttpStatusCode.Unauthorized)]
|
||||||
public async Task<IActionResult> Login([FromBody] LoginDto loginDto)
|
public async Task<IActionResult> Login([FromBody] LoginDto loginDto)
|
||||||
|
|
@ -87,11 +83,8 @@ public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get user ID from JWT claim
|
var userId = int.Parse(User.FindFirst("nameid")?.Value ?? "0");
|
||||||
// Note: JWT "sub" claim is mapped by ASP.NET Core JWT middleware to ClaimTypes.NameIdentifier
|
if (userId == 0)
|
||||||
// (http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier)
|
|
||||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier) ?? User.FindFirst("sub");
|
|
||||||
if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId) || userId == 0)
|
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
var user = await _authService.GetCurrentUserAsync(userId);
|
var user = await _authService.GetCurrentUserAsync(userId);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -28,6 +28,7 @@ public class LessonsController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of all lessons</returns>
|
/// <returns>List of all lessons</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetAllLessonsAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetAllLessonsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lessons = await _lessonService.GetAllLessonsAsync(cancellationToken);
|
var lessons = await _lessonService.GetAllLessonsAsync(cancellationToken);
|
||||||
|
|
@ -39,6 +40,7 @@ public class LessonsController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of all lessons in order</returns>
|
/// <returns>List of all lessons in order</returns>
|
||||||
[HttpGet("ordered")]
|
[HttpGet("ordered")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetAllOrderedLessonsAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetAllOrderedLessonsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lessons = await _lessonService.GetAllOrderedLessonsAsync(cancellationToken);
|
var lessons = await _lessonService.GetAllOrderedLessonsAsync(cancellationToken);
|
||||||
|
|
@ -51,6 +53,7 @@ public class LessonsController : ControllerBase
|
||||||
/// <param name="id">The lesson ID</param>
|
/// <param name="id">The lesson ID</param>
|
||||||
/// <returns>The lesson with the specified ID</returns>
|
/// <returns>The lesson with the specified ID</returns>
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetLessonByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetLessonByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lesson = await _lessonService.GetLessonByIdAsync(id, cancellationToken);
|
var lesson = await _lessonService.GetLessonByIdAsync(id, cancellationToken);
|
||||||
|
|
@ -65,6 +68,7 @@ public class LessonsController : ControllerBase
|
||||||
/// <param name="levelId">The level ID</param>
|
/// <param name="levelId">The level ID</param>
|
||||||
/// <returns>List of lessons for the specified level</returns>
|
/// <returns>List of lessons for the specified level</returns>
|
||||||
[HttpGet("by-level/{levelId}")]
|
[HttpGet("by-level/{levelId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetLessonsByLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetLessonsByLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lessons = await _lessonService.GetLessonsByLevelAsync(levelId, cancellationToken);
|
var lessons = await _lessonService.GetLessonsByLevelAsync(levelId, cancellationToken);
|
||||||
|
|
@ -76,6 +80,7 @@ public class LessonsController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of beginner lessons</returns>
|
/// <returns>List of beginner lessons</returns>
|
||||||
[HttpGet("beginner")]
|
[HttpGet("beginner")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lessons = await _lessonService.GetBeginnerLessonsAsync(cancellationToken);
|
var lessons = await _lessonService.GetBeginnerLessonsAsync(cancellationToken);
|
||||||
|
|
@ -87,6 +92,7 @@ public class LessonsController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of advanced lessons</returns>
|
/// <returns>List of advanced lessons</returns>
|
||||||
[HttpGet("advanced")]
|
[HttpGet("advanced")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lessons = await _lessonService.GetAdvancedLessonsAsync(cancellationToken);
|
var lessons = await _lessonService.GetAdvancedLessonsAsync(cancellationToken);
|
||||||
|
|
@ -143,6 +149,7 @@ public class LessonsController : ControllerBase
|
||||||
/// <param name="levelId">The level ID</param>
|
/// <param name="levelId">The level ID</param>
|
||||||
/// <returns>The first lesson in the level</returns>
|
/// <returns>The first lesson in the level</returns>
|
||||||
[HttpGet("first/{levelId}")]
|
[HttpGet("first/{levelId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetFirstLessonInLevelAsync(int levelId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lesson = await _lessonService.GetFirstLessonInLevelAsync(levelId, cancellationToken);
|
var lesson = await _lessonService.GetFirstLessonInLevelAsync(levelId, cancellationToken);
|
||||||
|
|
@ -157,6 +164,7 @@ public class LessonsController : ControllerBase
|
||||||
/// <param name="currentLessonId">The current lesson ID</param>
|
/// <param name="currentLessonId">The current lesson ID</param>
|
||||||
/// <returns>The next lesson</returns>
|
/// <returns>The next lesson</returns>
|
||||||
[HttpGet("next/{currentLessonId}")]
|
[HttpGet("next/{currentLessonId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetNextLessonAsync(int currentLessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var lesson = await _lessonService.GetNextLessonAsync(currentLessonId, cancellationToken);
|
var lesson = await _lessonService.GetNextLessonAsync(currentLessonId, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ public class LevelsController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of all levels</returns>
|
/// <returns>List of all levels</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetAllLevelsAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetAllLevelsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var levels = await _levelService.GetAllLevelsAsync(cancellationToken);
|
var levels = await _levelService.GetAllLevelsAsync(cancellationToken);
|
||||||
|
|
@ -38,6 +39,7 @@ public class LevelsController : ControllerBase
|
||||||
/// <param name="id">The level ID</param>
|
/// <param name="id">The level ID</param>
|
||||||
/// <returns>The level with the specified ID</returns>
|
/// <returns>The level with the specified ID</returns>
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetLevelByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetLevelByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level = await _levelService.GetLevelByIdAsync(id, cancellationToken);
|
var level = await _levelService.GetLevelByIdAsync(id, cancellationToken);
|
||||||
|
|
@ -52,6 +54,7 @@ public class LevelsController : ControllerBase
|
||||||
/// <param name="code">The level code</param>
|
/// <param name="code">The level code</param>
|
||||||
/// <returns>The level with the specified code</returns>
|
/// <returns>The level with the specified code</returns>
|
||||||
[HttpGet("by-code/{code}")]
|
[HttpGet("by-code/{code}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetLevelByCodeAsync(string code, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetLevelByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level = await _levelService.GetLevelByCodeAsync(code, cancellationToken);
|
var level = await _levelService.GetLevelByCodeAsync(code, cancellationToken);
|
||||||
|
|
@ -109,6 +112,7 @@ public class LevelsController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>The first level</returns>
|
/// <returns>The first level</returns>
|
||||||
[HttpGet("first")]
|
[HttpGet("first")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetFirstLevelAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetFirstLevelAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level = await _levelService.GetFirstLevelAsync(cancellationToken);
|
var level = await _levelService.GetFirstLevelAsync(cancellationToken);
|
||||||
|
|
@ -123,6 +127,7 @@ public class LevelsController : ControllerBase
|
||||||
/// <param name="currentLevelId">The current level ID</param>
|
/// <param name="currentLevelId">The current level ID</param>
|
||||||
/// <returns>The next level</returns>
|
/// <returns>The next level</returns>
|
||||||
[HttpGet("next/{currentLevelId}")]
|
[HttpGet("next/{currentLevelId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetNextLevelAsync(int currentLevelId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var level = await _levelService.GetNextLevelAsync(currentLevelId, cancellationToken);
|
var level = await _levelService.GetNextLevelAsync(currentLevelId, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of all quiz questions</returns>
|
/// <returns>List of all quiz questions</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetAllQuizQuestionsAsync(cancellationToken);
|
var questions = await _quizQuestionService.GetAllQuizQuestionsAsync(cancellationToken);
|
||||||
|
|
@ -39,6 +40,7 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="quizId">The quiz ID</param>
|
/// <param name="quizId">The quiz ID</param>
|
||||||
/// <returns>List of quiz questions for the specified quiz</returns>
|
/// <returns>List of quiz questions for the specified quiz</returns>
|
||||||
[HttpGet("by-quiz/{quizId}")]
|
[HttpGet("by-quiz/{quizId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
||||||
|
|
@ -51,6 +53,7 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>List of quiz questions for the specified lesson</returns>
|
/// <returns>List of quiz questions for the specified lesson</returns>
|
||||||
[HttpGet("by-lesson/{lessonId}")]
|
[HttpGet("by-lesson/{lessonId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetActiveQuizQuestionsByLessonAsync(lessonId, cancellationToken);
|
var questions = await _quizQuestionService.GetActiveQuizQuestionsByLessonAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -63,6 +66,7 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="id">The quiz question ID</param>
|
/// <param name="id">The quiz question ID</param>
|
||||||
/// <returns>The quiz question with the specified ID</returns>
|
/// <returns>The quiz question with the specified ID</returns>
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var question = await _quizQuestionService.GetQuizQuestionByIdAsync(id, cancellationToken);
|
var question = await _quizQuestionService.GetQuizQuestionByIdAsync(id, cancellationToken);
|
||||||
|
|
@ -78,6 +82,7 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="count">Number of questions to return</param>
|
/// <param name="count">Number of questions to return</param>
|
||||||
/// <returns>List of random quiz questions for the quiz</returns>
|
/// <returns>List of random quiz questions for the quiz</returns>
|
||||||
[HttpGet("random/{quizId}/{count}")]
|
[HttpGet("random/{quizId}/{count}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken);
|
var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken);
|
||||||
|
|
@ -91,6 +96,7 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="count">Number of questions to return</param>
|
/// <param name="count">Number of questions to return</param>
|
||||||
/// <returns>List of random quiz questions for the lesson</returns>
|
/// <returns>List of random quiz questions for the lesson</returns>
|
||||||
[HttpGet("random/by-lesson/{lessonId}/{count}")]
|
[HttpGet("random/by-lesson/{lessonId}/{count}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetRandomQuizQuestionsForLessonAsync(lessonId, count, cancellationToken);
|
var questions = await _quizQuestionService.GetRandomQuizQuestionsForLessonAsync(lessonId, count, cancellationToken);
|
||||||
|
|
@ -103,6 +109,7 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="difficulty">The difficulty level (1-5)</param>
|
/// <param name="difficulty">The difficulty level (1-5)</param>
|
||||||
/// <returns>List of quiz questions with the specified difficulty</returns>
|
/// <returns>List of quiz questions with the specified difficulty</returns>
|
||||||
[HttpGet("by-difficulty/{difficulty}")]
|
[HttpGet("by-difficulty/{difficulty}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetQuizQuestionsByDifficultyAsync(difficulty, cancellationToken);
|
var questions = await _quizQuestionService.GetQuizQuestionsByDifficultyAsync(difficulty, cancellationToken);
|
||||||
|
|
@ -115,6 +122,7 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="type">The question type</param>
|
/// <param name="type">The question type</param>
|
||||||
/// <returns>List of quiz questions with the specified type</returns>
|
/// <returns>List of quiz questions with the specified type</returns>
|
||||||
[HttpGet("by-type/{type}")]
|
[HttpGet("by-type/{type}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetQuizQuestionsByTypeAsync(type, cancellationToken);
|
var questions = await _quizQuestionService.GetQuizQuestionsByTypeAsync(type, cancellationToken);
|
||||||
|
|
@ -171,6 +179,7 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="questionId">The question ID</param>
|
/// <param name="questionId">The question ID</param>
|
||||||
/// <returns>List of quiz options for the question</returns>
|
/// <returns>List of quiz options for the question</returns>
|
||||||
[HttpGet("options/{questionId}")]
|
[HttpGet("options/{questionId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var options = await _quizQuestionService.GetQuizOptionsByQuestionAsync(questionId, cancellationToken);
|
var options = await _quizQuestionService.GetQuizOptionsByQuestionAsync(questionId, cancellationToken);
|
||||||
|
|
@ -183,6 +192,7 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="questionId">The question ID</param>
|
/// <param name="questionId">The question ID</param>
|
||||||
/// <returns>The correct option for the question</returns>
|
/// <returns>The correct option for the question</returns>
|
||||||
[HttpGet("correct-option/{questionId}")]
|
[HttpGet("correct-option/{questionId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var option = await _quizQuestionService.GetCorrectOptionAsync(questionId, cancellationToken);
|
var option = await _quizQuestionService.GetCorrectOptionAsync(questionId, cancellationToken);
|
||||||
|
|
@ -197,6 +207,7 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="questionId">The question ID</param>
|
/// <param name="questionId">The question ID</param>
|
||||||
/// <returns>List of correct options for the question</returns>
|
/// <returns>List of correct options for the question</returns>
|
||||||
[HttpGet("correct-options/{questionId}")]
|
[HttpGet("correct-options/{questionId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var options = await _quizQuestionService.GetCorrectOptionsAsync(questionId, cancellationToken);
|
var options = await _quizQuestionService.GetCorrectOptionsAsync(questionId, cancellationToken);
|
||||||
|
|
@ -270,6 +281,7 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>The count of quiz questions for the lesson</returns>
|
/// <returns>The count of quiz questions for the lesson</returns>
|
||||||
[HttpGet("count/{lessonId}")]
|
[HttpGet("count/{lessonId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var count = await _quizQuestionService.GetQuizQuestionCountForLessonAsync(lessonId, cancellationToken);
|
var count = await _quizQuestionService.GetQuizQuestionCountForLessonAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -282,6 +294,7 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>The difficulty distribution for the lesson</returns>
|
/// <returns>The difficulty distribution for the lesson</returns>
|
||||||
[HttpGet("difficulty-distribution/{lessonId}")]
|
[HttpGet("difficulty-distribution/{lessonId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var distribution = await _quizQuestionService.GetDifficultyDistributionAsync(lessonId, cancellationToken);
|
var distribution = await _quizQuestionService.GetDifficultyDistributionAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -294,6 +307,7 @@ public class QuizQuestionsController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>The type distribution for the lesson</returns>
|
/// <returns>The type distribution for the lesson</returns>
|
||||||
[HttpGet("type-distribution/{lessonId}")]
|
[HttpGet("type-distribution/{lessonId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var distribution = await _quizQuestionService.GetTypeDistributionAsync(lessonId, cancellationToken);
|
var distribution = await _quizQuestionService.GetTypeDistributionAsync(lessonId, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of all quizzes</returns>
|
/// <returns>List of all quizzes</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetAllQuizzesAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetAllQuizzesAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quizzes = await _quizService.GetAllQuizzesAsync(cancellationToken);
|
var quizzes = await _quizService.GetAllQuizzesAsync(cancellationToken);
|
||||||
|
|
@ -42,6 +43,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of quiz summary items</returns>
|
/// <returns>List of quiz summary items</returns>
|
||||||
[HttpGet("list")]
|
[HttpGet("list")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetAllQuizListItemsAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetAllQuizListItemsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quizzes = await _quizService.GetAllQuizListItemsAsync(cancellationToken);
|
var quizzes = await _quizService.GetAllQuizListItemsAsync(cancellationToken);
|
||||||
|
|
@ -54,6 +56,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="id">The quiz ID</param>
|
/// <param name="id">The quiz ID</param>
|
||||||
/// <returns>The quiz with the specified ID</returns>
|
/// <returns>The quiz with the specified ID</returns>
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetQuizByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quiz = await _quizService.GetQuizByIdAsync(id, cancellationToken);
|
var quiz = await _quizService.GetQuizByIdAsync(id, cancellationToken);
|
||||||
|
|
@ -68,6 +71,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="id">The quiz ID</param>
|
/// <param name="id">The quiz ID</param>
|
||||||
/// <returns>The quiz with questions</returns>
|
/// <returns>The quiz with questions</returns>
|
||||||
[HttpGet("{id}/with-questions")]
|
[HttpGet("{id}/with-questions")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetQuizWithQuestionsAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizWithQuestionsAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quiz = await _quizService.GetQuizWithQuestionsAsync(id, cancellationToken);
|
var quiz = await _quizService.GetQuizWithQuestionsAsync(id, cancellationToken);
|
||||||
|
|
@ -82,6 +86,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>List of quizzes for the specified lesson</returns>
|
/// <returns>List of quizzes for the specified lesson</returns>
|
||||||
[HttpGet("by-lesson/{lessonId}")]
|
[HttpGet("by-lesson/{lessonId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quizzes = await _quizService.GetQuizzesByLessonAsync(lessonId, cancellationToken);
|
var quizzes = await _quizService.GetQuizzesByLessonAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -94,6 +99,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>List of active quizzes for the specified lesson</returns>
|
/// <returns>List of active quizzes for the specified lesson</returns>
|
||||||
[HttpGet("active/by-lesson/{lessonId}")]
|
[HttpGet("active/by-lesson/{lessonId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetActiveQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetActiveQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quizzes = await _quizService.GetActiveQuizzesByLessonAsync(lessonId, cancellationToken);
|
var quizzes = await _quizService.GetActiveQuizzesByLessonAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -106,6 +112,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>The first quiz for the lesson</returns>
|
/// <returns>The first quiz for the lesson</returns>
|
||||||
[HttpGet("first/by-lesson/{lessonId}")]
|
[HttpGet("first/by-lesson/{lessonId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetFirstQuizByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetFirstQuizByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quiz = await _quizService.GetFirstQuizByLessonAsync(lessonId, cancellationToken);
|
var quiz = await _quizService.GetFirstQuizByLessonAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -119,6 +126,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of active quizzes</returns>
|
/// <returns>List of active quizzes</returns>
|
||||||
[HttpGet("active")]
|
[HttpGet("active")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetActiveQuizzesAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetActiveQuizzesAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quizzes = await _quizService.GetActiveQuizzesAsync(cancellationToken);
|
var quizzes = await _quizService.GetActiveQuizzesAsync(cancellationToken);
|
||||||
|
|
@ -205,6 +213,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="id">The quiz ID</param>
|
/// <param name="id">The quiz ID</param>
|
||||||
/// <returns>True if the quiz exists</returns>
|
/// <returns>True if the quiz exists</returns>
|
||||||
[HttpGet("exists/{id}")]
|
[HttpGet("exists/{id}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> ExistsAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var exists = await _quizService.ExistsAsync(id, cancellationToken);
|
var exists = await _quizService.ExistsAsync(id, cancellationToken);
|
||||||
|
|
@ -217,6 +226,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>True if a quiz exists for the lesson</returns>
|
/// <returns>True if a quiz exists for the lesson</returns>
|
||||||
[HttpGet("exists/by-lesson/{lessonId}")]
|
[HttpGet("exists/by-lesson/{lessonId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var exists = await _quizService.ExistsByLessonAsync(lessonId, cancellationToken);
|
var exists = await _quizService.ExistsByLessonAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -228,6 +238,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>The total count of quizzes</returns>
|
/// <returns>The total count of quizzes</returns>
|
||||||
[HttpGet("count")]
|
[HttpGet("count")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetTotalQuizCountAsync(CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetTotalQuizCountAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var count = await _quizService.GetTotalQuizCountAsync(cancellationToken);
|
var count = await _quizService.GetTotalQuizCountAsync(cancellationToken);
|
||||||
|
|
@ -240,6 +251,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="lessonId">The lesson ID</param>
|
/// <param name="lessonId">The lesson ID</param>
|
||||||
/// <returns>The count of quizzes for the lesson</returns>
|
/// <returns>The count of quizzes for the lesson</returns>
|
||||||
[HttpGet("count/by-lesson/{lessonId}")]
|
[HttpGet("count/by-lesson/{lessonId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetQuizCountByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizCountByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var count = await _quizService.GetQuizCountByLessonAsync(lessonId, cancellationToken);
|
var count = await _quizService.GetQuizCountByLessonAsync(lessonId, cancellationToken);
|
||||||
|
|
@ -253,6 +265,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="maxScore">Maximum passing score</param>
|
/// <param name="maxScore">Maximum passing score</param>
|
||||||
/// <returns>List of quizzes in the score range</returns>
|
/// <returns>List of quizzes in the score range</returns>
|
||||||
[HttpGet("by-passing-score/{minScore}/{maxScore}")]
|
[HttpGet("by-passing-score/{minScore}/{maxScore}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetQuizzesByPassingScoreRangeAsync(int minScore, int maxScore, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizzesByPassingScoreRangeAsync(int minScore, int maxScore, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var quizzes = await _quizService.GetQuizzesByPassingScoreRangeAsync(minScore, maxScore, cancellationToken);
|
var quizzes = await _quizService.GetQuizzesByPassingScoreRangeAsync(minScore, maxScore, cancellationToken);
|
||||||
|
|
@ -265,6 +278,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="quizId">The quiz ID</param>
|
/// <param name="quizId">The quiz ID</param>
|
||||||
/// <returns>List of quiz questions for the quiz</returns>
|
/// <returns>List of quiz questions for the quiz</returns>
|
||||||
[HttpGet("{quizId}/questions")]
|
[HttpGet("{quizId}/questions")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
var questions = await _quizQuestionService.GetQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
||||||
|
|
@ -277,6 +291,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="quizId">The quiz ID</param>
|
/// <param name="quizId">The quiz ID</param>
|
||||||
/// <returns>List of active quiz questions for the quiz</returns>
|
/// <returns>List of active quiz questions for the quiz</returns>
|
||||||
[HttpGet("{quizId}/questions/active")]
|
[HttpGet("{quizId}/questions/active")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetActiveQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetActiveQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken);
|
||||||
|
|
@ -290,6 +305,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="count">Number of questions to return</param>
|
/// <param name="count">Number of questions to return</param>
|
||||||
/// <returns>List of random quiz questions</returns>
|
/// <returns>List of random quiz questions</returns>
|
||||||
[HttpGet("{quizId}/questions/random/{count}")]
|
[HttpGet("{quizId}/questions/random/{count}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken);
|
var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken);
|
||||||
|
|
@ -319,6 +335,7 @@ public class QuizzesController : ControllerBase
|
||||||
/// <param name="quizId">The quiz ID</param>
|
/// <param name="quizId">The quiz ID</param>
|
||||||
/// <returns>The total points for the quiz</returns>
|
/// <returns>The total points for the quiz</returns>
|
||||||
[HttpGet("{quizId}/total-points")]
|
[HttpGet("{quizId}/total-points")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var points = await _quizQuestionService.GetTotalPointsForQuizAsync(quizId, cancellationToken);
|
var points = await _quizQuestionService.GetTotalPointsForQuizAsync(quizId, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GermanApp.Application.DTOs;
|
using GermanApp.Application.DTOs;
|
||||||
|
|
@ -17,7 +16,7 @@ namespace GermanApp.Presentation.Controllers;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[Authorize] // All story endpoints require authentication
|
[Authorize]
|
||||||
public class StoryController : ControllerBase
|
public class StoryController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly StoryService _storyService;
|
private readonly StoryService _storyService;
|
||||||
|
|
@ -102,8 +101,8 @@ public class StoryController : ControllerBase
|
||||||
|
|
||||||
if (!segments.Any())
|
if (!segments.Any())
|
||||||
{
|
{
|
||||||
_logger.LogInformation("No story segments found for level {LevelId}", levelId);
|
_logger.LogWarning("No story segments found for level {LevelId}", levelId);
|
||||||
return Ok(new List<StorySegmentDto>()); // Return empty list instead of NotFound
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(segments);
|
return Ok(segments);
|
||||||
|
|
@ -412,9 +411,8 @@ public class StoryController : ControllerBase
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static files are served from wwwroot, and audio files are stored at wwwroot/audio/story/
|
// In a real implementation, return the actual file
|
||||||
// The segment.AudioUrl is already in the format "/audio/story/levelN-segmentM.wav"
|
// For now, return the URL
|
||||||
// The static file middleware will serve it automatically
|
|
||||||
return Ok(new { AudioUrl = segment.AudioUrl });
|
return Ok(new { AudioUrl = segment.AudioUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -457,8 +455,7 @@ public class StoryController : ControllerBase
|
||||||
/// <returns>The user ID</returns>
|
/// <returns>The user ID</returns>
|
||||||
private int GetUserId()
|
private int GetUserId()
|
||||||
{
|
{
|
||||||
// Note: JWT "sub" claim is mapped by ASP.NET Core JWT middleware to ClaimTypes.NameIdentifier
|
var userIdClaim = User.FindFirst("sub") ?? User.FindFirst("nameidentifier");
|
||||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier) ?? User.FindFirst("sub");
|
|
||||||
|
|
||||||
if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId))
|
if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ public static class LessonsEndpoints
|
||||||
var lessons = await repository.GetAllAsync();
|
var lessons = await repository.GetAllAsync();
|
||||||
return Results.Ok(lessons.Select(l => l.ToDto()));
|
return Results.Ok(lessons.Select(l => l.ToDto()));
|
||||||
})
|
})
|
||||||
.RequireAuthorization()
|
|
||||||
.WithName("GetAllLessons")
|
.WithName("GetAllLessons")
|
||||||
.WithOpenApi(operation => new(operation)
|
.WithOpenApi(operation => new(operation)
|
||||||
{
|
{
|
||||||
|
|
@ -37,7 +36,6 @@ public static class LessonsEndpoints
|
||||||
var lesson = await repository.GetByIdAsync(id);
|
var lesson = await repository.GetByIdAsync(id);
|
||||||
return lesson is null ? Results.NotFound() : Results.Ok(lesson.ToDto());
|
return lesson is null ? Results.NotFound() : Results.Ok(lesson.ToDto());
|
||||||
})
|
})
|
||||||
.RequireAuthorization()
|
|
||||||
.WithName("GetLessonById")
|
.WithName("GetLessonById")
|
||||||
.WithOpenApi(operation => new(operation)
|
.WithOpenApi(operation => new(operation)
|
||||||
{
|
{
|
||||||
|
|
@ -56,7 +54,6 @@ public static class LessonsEndpoints
|
||||||
beginnerLessons.AddRange(level2Lessons);
|
beginnerLessons.AddRange(level2Lessons);
|
||||||
return Results.Ok(beginnerLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto()));
|
return Results.Ok(beginnerLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto()));
|
||||||
})
|
})
|
||||||
.RequireAuthorization()
|
|
||||||
.WithName("GetBeginnerLessons")
|
.WithName("GetBeginnerLessons")
|
||||||
.WithOpenApi(operation => new(operation)
|
.WithOpenApi(operation => new(operation)
|
||||||
{
|
{
|
||||||
|
|
@ -75,7 +72,6 @@ public static class LessonsEndpoints
|
||||||
advancedLessons.AddRange(level5Lessons);
|
advancedLessons.AddRange(level5Lessons);
|
||||||
return Results.Ok(advancedLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto()));
|
return Results.Ok(advancedLessons.OrderBy(l => l.LevelId).ThenBy(l => l.Order).Select(l => l.ToDto()));
|
||||||
})
|
})
|
||||||
.RequireAuthorization()
|
|
||||||
.WithName("GetAdvancedLessons")
|
.WithName("GetAdvancedLessons")
|
||||||
.WithOpenApi(operation => new(operation)
|
.WithOpenApi(operation => new(operation)
|
||||||
{
|
{
|
||||||
|
|
@ -89,7 +85,6 @@ public static class LessonsEndpoints
|
||||||
var lessons = await repository.GetByLevelAsync(level);
|
var lessons = await repository.GetByLevelAsync(level);
|
||||||
return Results.Ok(lessons.Select(l => l.ToDto()));
|
return Results.Ok(lessons.Select(l => l.ToDto()));
|
||||||
})
|
})
|
||||||
.RequireAuthorization()
|
|
||||||
.WithName("GetLessonsByLevel")
|
.WithName("GetLessonsByLevel")
|
||||||
.WithOpenApi(operation => new(operation)
|
.WithOpenApi(operation => new(operation)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using System.IO;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
// Configure Serilog
|
// Configure Serilog
|
||||||
|
|
@ -78,7 +77,7 @@ try
|
||||||
ValidIssuer = jwtIssuer,
|
ValidIssuer = jwtIssuer,
|
||||||
ValidAudience = jwtAudience,
|
ValidAudience = jwtAudience,
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
||||||
ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes of clock difference
|
ClockSkew = TimeSpan.Zero
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -91,32 +90,11 @@ try
|
||||||
// Configure CORS
|
// Configure CORS
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
// AllowAll policy - for production without credentials
|
|
||||||
// Note: Cannot use AllowAnyOrigin() with AllowCredentials()
|
|
||||||
options.AddPolicy("AllowAll", builder =>
|
options.AddPolicy("AllowAll", builder =>
|
||||||
{
|
{
|
||||||
builder.AllowAnyOrigin()
|
builder.AllowAnyOrigin()
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()
|
||||||
.AllowAnyHeader();
|
.AllowAnyHeader();
|
||||||
// Note: No AllowCredentials() - cannot combine with AllowAnyOrigin()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Development policy - for Docker and local development with credentials
|
|
||||||
options.AddPolicy("Development", builder =>
|
|
||||||
{
|
|
||||||
builder.WithOrigins("http://localhost:5173", "http://localhost:5174", "http://localhost:5175", "http://localhost:3000")
|
|
||||||
.AllowAnyMethod()
|
|
||||||
.AllowAnyHeader()
|
|
||||||
.AllowCredentials();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Docker policy - for when frontend is served from Docker nginx
|
|
||||||
options.AddPolicy("Docker", builder =>
|
|
||||||
{
|
|
||||||
builder.WithOrigins("http://localhost:3000")
|
|
||||||
.AllowAnyMethod()
|
|
||||||
.AllowAnyHeader()
|
|
||||||
.AllowCredentials();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -154,7 +132,6 @@ try
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register repositories (Infrastructure implementations of Domain interfaces)
|
// Register repositories (Infrastructure implementations of Domain interfaces)
|
||||||
builder.Services.AddScoped<IRepository<User, int>, UserRepository>();
|
|
||||||
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
|
builder.Services.AddScoped<ILevelRepository, LevelRepository>();
|
||||||
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
|
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
|
||||||
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
|
builder.Services.AddScoped<IUserProgressRepository, UserProgressRepository>();
|
||||||
|
|
@ -195,14 +172,7 @@ try
|
||||||
builder.Services.Configure<CoquiConfig>(builder.Configuration.GetSection("Coqui"));
|
builder.Services.Configure<CoquiConfig>(builder.Configuration.GetSection("Coqui"));
|
||||||
|
|
||||||
// Validate AI service configurations
|
// Validate AI service configurations
|
||||||
try
|
|
||||||
{
|
|
||||||
ValidateAiConfigurations(builder.Configuration);
|
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)
|
// Register AI services (Infrastructure implementations of Domain interfaces)
|
||||||
builder.Services.AddScoped<IMistralService, MistralService>();
|
builder.Services.AddScoped<IMistralService, MistralService>();
|
||||||
|
|
@ -236,11 +206,6 @@ try
|
||||||
// Register Story services
|
// Register Story services
|
||||||
builder.Services.AddScoped<StoryService>();
|
builder.Services.AddScoped<StoryService>();
|
||||||
builder.Services.AddScoped<StoryUnlockService>();
|
builder.Services.AddScoped<StoryUnlockService>();
|
||||||
|
|
||||||
// Register Admin services
|
|
||||||
builder.Services.AddScoped<IAdminService, AdminService>();
|
|
||||||
builder.Services.AddScoped<IUserReportService, UserReportService>();
|
|
||||||
|
|
||||||
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
|
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
|
||||||
|
|
||||||
// Note: QuizQuestionService requires IQuizRepository, ILessonRepository, and ProgressService which are registered above
|
// Note: QuizQuestionService requires IQuizRepository, ILessonRepository, and ProgressService which are registered above
|
||||||
|
|
@ -253,33 +218,11 @@ try
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Create static file directories
|
|
||||||
bool isEfDesignTime = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_EF") == "true";
|
|
||||||
if (!isEfDesignTime)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(Path.Combine("wwwroot", "audio", "story"));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Skip during migrations
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
|
|
||||||
// Use exception middleware first (to catch all exceptions)
|
// Use exception middleware first (to catch all exceptions)
|
||||||
app.UseExceptionMiddleware();
|
app.UseExceptionMiddleware();
|
||||||
|
|
||||||
// Use CORS - must be early in the pipeline, before UseAuthorization
|
|
||||||
// In Docker, use "Docker" policy to allow credentials from localhost:3000
|
|
||||||
// In development without Docker, use "Development" policy
|
|
||||||
app.UseCors(app.Environment.IsDevelopment() ? "Development" : "Docker");
|
|
||||||
|
|
||||||
// Serve static files (audio, etc.)
|
|
||||||
app.UseStaticFiles();
|
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
|
|
@ -291,6 +234,9 @@ try
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
// Use CORS
|
||||||
|
app.UseCors("AllowAll");
|
||||||
|
|
||||||
// Use Health Checks
|
// Use Health Checks
|
||||||
app.MapHealthChecks("/health");
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
|
|
@ -343,32 +289,17 @@ try
|
||||||
// Helper method to validate AI service configurations
|
// Helper method to validate AI service configurations
|
||||||
static void ValidateAiConfigurations(IConfiguration configuration)
|
static void ValidateAiConfigurations(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
// Skip validation during EF migrations
|
// Validate Mistral configuration
|
||||||
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();
|
var mistralConfig = configuration.GetSection("Mistral").Get<MistralConfig>() ?? new MistralConfig();
|
||||||
if (!string.IsNullOrWhiteSpace(mistralConfig.ApiKey))
|
|
||||||
{
|
|
||||||
mistralConfig.Validate();
|
mistralConfig.Validate();
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Vosk configuration (only if model path is set)
|
// Validate Vosk configuration
|
||||||
var voskConfig = configuration.GetSection("Vosk").Get<VoskConfig>() ?? new VoskConfig();
|
var voskConfig = configuration.GetSection("Vosk").Get<VoskConfig>() ?? new VoskConfig();
|
||||||
if (!string.IsNullOrWhiteSpace(voskConfig.ModelPath))
|
|
||||||
{
|
|
||||||
voskConfig.Validate();
|
voskConfig.Validate();
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Coqui configuration (only if Python path is set)
|
// Validate Coqui configuration
|
||||||
var coquiConfig = configuration.GetSection("Coqui").Get<CoquiConfig>() ?? new CoquiConfig();
|
var coquiConfig = configuration.GetSection("Coqui").Get<CoquiConfig>() ?? new CoquiConfig();
|
||||||
if (!string.IsNullOrWhiteSpace(coquiConfig.PythonPath))
|
|
||||||
{
|
|
||||||
coquiConfig.Validate();
|
coquiConfig.Validate();
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information("All AI service configurations validated successfully");
|
Log.Information("All AI service configurations validated successfully");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Text;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
||||||
|
|
||||||
namespace GermanApp.Tests.Unit.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests JWT token generation and validation to verify the authentication fix
|
|
||||||
/// </summary>
|
|
||||||
[TestClass]
|
|
||||||
public class JwtTokenValidationTests
|
|
||||||
{
|
|
||||||
private const string TestJwtKey = "test-secret-key-at-least-32-characters-long";
|
|
||||||
private const string TestJwtIssuer = "DeutschLernen";
|
|
||||||
private const string TestJwtAudience = "DeutschLernen";
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[Description("Tests that JWT sub claim can be found after validation (reproduces the bug and verifies the fix)")]
|
|
||||||
public void JWT_SubClaim_Mapping_Bug_Reproduction()
|
|
||||||
{
|
|
||||||
// This test reproduces the exact bug that was causing /me to return 401
|
|
||||||
|
|
||||||
// Arrange: Generate a JWT token with "sub" claim (what AuthService.GenerateJwtToken does)
|
|
||||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(TestJwtKey));
|
|
||||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
|
||||||
|
|
||||||
var claims = new[]
|
|
||||||
{
|
|
||||||
new Claim(JwtRegisteredClaimNames.Sub, "123"), // JWT standard: "sub"
|
|
||||||
new Claim(JwtRegisteredClaimNames.Name, "testuser"),
|
|
||||||
new Claim(ClaimTypes.Role, "User")
|
|
||||||
};
|
|
||||||
|
|
||||||
var token = new JwtSecurityToken(
|
|
||||||
issuer: TestJwtIssuer,
|
|
||||||
audience: TestJwtAudience,
|
|
||||||
claims: claims,
|
|
||||||
expires: DateTime.UtcNow.AddHours(24),
|
|
||||||
signingCredentials: credentials
|
|
||||||
);
|
|
||||||
|
|
||||||
var tokenString = new JwtSecurityTokenHandler().WriteToken(token);
|
|
||||||
|
|
||||||
// Act: Validate the token (what ASP.NET Core JWT middleware does)
|
|
||||||
var validationParameters = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidateLifetime = true,
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
ValidIssuer = TestJwtIssuer,
|
|
||||||
ValidAudience = TestJwtAudience,
|
|
||||||
IssuerSigningKey = securityKey,
|
|
||||||
ClockSkew = TimeSpan.FromMinutes(5)
|
|
||||||
};
|
|
||||||
|
|
||||||
var tokenHandler = new JwtSecurityTokenHandler();
|
|
||||||
SecurityToken validatedToken;
|
|
||||||
var principal = tokenHandler.ValidateToken(tokenString, validationParameters, out validatedToken);
|
|
||||||
|
|
||||||
// Assert: This is what AuthController.GetCurrentUser() does
|
|
||||||
// BEFORE THE FIX: User.FindFirst("sub") returned null because JWT middleware maps "sub" to ClaimTypes.NameIdentifier
|
|
||||||
// AFTER THE FIX: We use ClaimTypes.NameIdentifier to find the mapped claim
|
|
||||||
|
|
||||||
// The WRONG way (what was causing the bug):
|
|
||||||
var subClaimByString = principal.FindFirst("sub");
|
|
||||||
|
|
||||||
// The RIGHT way (the fix):
|
|
||||||
var subClaimByNameIdentifier = principal.FindFirst(ClaimTypes.NameIdentifier);
|
|
||||||
|
|
||||||
// Verify the problem exists (for documentation):
|
|
||||||
Assert.IsNull(subClaimByString, "This SHOULD be null - JWT middleware maps 'sub' to NameIdentifier");
|
|
||||||
|
|
||||||
// Verify the fix works:
|
|
||||||
Assert.IsNotNull(subClaimByNameIdentifier, "This SHOULD NOT be null - using ClaimTypes.NameIdentifier");
|
|
||||||
Assert.AreEqual("123", subClaimByNameIdentifier.Value);
|
|
||||||
|
|
||||||
// Also verify the claim type is the full URI:
|
|
||||||
Assert.AreEqual(
|
|
||||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
|
|
||||||
subClaimByNameIdentifier.Type
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[Description("Tests that User.FindFirst(ClaimTypes.NameIdentifier) works for getting user ID")]
|
|
||||||
public void JWT_UserId_Extraction_Works()
|
|
||||||
{
|
|
||||||
// Simulates the exact flow: login -> token generated -> /me endpoint called
|
|
||||||
|
|
||||||
// Arrange: Generate token (AuthService.GenerateJwtToken)
|
|
||||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(TestJwtKey));
|
|
||||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
|
||||||
|
|
||||||
var userId = 42;
|
|
||||||
var claims = new[]
|
|
||||||
{
|
|
||||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
|
||||||
new Claim(JwtRegisteredClaimNames.Name, "testuser"),
|
|
||||||
new Claim(ClaimTypes.Role, "User")
|
|
||||||
};
|
|
||||||
|
|
||||||
var token = new JwtSecurityToken(
|
|
||||||
issuer: TestJwtIssuer,
|
|
||||||
audience: TestJwtAudience,
|
|
||||||
claims: claims,
|
|
||||||
expires: DateTime.UtcNow.AddHours(24),
|
|
||||||
signingCredentials: credentials
|
|
||||||
);
|
|
||||||
|
|
||||||
var tokenString = new JwtSecurityTokenHandler().WriteToken(token);
|
|
||||||
|
|
||||||
// Act: Validate token (JWT middleware)
|
|
||||||
var validationParameters = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidateLifetime = true,
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
ValidIssuer = TestJwtIssuer,
|
|
||||||
ValidAudience = TestJwtAudience,
|
|
||||||
IssuerSigningKey = securityKey,
|
|
||||||
ClockSkew = TimeSpan.FromMinutes(5)
|
|
||||||
};
|
|
||||||
|
|
||||||
var tokenHandler = new JwtSecurityTokenHandler();
|
|
||||||
SecurityToken validatedToken;
|
|
||||||
var principal = tokenHandler.ValidateToken(tokenString, validationParameters, out validatedToken);
|
|
||||||
|
|
||||||
// Act: Extract user ID (AuthController.GetCurrentUser)
|
|
||||||
// This is the FIX - use ClaimTypes.NameIdentifier instead of "sub"
|
|
||||||
var userIdClaim = principal.FindFirst(ClaimTypes.NameIdentifier) ?? principal.FindFirst("sub");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(userIdClaim, "User ID claim should be found");
|
|
||||||
Assert.AreEqual(userId.ToString(), userIdClaim.Value);
|
|
||||||
Assert.AreEqual("42", userIdClaim.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[Description("Tests fallback: if ClaimTypes.NameIdentifier is null, try 'sub' string")]
|
|
||||||
public void JWT_Fallback_To_Sub_String_Works()
|
|
||||||
{
|
|
||||||
// This tests the fallback logic in case the mapping doesn't happen
|
|
||||||
|
|
||||||
// Arrange: Generate token
|
|
||||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(TestJwtKey));
|
|
||||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
|
||||||
|
|
||||||
var claims = new[]
|
|
||||||
{
|
|
||||||
new Claim(JwtRegisteredClaimNames.Sub, "123"),
|
|
||||||
new Claim(JwtRegisteredClaimNames.Name, "testuser"),
|
|
||||||
new Claim(ClaimTypes.Role, "User")
|
|
||||||
};
|
|
||||||
|
|
||||||
var token = new JwtSecurityToken(
|
|
||||||
issuer: TestJwtIssuer,
|
|
||||||
audience: TestJwtAudience,
|
|
||||||
claims: claims,
|
|
||||||
expires: DateTime.UtcNow.AddHours(24),
|
|
||||||
signingCredentials: credentials
|
|
||||||
);
|
|
||||||
|
|
||||||
var tokenString = new JwtSecurityTokenHandler().WriteToken(token);
|
|
||||||
|
|
||||||
// Act: Validate
|
|
||||||
var validationParameters = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidateLifetime = true,
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
ValidIssuer = TestJwtIssuer,
|
|
||||||
ValidAudience = TestJwtAudience,
|
|
||||||
IssuerSigningKey = securityKey,
|
|
||||||
ClockSkew = TimeSpan.FromMinutes(5)
|
|
||||||
};
|
|
||||||
|
|
||||||
var tokenHandler = new JwtSecurityTokenHandler();
|
|
||||||
SecurityToken validatedToken;
|
|
||||||
var principal = tokenHandler.ValidateToken(tokenString, validationParameters, out validatedToken);
|
|
||||||
|
|
||||||
// Act: Extract user ID with fallback
|
|
||||||
var userIdClaim = principal.FindFirst(ClaimTypes.NameIdentifier) ?? principal.FindFirst("sub");
|
|
||||||
|
|
||||||
// Assert: Should find it with ClaimTypes.NameIdentifier
|
|
||||||
Assert.IsNotNull(userIdClaim);
|
|
||||||
Assert.AreEqual("123", userIdClaim.Value);
|
|
||||||
|
|
||||||
// Also verify that the fallback ("sub") would also work if NameIdentifier is not found
|
|
||||||
// (though in practice, NameIdentifier should always be found when JWT has "sub")
|
|
||||||
var subClaim = principal.FindFirst("sub");
|
|
||||||
// This will be null because of the JWT middleware mapping, but the fallback logic handles it
|
|
||||||
// The actual fix is using ClaimTypes.NameIdentifier first
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,5 @@
|
||||||
# Feature Tracking & Implementation Plans
|
# Feature Tracking & Implementation Plans
|
||||||
|
|
||||||
> **🚨 CURRENT STATUS: Phase 4 (Admin Module) Being Defined - Authentication Enforcement Required**
|
|
||||||
>
|
|
||||||
> **Completed:** Infrastructure, Auth, Lesson Mgmt, AI Services, Vocabulary, Quiz, Story Integration (Phases 1-6)
|
|
||||||
>
|
|
||||||
> **In Progress:** Admin Module & User Management (Authentication Enforcement)
|
|
||||||
>
|
|
||||||
> **Next:** Frontend Completion, Polish & Testing
|
|
||||||
|
|
||||||
This directory contains implementation plans and progress tracking for features in the **DeutschLernen** solution. Each feature follows a comprehensive template with **Definition of Done**, **Testing Strategy**, and detailed technical design.
|
This directory contains implementation plans and progress tracking for features in the **DeutschLernen** solution. Each feature follows a comprehensive template with **Definition of Done**, **Testing Strategy**, and detailed technical design.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -30,7 +22,6 @@ Backlog → Planned → In Progress → Code Review → Completed
|
||||||
features/
|
features/
|
||||||
├── README.md # This file - Feature tracking overview & roadmap
|
├── README.md # This file - Feature tracking overview & roadmap
|
||||||
├── template.md # Template for new feature implementation plans
|
├── template.md # Template for new feature implementation plans
|
||||||
├── admin-module.md # Admin dashboard, user management, reports
|
|
||||||
├── ai-services.md # Mistral, Vosk, Coqui TTS integration
|
├── ai-services.md # Mistral, Vosk, Coqui TTS integration
|
||||||
├── frontend-ui.md # React + TypeScript frontend application
|
├── frontend-ui.md # React + TypeScript frontend application
|
||||||
├── gamification.md # Points, badges, streaks system
|
├── gamification.md # Points, badges, streaks system
|
||||||
|
|
@ -63,90 +54,51 @@ Each feature file contains:
|
||||||
Based on dependencies and complexity, here's the recommended implementation order:
|
Based on dependencies and complexity, here's the recommended implementation order:
|
||||||
|
|
||||||
### Phase 1: Foundation (Weeks 1-2)
|
### Phase 1: Foundation (Weeks 1-2)
|
||||||
**Total: ~30-42 hours** | **Prerequisite: None** | **Status: ✅ COMPLETE**
|
**Total: ~30-42 hours** | **Prerequisite: None**
|
||||||
|
|
||||||
| # | Feature | Priority | Estimate | Dependencies | Status |
|
| # | Feature | Priority | Estimate | Dependencies | Status |
|
||||||
|---|---------|----------|----------|--------------|--------|
|
|---|---------|----------|----------|--------------|--------|
|
||||||
| 1 | [Infrastructure Setup](infrastructure-setup.md) | High | 10-14h | None | ✅ Complete |
|
| 1 | [Infrastructure Setup](infrastructure-setup.md) | High | 10-14h | None | ⏳ Planned |
|
||||||
| 2 | [User Authentication](user-authentication.md) | High | 4-6h | Infrastructure | ✅ Complete |
|
| 2 | [User Authentication](user-authentication.md) | High | 4-6h | Infrastructure | ⏳ Planned |
|
||||||
|
|
||||||
**Goal:** Have a working backend project, database, and authentication system.
|
**Goal:** Have a working backend project, database, and authentication system.
|
||||||
✅ **ACHIEVED**: JWT auth working, register/login endpoints functional
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 2: Core Backend (Weeks 3-4)
|
### Phase 2: Core Backend (Weeks 3-4)
|
||||||
**Total: ~42-58 hours** | **Prerequisite: Phase 1** | **Status: ✅ COMPLETE**
|
**Total: ~42-58 hours** | **Prerequisite: Phase 1**
|
||||||
|
|
||||||
| # | Feature | Priority | Estimate | Dependencies | Status |
|
| # | Feature | Priority | Estimate | Dependencies | Status |
|
||||||
|---|---------|----------|----------|--------------|--------|
|
|---|---------|----------|----------|--------------|--------|
|
||||||
| 3 | [Lesson Management](lesson-management.md) | High | 10-16h | Infrastructure, Auth | ✅ Complete |
|
| 3 | [Lesson Management](lesson-management.md) | High | 10-16h | Infrastructure, Auth | ⏳ Planned |
|
||||||
| 4 | [AI Services](ai-services.md) | High | 10-16h | Infrastructure | ✅ Complete |
|
| 4 | [AI Services](ai-services.md) | High | 10-16h | Infrastructure | ⏳ Planned |
|
||||||
| 5 | [Vocabulary System](vocabulary-system.md) | High | 8-12h | Infrastructure, Lessons | ✅ Complete |
|
| 5 | [Vocabulary System](vocabulary-system.md) | High | 8-12h | Infrastructure, Lessons | ⏳ Planned |
|
||||||
| 6 | [Quiz System](quiz-system.md) | High | 6-10h | Infrastructure, Lessons | ✅ Complete |
|
| 6 | [Quiz System](quiz-system.md) | High | 6-10h | Infrastructure, Lessons | ⏳ Planned |
|
||||||
|
|
||||||
**Goal:** Have all core backend functionality working with AI integration.
|
**Goal:** Have all core backend functionality working with AI integration.
|
||||||
✅ **ACHIEVED**: Lessons, AI, Vocabulary, Quiz systems implemented
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 3: Content & Features (Weeks 5-6)
|
### Phase 3: Content & Features (Weeks 5-6)
|
||||||
**Total: ~30-42 hours** | **Prerequisite: Phase 2** | **Status: 🚀 IN PROGRESS (86% Complete)**
|
**Total: ~30-42 hours** | **Prerequisite: Phase 2**
|
||||||
|
|
||||||
| # | Feature | Priority | Estimate | Dependencies | Status |
|
| # | Feature | Priority | Estimate | Dependencies | Status |
|
||||||
|---|---------|----------|----------|--------------|--------|
|
|---|---------|----------|----------|--------------|--------|
|
||||||
| 7 | [Story Integration](story-integration.md) | High | 8-12h | Lessons, AI Services | ✅ Phase 1-6 Complete |
|
| 7 | [Story Integration](story-integration.md) | High | 8-12h | Lessons, AI Services | ⏳ Planned |
|
||||||
| 8 | [Gamification](gamification.md) | Medium | 6-8h | Auth, Lessons, Quiz | ⏳ Planned |
|
| 8 | [Gamification](gamification.md) | Medium | 6-8h | Auth, Lessons, Quiz | ⏳ Planned |
|
||||||
|
|
||||||
**Goal:** Have all content management and gamification features working.
|
**Goal:** Have all content management and gamification features working.
|
||||||
✅ **PHASE 6 COMPLETE**: Database, Backend Services, Unit Tests, AI Integration, Audio Generation, Frontend Integration
|
|
||||||
⏳ **REMAINING**: Gamification
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 4: Authentication Enforcement & Admin (Week 7)
|
### Phase 4: Frontend (Weeks 7-8)
|
||||||
**Total: ~12-16 hours** | **Prerequisite: Phase 1-3** | **Status: 📝 PLANNED**
|
**Total: ~10-16 hours** | **Prerequisite: Phase 2-3**
|
||||||
|
|
||||||
| # | Feature | Priority | Estimate | Dependencies | Status |
|
| # | Feature | Priority | Estimate | Dependencies | Status |
|
||||||
|---|---------|----------|----------|--------------|--------|
|
|---|---------|----------|----------|--------------|--------|
|
||||||
| 9 | **[Admin Module & User Management](admin-module.md)** | **High** | **12-16h** | Auth, User Auth, Story Integration | 📝 Planned |
|
| 9 | [Frontend UI](frontend-ui.md) | High | 10-16h | All backend features | ⏳ Planned |
|
||||||
|
|
||||||
**Requirements:**
|
**Goal:** Complete frontend application with all UI components and pages.
|
||||||
- ✅ Mandatory user registration before accessing content
|
|
||||||
- ✅ Track multiple users' progress individually
|
|
||||||
- ✅ Admin module (Lasse only)
|
|
||||||
- ✅ Admin can generate stories
|
|
||||||
- ✅ Admin can generate user progress reports
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
- All learning endpoints require authentication
|
|
||||||
- Admin role system with role-based authorization
|
|
||||||
- Admin UI for user management and story generation
|
|
||||||
- Admin reporting functionality
|
|
||||||
|
|
||||||
**Goal:** Enforce authentication and provide admin dashboard for content management and analytics.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 5: Frontend Completion (Weeks 8-9)
|
|
||||||
**Total: ~10-16 hours** | **Prerequisite: Phase 2-4**
|
|
||||||
|
|
||||||
| # | Feature | Priority | Estimate | Dependencies | Status |
|
|
||||||
|---|---------|----------|----------|--------------|--------|
|
|
||||||
| 10 | [Frontend UI](frontend-ui.md) | High | 10-16h | All backend features | ⏳ Planned |
|
|
||||||
|
|
||||||
**Goal:** Complete frontend application with all UI components, pages, and authentication flow.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 6: Polish & Testing (Week 10)
|
|
||||||
**Total: ~8-12 hours** | **Prerequisite: All previous phases**
|
|
||||||
|
|
||||||
| # | Feature | Priority | Estimate | Dependencies | Status |
|
|
||||||
|---|---------|----------|----------|--------------|--------|
|
|
||||||
| 11 | Testing & Bug Fixes | High | 8-12h | All features | ⏳ Planned |
|
|
||||||
|
|
||||||
**Goal:** Comprehensive testing, bug fixing, and polish before production.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,443 +0,0 @@
|
||||||
# Feature: Admin Module & User Management
|
|
||||||
|
|
||||||
> **Status**: ✅ Completed
|
|
||||||
> **Priority**: High
|
|
||||||
> **Complexity**: High
|
|
||||||
> **Estimate**: 12-16 hours
|
|
||||||
> **Assignee**: -
|
|
||||||
> **Created**: June 14, 2025
|
|
||||||
> **Completed**: June 14, 2026
|
|
||||||
> **PR**: -
|
|
||||||
> **Related Features**: User Authentication, Story Integration, Lesson Management, Progress Tracking
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📌 Overview
|
|
||||||
|
|
||||||
### Purpose
|
|
||||||
Implement a comprehensive admin module that allows the application owner (Lasse) to manage users, generate story content, and access progress reports. This is **mission-critical** for the application as it enforces the requirement that **all visitors must sign up before accessing any learning content**.
|
|
||||||
|
|
||||||
### User Stories
|
|
||||||
- **As an admin**, I want to generate stories for levels so that users have content to learn from
|
|
||||||
- **As an admin**, I want to view user lists and their progress so that I can monitor application usage
|
|
||||||
- **As an admin**, I want to view progress reports so that I can understand how users are engaging with the platform
|
|
||||||
- **As a visitor**, I must sign up and login before I can access any learning content (stories, lessons, quizzes)
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
- [x] All learning content endpoints require authentication (no public access)
|
|
||||||
- [x] User registration is mandatory before accessing any content
|
|
||||||
- [x] Admin role exists and is assigned to specific users only
|
|
||||||
- [x] Admin can access story generation UI/API
|
|
||||||
- [x] Admin can view list of all users
|
|
||||||
- [x] Admin can view individual user progress (lessons completed, stories unlocked)
|
|
||||||
- [x] Admin can generate reports on user activity
|
|
||||||
- [x] Admin endpoints are protected and only accessible to admin users
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Requirements
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
| ID | Requirement | Priority | Status |
|
|
||||||
|----|-------------|----------|--------|
|
|
||||||
| FR-001 | Mandatory user registration before accessing content | High | ✅ Complete |
|
|
||||||
| FR-002 | JWT authentication required for ALL learning endpoints | High | ✅ Complete |
|
|
||||||
| FR-003 | Admin role system with single admin user (Lasse) | High | ✅ Complete |
|
|
||||||
| FR-004 | Admin UI for story generation | High | ✅ Complete |
|
|
||||||
| FR-005 | Admin API endpoint for story generation | High | ✅ Complete |
|
|
||||||
| FR-006 | Admin UI for viewing all users | High | ✅ Complete |
|
|
||||||
| FR-007 | Admin API endpoint for listing users | High | ✅ Complete |
|
|
||||||
| FR-008 | Admin UI for viewing user progress | High | ✅ Complete |
|
|
||||||
| FR-009 | Admin API endpoint for user progress | High | ✅ Complete |
|
|
||||||
| FR-010 | Admin UI for generating user progress reports | Medium | ✅ Complete |
|
|
||||||
| FR-011 | Admin API endpoint for progress reports | Medium | ✅ Complete |
|
|
||||||
| FR-012 | Admin dashboard with overview statistics | Medium | ✅ Complete |
|
|
||||||
|
|
||||||
### Non-Functional Requirements
|
|
||||||
- Security: Admin endpoints must be protected with role-based authorization
|
|
||||||
- Security: Admin role can only be assigned through direct database manipulation (not via API)
|
|
||||||
- Performance: User list loading < 500ms for up to 10,000 users
|
|
||||||
- Performance: Progress reports generation < 2 seconds
|
|
||||||
- Data Retention: User progress data retained indefinitely
|
|
||||||
- Audit: Admin actions should be logged (future enhancement)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ Technical Design
|
|
||||||
|
|
||||||
### Components Involved
|
|
||||||
|
|
||||||
#### Backend (GermanApp)
|
|
||||||
- **Controllers**: AdminController (new), UserController (new), ReportController (new)
|
|
||||||
- **Services**: AdminService (new), UserReportService (new)
|
|
||||||
- **Entities**: User (existing), StorySegment (existing), StoryProgress (existing), UserProgress (existing)
|
|
||||||
- **Repositories**: IUserRepository (existing), IStoryProgressRepository (existing), IUserProgressRepository (existing)
|
|
||||||
- **DTOs**: UserDto, UserListDto, UserProgressDto, ProgressReportDto, StoryGenerationRequestDto (existing)
|
|
||||||
- **Middleware**: Role-based authorization (existing JWT infrastructure)
|
|
||||||
|
|
||||||
#### Frontend (german-app-frontend)
|
|
||||||
- **Pages**: AdminDashboard (new), AdminUsers (new), AdminReports (new), AdminStoryGenerator (new)
|
|
||||||
- **Components**: UserList (new), UserCard (new), ProgressChart (new), StoryGenerationForm (new)
|
|
||||||
- **Services**: adminApi (new), authService (existing)
|
|
||||||
- **Types**: User, UserProgress, ProgressReport (new)
|
|
||||||
- **Routing**: Protected admin routes with role check
|
|
||||||
|
|
||||||
#### Infrastructure
|
|
||||||
- **Database**: No new tables needed (uses existing Users, StoryProgress, UserProgress, StorySegments)
|
|
||||||
- **Configuration**: Admin role configuration in JWT claims
|
|
||||||
|
|
||||||
### Data Flow
|
|
||||||
|
|
||||||
#### User Authentication Flow (Mandatory)
|
|
||||||
```
|
|
||||||
1. Visitor arrives at / (home page)
|
|
||||||
2. Frontend checks localStorage for JWT token
|
|
||||||
3. If no token → redirect to /login
|
|
||||||
4. User enters credentials → POST /api/auth/login
|
|
||||||
5. Backend validates → returns JWT token
|
|
||||||
6. Frontend stores token → redirects to /learn or /story/1
|
|
||||||
7. All subsequent requests include Authorization: Bearer <token>
|
|
||||||
8. Backend middleware validates token → allows access to protected endpoints
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Story Generation Flow (Admin Only)
|
|
||||||
```
|
|
||||||
1. Admin navigates to /admin/stories
|
|
||||||
2. Frontend verifies user has admin role (from JWT claims)
|
|
||||||
3. Admin selects level (1-5) and enters theme
|
|
||||||
4. Frontend POST /api/admin/stories/generate with {levelId, theme, segmentCount}
|
|
||||||
5. Backend verifies admin role
|
|
||||||
6. Backend extracts vocabulary from level's lessons
|
|
||||||
7. Backend calls Mistral AI with CEFR-specific prompt
|
|
||||||
8. Backend splits story into segments
|
|
||||||
9. Backend saves segments to database
|
|
||||||
10. Backend returns success with generated segments
|
|
||||||
11. Frontend shows success message
|
|
||||||
```
|
|
||||||
|
|
||||||
#### User Management Flow (Admin Only)
|
|
||||||
```
|
|
||||||
1. Admin navigates to /admin/users
|
|
||||||
2. Frontend verifies admin role
|
|
||||||
3. Frontend GET /api/admin/users
|
|
||||||
4. Backend verifies admin role
|
|
||||||
5. Backend fetches all users from database
|
|
||||||
6. Backend returns user list with basic info
|
|
||||||
7. Frontend displays user table with filters
|
|
||||||
8. Admin clicks on user → GET /api/admin/users/{id}/progress
|
|
||||||
9. Backend returns detailed progress for that user
|
|
||||||
10. Frontend displays user progress dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Report Generation Flow (Admin Only)
|
|
||||||
```
|
|
||||||
1. Admin navigates to /admin/reports
|
|
||||||
2. Frontend verifies admin role
|
|
||||||
3. Admin selects report type (user activity, progress, etc.)
|
|
||||||
4. Frontend GET /api/admin/reports/{type}?startDate=...&endDate=...
|
|
||||||
5. Backend verifies admin role
|
|
||||||
6. Backend aggregates data based on report type
|
|
||||||
7. Backend returns report data
|
|
||||||
8. Frontend renders report with charts/tables
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Endpoints
|
|
||||||
|
|
||||||
#### Admin Endpoints (Require Admin Role)
|
|
||||||
| Endpoint | Method | Description | Auth Required | Admin Only |
|
|
||||||
|----------|--------|-------------|----------------|------------|
|
|
||||||
| `/api/admin/stories/generate` | POST | Generate story for a level | Yes | Yes |
|
|
||||||
| `/api/admin/stories/levels/{levelId}/regenerate` | POST | Regenerate story for level | Yes | Yes |
|
|
||||||
| `/api/admin/users` | GET | List all users | Yes | Yes |
|
|
||||||
| `/api/admin/users/{id}` | GET | Get specific user details | Yes | Yes |
|
|
||||||
| `/api/admin/users/{id}/progress` | GET | Get user's progress | Yes | Yes |
|
|
||||||
| `/api/admin/reports/activity` | GET | User activity report | Yes | Yes |
|
|
||||||
| `/api/admin/reports/progress` | GET | Learning progress report | Yes | Yes |
|
|
||||||
| `/api/admin/reports/completion` | GET | Lesson/story completion report | Yes | Yes |
|
|
||||||
| `/api/admin/dashboard` | GET | Admin dashboard statistics | Yes | Yes |
|
|
||||||
|
|
||||||
#### Modified Endpoints (Now Require Authentication)
|
|
||||||
| Endpoint | Method | Description | Auth Required | Change |
|
|
||||||
|----------|--------|-------------|----------------|--------|
|
|
||||||
| `/api/story/*` | GET/POST | All story endpoints | Yes | Added [Authorize] |
|
|
||||||
| `/api/lessons/*` | GET | All lesson endpoints | Yes | Added [Authorize] |
|
|
||||||
| `/api/quizzes/*` | GET | All quiz endpoints | Yes | Added [Authorize] |
|
|
||||||
| `/api/levels/*` | GET | All level endpoints | Yes | Added [Authorize] |
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
No new tables required. Uses existing:
|
|
||||||
- `Users` - User accounts
|
|
||||||
- `StorySegments` - Story content
|
|
||||||
- `StoryProgress` - Which story segments user has unlocked/completed
|
|
||||||
- `UserProgress` - Lesson completion tracking
|
|
||||||
- `Levels` - Learning levels (A1, A2, etc.)
|
|
||||||
- `Lessons` - Learning lessons
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Implementation Plan
|
|
||||||
|
|
||||||
### Phase 1: Authentication Enforcement (1-2 hours)
|
|
||||||
**Priority: Critical** - Must be done before any other work
|
|
||||||
|
|
||||||
- [x] Add `[Authorize]` to all learning content controllers
|
|
||||||
- [x] StoryController (GET endpoints)
|
|
||||||
- [x] LessonsController (GET endpoints)
|
|
||||||
- [x] QuizzesController (GET endpoints)
|
|
||||||
- [x] LevelsController (GET endpoints)
|
|
||||||
- [x] Add `.RequireAuthorization()` to all learning content minimal API endpoints
|
|
||||||
- [x] LessonsEndpoints.cs GET endpoints (/api/lessons, /api/lessons/{id}, /api/lessons/beginner, /api/lessons/advanced, /api/lessons/level/{level})
|
|
||||||
- [x] Remove `[Authorize]` from AuthController (register/login should be public)
|
|
||||||
- [x] Update CORS configuration to support credentials
|
|
||||||
- [x] Test authentication flow with Postman/curl
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
- All learning endpoints require JWT token
|
|
||||||
- Unauthenticated requests return 401
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 2: Admin Role System (2-3 hours)
|
|
||||||
**Priority: High** - Required for admin functionality
|
|
||||||
|
|
||||||
- [x] Define "Admin" role constant in backend
|
|
||||||
- [x] Modify JWT token generation to include roles
|
|
||||||
- [x] Update User entity to include role field
|
|
||||||
- [x] Add role to RegisterDto (or make first user admin)
|
|
||||||
- [x] Create migration for role field (if needed)
|
|
||||||
- [x] Add `[Authorize(Roles = "Admin")]` to admin endpoints
|
|
||||||
- [x] Update frontend auth to decode and store roles
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
- Role-based authorization working
|
|
||||||
- Admin users can access admin endpoints
|
|
||||||
- Regular users cannot access admin endpoints
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 3: Admin Backend Services (4-5 hours)
|
|
||||||
**Priority: High** - Backend infrastructure for admin features
|
|
||||||
|
|
||||||
- [x] Create AdminController
|
|
||||||
- [x] POST /api/admin/stories/generate
|
|
||||||
- [x] GET /api/admin/users
|
|
||||||
- [x] GET /api/admin/users/{id}/progress
|
|
||||||
- [x] GET /api/admin/reports/activity
|
|
||||||
- [x] GET /api/admin/reports/progress
|
|
||||||
- [x] GET /api/admin/reports/completion
|
|
||||||
- [x] Create AdminService
|
|
||||||
- [x] GenerateStoryForLevel()
|
|
||||||
- [x] GetAllUsers()
|
|
||||||
- [x] GetUserProgress()
|
|
||||||
- [x] GenerateActivityReport()
|
|
||||||
- [x] GenerateProgressReport()
|
|
||||||
- [x] GenerateCompletionReport()
|
|
||||||
- [x] Create UserReportService
|
|
||||||
- [x] Aggregate user data
|
|
||||||
- [x] Calculate statistics
|
|
||||||
- [x] Format reports
|
|
||||||
- [x] Create DTOs
|
|
||||||
- [x] AdminUserDto
|
|
||||||
- [x] UserProgressReportDto
|
|
||||||
- [x] ActivityReportDto
|
|
||||||
- [x] CompletionReportDto
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
- All admin API endpoints working
|
|
||||||
- Reports can be generated
|
|
||||||
- User data can be retrieved
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 4: Admin Frontend (5-6 hours)
|
|
||||||
**Priority: High** - Admin UI for managing the application
|
|
||||||
|
|
||||||
- [x] Create admin layout component
|
|
||||||
- [x] Create protected admin routes
|
|
||||||
- [x] Create AdminDashboard page
|
|
||||||
- [x] Display user count
|
|
||||||
- [x] Display story count
|
|
||||||
- [x] Display activity statistics
|
|
||||||
- [x] Quick access buttons
|
|
||||||
- [x] Create AdminUsers page
|
|
||||||
- [x] User list table with pagination
|
|
||||||
- [x] Search/filter functionality
|
|
||||||
- [x] Click to view user details
|
|
||||||
- [x] Create AdminUserDetail page
|
|
||||||
- [x] User profile information
|
|
||||||
- [x] Lesson completion progress
|
|
||||||
- [x] Story segment unlock/completion status
|
|
||||||
- [x] Activity timeline
|
|
||||||
- [x] Create AdminReports page
|
|
||||||
- [x] Report type selector
|
|
||||||
- [x] Date range picker
|
|
||||||
- [x] Report generation button
|
|
||||||
- [x] Report display (tables/charts)
|
|
||||||
- [x] Create AdminStoryGenerator page
|
|
||||||
- [x] Level selector
|
|
||||||
- [x] Theme input
|
|
||||||
- [x] Segment count input
|
|
||||||
- [x] Generate button
|
|
||||||
- [x] Progress indicator
|
|
||||||
- [x] Success/failure messages
|
|
||||||
- [x] Add admin navigation
|
|
||||||
- [x] Add role check in frontend routing
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
- Complete admin UI
|
|
||||||
- All admin features accessible via web interface
|
|
||||||
- Responsive design for admin pages
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Milestones
|
|
||||||
|
|
||||||
| Milestone | Date | Status |
|
|
||||||
|-----------|------|--------|
|
|
||||||
| Authentication Enforcement | June 14, 2026 | ✅ Complete |
|
|
||||||
| Admin Role System | June 14, 2026 | ✅ Complete |
|
|
||||||
| Admin Backend Services | June 14, 2026 | ✅ Complete |
|
|
||||||
| Admin Frontend | June 14, 2026 | ✅ Complete |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Definition of Done
|
|
||||||
|
|
||||||
### General Criteria
|
|
||||||
- [x] All code follows Clean Architecture principles
|
|
||||||
- [x] All code compiles with 0 errors
|
|
||||||
- [x] All existing tests still pass
|
|
||||||
- [x] New code has corresponding unit tests (where applicable)
|
|
||||||
- [x] Code reviewed and approved
|
|
||||||
- [x] Documentation updated
|
|
||||||
- [x] Feature works in both development and Docker environments
|
|
||||||
|
|
||||||
### Feature-Specific Criteria
|
|
||||||
- [x] All learning content endpoints return 401 for unauthenticated requests
|
|
||||||
- [x] Users must register/login before accessing any content
|
|
||||||
- [x] Admin can generate stories for all levels
|
|
||||||
- [x] Admin can view all users
|
|
||||||
- [x] Admin can view individual user progress
|
|
||||||
- [x] Admin can generate activity reports
|
|
||||||
- [x] Admin can generate progress reports
|
|
||||||
- [x] Admin can generate completion reports
|
|
||||||
- [x] Admin UI is intuitive and functional
|
|
||||||
- [x] Frontend handles 401/403 errors gracefully
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing Strategy
|
|
||||||
|
|
||||||
### Backend Tests (MSTest + Moq)
|
|
||||||
| Test Type | Coverage | Tools |
|
|
||||||
|-----------|----------|-------|
|
|
||||||
| Unit Tests | All admin services | MSTest, Moq |
|
|
||||||
| Integration Tests | Admin endpoints | MSTest, TestServer |
|
|
||||||
| Authorization Tests | Role-based access | MSTest, Custom attributes |
|
|
||||||
|
|
||||||
#### AdminService Tests
|
|
||||||
- [ ] GenerateStoryForLevelAsync_ValidLevel_ReturnsSegments
|
|
||||||
- [ ] GenerateStoryForLevelAsync_InvalidLevel_ThrowsException
|
|
||||||
- [ ] GenerateStoryForLevelAsync_NoLessons_ThrowsException
|
|
||||||
- [ ] GetAllUsersAsync_ReturnsAllUsers
|
|
||||||
- [ ] GetUserProgressAsync_ValidUser_ReturnsProgress
|
|
||||||
- [ ] GetUserProgressAsync_InvalidUser_ThrowsException
|
|
||||||
- [ ] GenerateActivityReportAsync_ReturnsReport
|
|
||||||
- [ ] GenerateProgressReportAsync_ReturnsReport
|
|
||||||
- [ ] GenerateCompletionReportAsync_ReturnsReport
|
|
||||||
|
|
||||||
#### AdminController Tests
|
|
||||||
- [ ] GenerateStory_AdminRole_ReturnsSuccess
|
|
||||||
- [ ] GenerateStory_NonAdminRole_ReturnsForbidden
|
|
||||||
- [ ] GetAllUsers_AdminRole_ReturnsUsers
|
|
||||||
- [ ] GetAllUsers_NonAdminRole_ReturnsForbidden
|
|
||||||
- [ ] GetUserProgress_AdminRole_ReturnsProgress
|
|
||||||
- [ ] GetUserProgress_NonAdminRole_ReturnsForbidden
|
|
||||||
|
|
||||||
### Frontend Tests (Vitest)
|
|
||||||
| Test Type | Coverage | Tools |
|
|
||||||
|-----------|----------|-------|
|
|
||||||
| Unit Tests | Admin API client | Vitest |
|
|
||||||
| Component Tests | Admin pages/components | Vitest, @testing-library/react |
|
|
||||||
| Integration Tests | Auth flow + admin access | Vitest |
|
|
||||||
|
|
||||||
#### Admin API Client Tests
|
|
||||||
- [ ] generateStory_ValidRequest_ReturnsResponse
|
|
||||||
- [ ] generateStory_Unauthorized_ThrowsError
|
|
||||||
- [ ] getUsers_AdminRole_ReturnsUsers
|
|
||||||
- [ ] getUsers_NonAdminRole_ThrowsError
|
|
||||||
- [ ] getUserProgress_AdminRole_ReturnsProgress
|
|
||||||
- [ ] getReports_ReturnsReportData
|
|
||||||
|
|
||||||
#### Admin Component Tests
|
|
||||||
- [ ] AdminDashboard_RendersCorrectly
|
|
||||||
- [ ] AdminDashboard_DisplaysStatistics
|
|
||||||
- [ ] AdminUsers_ListDisplaysCorrectly
|
|
||||||
- [ ] AdminUsers_SearchWorks
|
|
||||||
- [ ] AdminUserDetail_DisplaysUserInfo
|
|
||||||
- [ ] AdminUserDetail_DisplaysProgress
|
|
||||||
- [ ] AdminReports_GeneratesCorrectly
|
|
||||||
- [ ] AdminStoryGenerator_CreatesStory
|
|
||||||
- [ ] ProtectedRoute_AdminRole_AllowsAccess
|
|
||||||
- [ ] ProtectedRoute_NonAdminRole_Redirects
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 Related Files
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
- [Clean Architecture Principles](../AGENTS.md)
|
|
||||||
- [Database Schema](../../GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs)
|
|
||||||
- [JWT Configuration](../../GermanApp/Infrastructure/Configuration/JwtConfig.cs)
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- [AuthController](../../GermanApp/Presentation/Controllers/AuthController.cs) - Existing
|
|
||||||
- [AuthService](../../GermanApp/Application/Services/AuthService.cs) - Existing
|
|
||||||
- [StoryController](../../GermanApp/Presentation/Controllers/StoryController.cs) - Needs [Authorize]
|
|
||||||
- [StoryService](../../GermanApp/Application/Services/StoryService.cs) - Existing
|
|
||||||
- [StoryGenerationService](../../GermanApp/Application/Services/StoryGenerationService.cs) - Existing
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- [App.tsx](../../german-app-frontend/src/App.tsx) - Needs auth routes
|
|
||||||
- [Auth API Client](../../german-app-frontend/src/lib/api/auth.ts) - May need to be created
|
|
||||||
|
|
||||||
### Database
|
|
||||||
- [User Entity](../../GermanApp/Domain/Entities/User.cs) - May need role field
|
|
||||||
- [StorySegment Entity](../../GermanApp/Domain/Entities/StorySegment.cs) - Existing
|
|
||||||
- [StoryProgress Entity](../../GermanApp/Domain/Entities/StoryProgress.cs) - Existing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Notes & Decisions
|
|
||||||
|
|
||||||
### Design Decisions
|
|
||||||
|
|
||||||
1. **Single Admin User**: Initially, there will be only one admin user (Lasse). Admin role will not be assignable via API to prevent privilege escalation attacks. Admin role will be set directly in the database.
|
|
||||||
|
|
||||||
2. **Mandatory Authentication**: ALL learning content requires authentication. No exceptions. This is a core business requirement. Users cannot access stories, lessons, or quizzes without first registering and logging in.
|
|
||||||
|
|
||||||
3. **Role-Based Authorization**: Using ASP.NET Core's built-in `[Authorize(Roles = "Admin")]` attribute for admin-only endpoints. Regular authenticated users can access learning content, but only admin users can access admin endpoints.
|
|
||||||
|
|
||||||
4. **Progress Tracking**: User progress (lesson completion, story unlock/completion) is tracked per user and is essential for the story unlocking feature to work correctly.
|
|
||||||
|
|
||||||
### Gotchas & Considerations
|
|
||||||
|
|
||||||
- **CORS with Credentials**: When using JWT tokens with credentials, CORS configuration must explicitly list allowed origins (cannot use wildcard `AllowAnyOrigin()` with `AllowCredentials()`).
|
|
||||||
- **Token Storage**: Frontend stores JWT tokens in localStorage. Consider HttpOnly cookies for enhanced security (future enhancement).
|
|
||||||
- **Admin Bootstrapping**: First admin user must be created via direct database manipulation or a special bootstrap endpoint (which should be removed after first use).
|
|
||||||
- **Role Migration**: Existing User table may need a `Role` column added via migration.
|
|
||||||
|
|
||||||
### Future Enhancements
|
|
||||||
|
|
||||||
- [ ] Admin user management (add/remove admins via UI)
|
|
||||||
- [ ] Admin action audit logging
|
|
||||||
- [ ] User export/import functionality
|
|
||||||
- [ ] Bulk story generation
|
|
||||||
- [ ] Scheduled report generation (email)
|
|
||||||
- [ ] User activity notifications
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: June 14, 2025
|
|
||||||
|
|
||||||
**Note**: Phase 4 (Admin Module) authentication enforcement was completed on June 14, 2025. All learning content endpoints now require JWT authentication. The LessonsEndpoints.cs minimal API endpoints were updated to include `.RequireAuthorization()` on all GET endpoints, ensuring that all lesson-related API calls require authentication.
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Feature: Story Integration
|
# Feature: Story Integration
|
||||||
|
|
||||||
> **Status**: 🚀 In Progress
|
> **Status**: 🚀 In Progress
|
||||||
> **📊 Current Progress**: Phase 1-6 ✅ Complete (Database & Models, Backend Services, Unit Tests, AI Integration, Audio Generation + Migration, Frontend Integration), Phase 7 Next (User Progress Tracking)
|
> **📊 Current Progress**: Phase 1-4 ✅ Complete (Database & Models, Backend Services, Unit Tests, AI Integration), Phase 5 Next (Audio Generation)
|
||||||
> **Priority**: High
|
> **Priority**: High
|
||||||
> **Complexity**: High
|
> **Complexity**: High
|
||||||
> **Estimate**: 8-12 hours
|
> **Estimate**: 8-12 hours
|
||||||
|
|
@ -139,7 +139,7 @@ Order: 1
|
||||||
- [x] Add DbSets to AppDbContext (StorySegments, StoryProgress)
|
- [x] Add DbSets to AppDbContext (StorySegments, StoryProgress)
|
||||||
- [x] Add entity configurations to AppDbContext
|
- [x] Add entity configurations to AppDbContext
|
||||||
- [x] Add navigation properties to Level, Lesson, and User entities
|
- [x] Add navigation properties to Level, Lesson, and User entities
|
||||||
- [x] Create and apply migration for StorySegments and StoryProgress tables
|
- [ ] Create and apply migration for StorySegments and StoryProgress tables
|
||||||
|
|
||||||
### Phase 2: Backend Services (2-3 hours)
|
### Phase 2: Backend Services (2-3 hours)
|
||||||
- [x] Create StoryService with CRUD operations (Application/Services/StoryService.cs)
|
- [x] Create StoryService with CRUD operations (Application/Services/StoryService.cs)
|
||||||
|
|
@ -165,27 +165,23 @@ Order: 1
|
||||||
- [x] Handle AI API errors gracefully (AiServiceException with error codes)
|
- [x] Handle AI API errors gracefully (AiServiceException with error codes)
|
||||||
- [x] Add retry logic for failed generations (ExecuteWithRetryAsync with exponential backoff)
|
- [x] Add retry logic for failed generations (ExecuteWithRetryAsync with exponential backoff)
|
||||||
|
|
||||||
### Phase 5: Audio Generation (2 hours) - COMPLETE
|
### Phase 5: Audio Generation (2 hours)
|
||||||
- [x] Integrate with Coqui TTS service (ITtsService, TtsService with CoquiConfig)
|
- [ ] Integrate with Coqui TTS service
|
||||||
- [x] Generate audio for each story segment (GenerateAudioAsync in StoryGenerationService)
|
- [ ] Generate audio for each story segment
|
||||||
- [x] Store audio files with consistent naming (wwwroot/audio/story/level{id}-segment{order}.wav)
|
- [ ] Store audio files with consistent naming
|
||||||
- [x] Update StorySegment.AudioUrl after generation (segment.UpdateAudioUrl)
|
- [ ] Update StorySegment.AudioUrl after generation
|
||||||
- [x] Add audio file serving endpoint (UseStaticFiles + GET segments/{id}/audio)
|
- [ ] Add audio file serving endpoint
|
||||||
|
|
||||||
### Phase 6: Frontend Integration (2-3 hours) - COMPLETE
|
### Phase 5: Frontend Integration (2-3 hours)
|
||||||
- [x] Create TypeScript types for Story DTOs (src/types/api/story.ts)
|
- [ ] Create StoryPage component
|
||||||
- [x] Create API client for story endpoints (src/lib/api/story.ts)
|
- [ ] Create StoryTab component
|
||||||
- [x] Create StoryPage component (src/pages/StoryPage.tsx)
|
- [ ] Create StorySegment component
|
||||||
- [x] Create StoryTab component (src/components/features/story/StoryTab.tsx)
|
- [ ] Create StoryPlayer component with audio
|
||||||
- [x] Create StorySegment component (src/components/features/story/StorySegment.tsx)
|
- [ ] Implement word click-to-translate functionality
|
||||||
- [x] Create StoryPlayer component with audio (src/components/features/story/StoryPlayer.tsx)
|
- [ ] Add story progress tracking
|
||||||
- [x] Implement word click-to-translate functionality (with built-in dictionary)
|
- [ ] Integrate with LessonPage
|
||||||
- [x] Add story progress tracking (via StoryTab and StoryPage)
|
|
||||||
- [x] Add routing for story pages (react-router-dom)
|
|
||||||
- [x] Add CSS styling for all components (src/index.css)
|
|
||||||
- [x] Update App.tsx with navigation and routing
|
|
||||||
|
|
||||||
### Phase 7: User Progress (1-2 hours)
|
### Phase 6: User Progress (1-2 hours)
|
||||||
- [ ] Track which story segments user has unlocked
|
- [ ] Track which story segments user has unlocked
|
||||||
- [ ] Update unlock status when lesson is completed
|
- [ ] Update unlock status when lesson is completed
|
||||||
- [ ] Display locked segments as "coming soon"
|
- [ ] Display locked segments as "coming soon"
|
||||||
|
|
@ -198,8 +194,8 @@ Order: 1
|
||||||
| Backend Services | June 13, 2025 | ✅ |
|
| Backend Services | June 13, 2025 | ✅ |
|
||||||
| Unit Tests | June 13, 2025 | ✅ |
|
| Unit Tests | June 13, 2025 | ✅ |
|
||||||
| AI Integration | June 13, 2025 | ✅ |
|
| AI Integration | June 13, 2025 | ✅ |
|
||||||
| Audio Generation & Migration | June 13, 2025 | ✅ |
|
| Audio Generation | - | ⏳ |
|
||||||
| Frontend Integration | June 13, 2025 | ✅ |
|
| Frontend Integration | - | ⏳ |
|
||||||
| User Progress | - | ⏳ |
|
| User Progress | - | ⏳ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -421,7 +417,6 @@ Make the story engaging and suitable for adult learners.
|
||||||
| June 13, 2025 | Phase 1-2 Complete | Database & Models, Backend Services implemented |
|
| June 13, 2025 | Phase 1-2 Complete | Database & Models, Backend Services implemented |
|
||||||
| June 13, 2025 | Phase 3 Complete | All unit tests written and passing (324 tests) |
|
| June 13, 2025 | Phase 3 Complete | All unit tests written and passing (324 tests) |
|
||||||
| June 13, 2025 | Phase 4 Complete | Enhanced prompts with CEFR-level-specific requirements for story generation |
|
| June 13, 2025 | Phase 4 Complete | Enhanced prompts with CEFR-level-specific requirements for story generation |
|
||||||
| June 13, 2025 | Phase 5 Complete | Audio generation with Coqui TTS, static file serving, and endpoints |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# Development environment variables
|
|
||||||
VITE_API_URL=http://localhost:5000
|
|
||||||
|
|
@ -12,18 +12,8 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
# API proxy to backend (when running in Docker Compose)
|
# API proxy to backend (when running in Docker Compose)
|
||||||
# Note: backend expects /api/ prefix, so we proxy /api/ -> http://backend:8080/api/
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8080/api/;
|
proxy_pass http://backend:8080;
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Audio files proxy to backend (served from wwwroot/audio/)
|
|
||||||
location /audio/ {
|
|
||||||
proxy_pass http://backend:8080/audio/;
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
|
||||||
60
german-app-frontend/package-lock.json
generated
60
german-app-frontend/package-lock.json
generated
|
|
@ -9,8 +9,7 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6"
|
||||||
"react-router-dom": "^7.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|
@ -1325,19 +1324,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -2411,44 +2397,6 @@
|
||||||
"react": "^19.2.6"
|
"react": "^19.2.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
|
||||||
"version": "7.17.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
|
|
||||||
"integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cookie": "^1.0.1",
|
|
||||||
"set-cookie-parser": "^2.6.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=18",
|
|
||||||
"react-dom": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-router-dom": {
|
|
||||||
"version": "7.17.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz",
|
|
||||||
"integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"react-router": "7.17.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=18",
|
|
||||||
"react-dom": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
|
||||||
|
|
@ -2499,12 +2447,6 @@
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/set-cookie-parser": {
|
|
||||||
"version": "2.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
|
||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6"
|
||||||
"react-router-dom": "^7.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|
|
||||||
|
|
@ -1,108 +1,8 @@
|
||||||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
|
||||||
import { AuthProvider } from '@/stores/authStore';
|
|
||||||
import { ProtectedRoute, AdminRoute } from '@/components/features/auth/ProtectedRoute';
|
|
||||||
import { HomePage } from '@/pages/HomePage';
|
|
||||||
import { LandingPage } from '@/pages/LandingPage';
|
|
||||||
import { LoginPage } from '@/pages/LoginPage';
|
|
||||||
import { RegisterPage } from '@/pages/RegisterPage';
|
|
||||||
import { StoryPage } from '@/pages/StoryPage';
|
|
||||||
import { AdminDashboardPage } from '@/pages/AdminDashboardPage';
|
|
||||||
import { AdminUsersPage } from '@/pages/AdminUsersPage';
|
|
||||||
import { AdminReportsPage } from '@/pages/AdminReportsPage';
|
|
||||||
import { AdminStoryGeneratorPage } from '@/pages/AdminStoryGeneratorPage';
|
|
||||||
import { AdminUserDetailPage } from '@/pages/AdminUserDetailPage';
|
|
||||||
import { AdminLayout } from '@/components/features/admin/AdminLayout';
|
|
||||||
import './index.css';
|
|
||||||
|
|
||||||
// Re-export for easier imports
|
|
||||||
export { AuthProvider } from '@/stores/authStore';
|
|
||||||
|
|
||||||
function NotFoundPage() {
|
|
||||||
return (
|
|
||||||
<div className="not-found-page">
|
|
||||||
<h1>404</h1>
|
|
||||||
<p>Page not found</p>
|
|
||||||
<Link to="/">Go to Home</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layout component with header and footer
|
|
||||||
function AppLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="app-container">
|
|
||||||
<header className="app-header">
|
|
||||||
<Link to="/" className="app-title">
|
|
||||||
<h1>DeutschLernen</h1>
|
|
||||||
</Link>
|
|
||||||
<nav className="app-nav">
|
|
||||||
<Link to="/">Home</Link>
|
|
||||||
<Link to="/story/1">Stories</Link>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="app-main">{children}</main>
|
|
||||||
|
|
||||||
<footer className="app-footer">
|
|
||||||
<p>© {new Date().getFullYear()} DeutschLernen</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<div>
|
||||||
<Router>
|
<h1>DeutschLernen</h1>
|
||||||
<Routes>
|
<p>German Learning Application</p>
|
||||||
{/* Public routes (no authentication required) */}
|
</div>
|
||||||
<Route path="/landing" element={<LandingPage />} />
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
|
||||||
|
|
||||||
{/* Protected routes (require authentication) */}
|
|
||||||
<Route
|
|
||||||
path="/"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AppLayout>
|
|
||||||
<HomePage />
|
|
||||||
</AppLayout>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/story/:levelId"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AppLayout>
|
|
||||||
<StoryPage />
|
|
||||||
</AppLayout>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Admin routes (require authentication + admin role) */}
|
|
||||||
<Route
|
|
||||||
path="/admin"
|
|
||||||
element={
|
|
||||||
<AdminRoute>
|
|
||||||
<AdminLayout />
|
|
||||||
</AdminRoute>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route index element={<AdminDashboardPage />} />
|
|
||||||
<Route path="dashboard" element={<AdminDashboardPage />} />
|
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
|
||||||
<Route path="users/:id" element={<AdminUserDetailPage />} />
|
|
||||||
<Route path="reports" element={<AdminReportsPage />} />
|
|
||||||
<Route path="stories" element={<AdminStoryGeneratorPage />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Catch-all route */}
|
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
|
||||||
</Routes>
|
|
||||||
</Router>
|
|
||||||
</AuthProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
/**
|
|
||||||
* Admin Layout component
|
|
||||||
* Provides consistent layout for all admin pages
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
|
||||||
import { useAuth } from '@/stores/authStore';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
export function AdminLayout({ children }: { children?: ReactNode }) {
|
|
||||||
const { user, logout } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ to: '/admin', label: 'Dashboard' },
|
|
||||||
{ to: '/admin/users', label: 'Users' },
|
|
||||||
{ to: '/admin/reports', label: 'Reports' },
|
|
||||||
{ to: '/admin/stories', label: 'Story Generator' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-layout">
|
|
||||||
<header className="admin-header">
|
|
||||||
<div className="admin-header-content">
|
|
||||||
<div className="admin-logo">
|
|
||||||
<h1>DeutschLernen Admin</h1>
|
|
||||||
</div>
|
|
||||||
<nav className="admin-nav">
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<NavLink
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`admin-nav-link ${isActive ? 'active' : ''}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<div className="admin-user-info">
|
|
||||||
<span className="admin-username">
|
|
||||||
Hello, {user?.username} ({user?.role})
|
|
||||||
</span>
|
|
||||||
<button onClick={() => navigate('/')} className="btn btn-secondary admin-btn">
|
|
||||||
Back to App
|
|
||||||
</button>
|
|
||||||
<button onClick={() => logout()} className="btn btn-secondary admin-btn">
|
|
||||||
Sign Out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="admin-main">
|
|
||||||
{children || <Outlet />}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer className="admin-footer">
|
|
||||||
<p>© {new Date().getFullYear()} DeutschLernen Admin Panel</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
/**
|
|
||||||
* Admin component exports
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { AdminLayout } from './AdminLayout';
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
/**
|
|
||||||
* ProtectedRoute component for React Router
|
|
||||||
* Redirects unauthenticated users to login page
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
|
||||||
import { useAuth } from '@/stores/authStore';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
|
||||||
children: ReactNode;
|
|
||||||
redirectTo?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProtectedRoute({
|
|
||||||
children,
|
|
||||||
redirectTo = '/login'
|
|
||||||
}: ProtectedRouteProps) {
|
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
// Show loading state while checking authentication
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="loading-overlay">
|
|
||||||
<div className="spinner"></div>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return <Navigate to={redirectTo} state={{ from: location }} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protected route for admin-only content
|
|
||||||
export function AdminRoute({ children }: { children: ReactNode }) {
|
|
||||||
const { isAuthenticated, user, isLoading } = useAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
// Show loading state while checking authentication
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="loading-overlay">
|
|
||||||
<div className="spinner"></div>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to home if authenticated but not admin
|
|
||||||
if (user?.role !== 'Admin') {
|
|
||||||
return <Navigate to="/" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,252 +0,0 @@
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
||||||
import type { StorySegmentDto } from '../../../types/api/story';
|
|
||||||
|
|
||||||
interface StoryPlayerProps {
|
|
||||||
segment: StorySegmentDto;
|
|
||||||
autoPlay?: boolean;
|
|
||||||
onPlay?: () => void;
|
|
||||||
onPause?: () => void;
|
|
||||||
onEnded?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* StoryPlayer component - Handles audio playback for story segments.
|
|
||||||
* Uses the HTML5 audio element to play audio files served from the backend.
|
|
||||||
*/
|
|
||||||
export function StoryPlayer({
|
|
||||||
segment,
|
|
||||||
autoPlay = false,
|
|
||||||
onPlay,
|
|
||||||
onPause,
|
|
||||||
onEnded,
|
|
||||||
}: StoryPlayerProps) {
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
const [volume, setVolume] = useState(0.8);
|
|
||||||
|
|
||||||
// Construct audio URL from segment audioUrl
|
|
||||||
// Backend serves files from wwwroot, so URLs are like /audio/story/level1-segment1.wav
|
|
||||||
// In Docker, use relative path (nginx proxies to backend)
|
|
||||||
const baseUrl = import.meta.env.PROD
|
|
||||||
? '' // Production: relative path, nginx handles it
|
|
||||||
: (import.meta.env.VITE_API_URL || 'http://localhost:5000');
|
|
||||||
|
|
||||||
const audioUrl = segment.audioUrl
|
|
||||||
? `${baseUrl}${segment.audioUrl}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Load audio metadata when component mounts or segment changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (audioUrl && audioRef.current) {
|
|
||||||
const audio = audioRef.current;
|
|
||||||
|
|
||||||
const handleLoadedMetadata = () => {
|
|
||||||
setDuration(audio.duration);
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleError = () => {
|
|
||||||
setError('Failed to load audio file');
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
|
||||||
audio.addEventListener('error', handleError);
|
|
||||||
|
|
||||||
// Load the audio source
|
|
||||||
audio.src = audioUrl;
|
|
||||||
audio.load();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
|
||||||
audio.removeEventListener('error', handleError);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [audioUrl]);
|
|
||||||
|
|
||||||
// Handle audio events
|
|
||||||
useEffect(() => {
|
|
||||||
if (!audioRef.current) return;
|
|
||||||
|
|
||||||
const audio = audioRef.current;
|
|
||||||
|
|
||||||
const handlePlay = () => {
|
|
||||||
setIsPlaying(true);
|
|
||||||
onPlay?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePause = () => {
|
|
||||||
setIsPlaying(false);
|
|
||||||
onPause?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnded = () => {
|
|
||||||
setIsPlaying(false);
|
|
||||||
setCurrentTime(0);
|
|
||||||
onEnded?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTimeUpdate = () => {
|
|
||||||
setCurrentTime(audio.currentTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
audio.addEventListener('play', handlePlay);
|
|
||||||
audio.addEventListener('pause', handlePause);
|
|
||||||
audio.addEventListener('ended', handleEnded);
|
|
||||||
audio.addEventListener('timeupdate', handleTimeUpdate);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
audio.removeEventListener('play', handlePlay);
|
|
||||||
audio.removeEventListener('pause', handlePause);
|
|
||||||
audio.removeEventListener('ended', handleEnded);
|
|
||||||
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
|
||||||
};
|
|
||||||
}, [onPlay, onPause, onEnded]);
|
|
||||||
|
|
||||||
// Auto-play effect
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoPlay && audioRef.current && !isPlaying && !isLoading) {
|
|
||||||
audioRef.current.play().catch(() => setError('Audio playback failed'));
|
|
||||||
}
|
|
||||||
}, [autoPlay, isPlaying, isLoading]);
|
|
||||||
|
|
||||||
const togglePlayPause = useCallback(() => {
|
|
||||||
if (!audioRef.current) return;
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
} else {
|
|
||||||
if (audioUrl) {
|
|
||||||
audioRef.current.play().catch(() => setError('Audio playback failed'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isPlaying, audioUrl]);
|
|
||||||
|
|
||||||
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (!audioRef.current) return;
|
|
||||||
const seekTime = parseFloat(e.target.value);
|
|
||||||
audioRef.current.currentTime = seekTime;
|
|
||||||
setCurrentTime(seekTime);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const newVolume = parseFloat(e.target.value);
|
|
||||||
setVolume(newVolume);
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.volume = newVolume;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePrevious = useCallback(() => {
|
|
||||||
// Previous segment logic would be handled by parent
|
|
||||||
onEnded?.();
|
|
||||||
}, [onEnded]);
|
|
||||||
|
|
||||||
const handleNext = useCallback(() => {
|
|
||||||
// Next segment logic would be handled by parent
|
|
||||||
onEnded?.();
|
|
||||||
}, [onEnded]);
|
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!audioUrl) {
|
|
||||||
return (
|
|
||||||
<div className="story-player" data-testid="story-player">
|
|
||||||
<div className="no-audio-notice">
|
|
||||||
<p>🎧 No audio available for this segment</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="story-player" data-testid="story-player">
|
|
||||||
<div className="player-error">
|
|
||||||
<p>⚠️ {error}</p>
|
|
||||||
<button onClick={() => setError(null)}>Retry</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="story-player" data-testid="story-player">
|
|
||||||
<div className="player-controls">
|
|
||||||
<button
|
|
||||||
className="player-btn"
|
|
||||||
onClick={handlePrevious}
|
|
||||||
title="Previous segment"
|
|
||||||
disabled={!segment.audioUrl}
|
|
||||||
>
|
|
||||||
⏮️
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="player-btn play-pause-btn"
|
|
||||||
onClick={togglePlayPause}
|
|
||||||
title={isPlaying ? 'Pause' : 'Play'}
|
|
||||||
disabled={isLoading}
|
|
||||||
data-testid="play-pause-btn"
|
|
||||||
>
|
|
||||||
{isLoading ? '⏳' : isPlaying ? '⏸️' : '▶️'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="player-btn"
|
|
||||||
onClick={handleNext}
|
|
||||||
title="Next segment"
|
|
||||||
disabled={!segment.audioUrl}
|
|
||||||
>
|
|
||||||
⏭️
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="player-progress">
|
|
||||||
<span className="player-time">{formatTime(currentTime)}</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max={duration || 0}
|
|
||||||
value={currentTime}
|
|
||||||
onChange={handleSeek}
|
|
||||||
className="seek-slider"
|
|
||||||
disabled={!duration}
|
|
||||||
data-testid="seek-slider"
|
|
||||||
/>
|
|
||||||
<span className="player-time">{formatTime(duration)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="player-volume">
|
|
||||||
<span className="volume-icon">🔊</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.01"
|
|
||||||
value={volume}
|
|
||||||
onChange={handleVolumeChange}
|
|
||||||
className="volume-slider"
|
|
||||||
data-testid="volume-slider"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hidden audio element */}
|
|
||||||
<audio
|
|
||||||
ref={audioRef}
|
|
||||||
preload="metadata"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StoryPlayer;
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import type { StorySegmentDto } from '../../../types/api/story';
|
|
||||||
import type { WordTranslation } from '../../../types/api/story';
|
|
||||||
|
|
||||||
// Simple German word dictionary for common words
|
|
||||||
// In production, this should come from an API
|
|
||||||
const GERMAN_WORD_DICTIONARY: Record<string, WordTranslation> = {
|
|
||||||
// Common words
|
|
||||||
der: { word: 'der', translation: 'the', partOfSpeech: 'article', gender: 'masculine' },
|
|
||||||
die: { word: 'die', translation: 'the', partOfSpeech: 'article', gender: 'feminine' },
|
|
||||||
das: { word: 'das', translation: 'the', partOfSpeech: 'article', gender: 'neuter' },
|
|
||||||
ein: { word: 'ein', translation: 'a/an', partOfSpeech: 'article', gender: 'masculine' },
|
|
||||||
eine: { word: 'eine', translation: 'a/an', partOfSpeech: 'article', gender: 'feminine' },
|
|
||||||
|
|
||||||
// Pronouns
|
|
||||||
ich: { word: 'ich', translation: 'I', partOfSpeech: 'pronoun' },
|
|
||||||
du: { word: 'du', translation: 'you (singular)', partOfSpeech: 'pronoun' },
|
|
||||||
er: { word: 'er', translation: 'he', partOfSpeech: 'pronoun' },
|
|
||||||
sie: { word: 'sie', translation: 'she/they', partOfSpeech: 'pronoun' },
|
|
||||||
es: { word: 'es', translation: 'it', partOfSpeech: 'pronoun' },
|
|
||||||
wir: { word: 'wir', translation: 'we', partOfSpeech: 'pronoun' },
|
|
||||||
ihr: { word: 'ihr', translation: 'you (plural)', partOfSpeech: 'pronoun' },
|
|
||||||
|
|
||||||
// Common verbs
|
|
||||||
sein: { word: 'sein', translation: 'to be', partOfSpeech: 'verb' },
|
|
||||||
haben: { word: 'haben', translation: 'to have', partOfSpeech: 'verb' },
|
|
||||||
werden: { word: 'werden', translation: 'to become', partOfSpeech: 'verb' },
|
|
||||||
gehen: { word: 'gehen', translation: 'to go', partOfSpeech: 'verb' },
|
|
||||||
kommen: { word: 'kommen', translation: 'to come', partOfSpeech: 'verb' },
|
|
||||||
machen: { word: 'machen', translation: 'to make/do', partOfSpeech: 'verb' },
|
|
||||||
|
|
||||||
// Common nouns
|
|
||||||
Mann: { word: 'Mann', translation: 'man', partOfSpeech: 'noun', gender: 'der' },
|
|
||||||
Frau: { word: 'Frau', translation: 'woman', partOfSpeech: 'noun', gender: 'die' },
|
|
||||||
Kind: { word: 'Kind', translation: 'child', partOfSpeech: 'noun', gender: 'das' },
|
|
||||||
Tag: { word: 'Tag', translation: 'day', partOfSpeech: 'noun', gender: 'der' },
|
|
||||||
Nacht: { word: 'Nacht', translation: 'night', partOfSpeech: 'noun', gender: 'die' },
|
|
||||||
Haus: { word: 'Haus', translation: 'house', partOfSpeech: 'noun', gender: 'das' },
|
|
||||||
|
|
||||||
// Adjectives
|
|
||||||
gut: { word: 'gut', translation: 'good', partOfSpeech: 'adjective' },
|
|
||||||
schön: { word: 'schön', translation: 'beautiful/nice', partOfSpeech: 'adjective' },
|
|
||||||
groß: { word: 'groß', translation: 'big/tall', partOfSpeech: 'adjective' },
|
|
||||||
klein: { word: 'klein', translation: 'small', partOfSpeech: 'adjective' },
|
|
||||||
|
|
||||||
// Prepositions
|
|
||||||
in: { word: 'in', translation: 'in', partOfSpeech: 'preposition' },
|
|
||||||
auf: { word: 'auf', translation: 'on', partOfSpeech: 'preposition' },
|
|
||||||
mit: { word: 'mit', translation: 'with', partOfSpeech: 'preposition' },
|
|
||||||
ohne: { word: 'ohne', translation: 'without', partOfSpeech: 'preposition' },
|
|
||||||
|
|
||||||
// Conjunctions
|
|
||||||
und: { word: 'und', translation: 'and', partOfSpeech: 'conjunction' },
|
|
||||||
oder: { word: 'oder', translation: 'or', partOfSpeech: 'conjunction' },
|
|
||||||
aber: { word: 'aber', translation: 'but', partOfSpeech: 'conjunction' },
|
|
||||||
|
|
||||||
// Time words
|
|
||||||
jetzt: { word: 'jetzt', translation: 'now', partOfSpeech: 'adverb' },
|
|
||||||
heute: { word: 'heute', translation: 'today', partOfSpeech: 'adverb' },
|
|
||||||
morgen: { word: 'morgen', translation: 'tomorrow', partOfSpeech: 'adverb' },
|
|
||||||
gestern: { word: 'gestern', translation: 'yesterday', partOfSpeech: 'adverb' },
|
|
||||||
};
|
|
||||||
|
|
||||||
interface StorySegmentProps {
|
|
||||||
segment: StorySegmentDto;
|
|
||||||
isUnlocked?: boolean;
|
|
||||||
isCompleted?: boolean;
|
|
||||||
showTranslation?: boolean;
|
|
||||||
onWordClick?: (word: string) => void;
|
|
||||||
onComplete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* StorySegment component - Displays a story segment with clickable words
|
|
||||||
* for translation and optional audio playback.
|
|
||||||
*/
|
|
||||||
export function StorySegment({
|
|
||||||
segment,
|
|
||||||
isUnlocked = true,
|
|
||||||
isCompleted = false,
|
|
||||||
showTranslation = true,
|
|
||||||
onWordClick,
|
|
||||||
onComplete,
|
|
||||||
}: StorySegmentProps) {
|
|
||||||
const [selectedWord, setSelectedWord] = useState<string | null>(null);
|
|
||||||
const [translation, setTranslation] = useState<WordTranslation | null>(null);
|
|
||||||
const [showTranslationTooltip, setShowTranslationTooltip] = useState(false);
|
|
||||||
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
|
|
||||||
|
|
||||||
const handleWordClick = useCallback((word: string, event: React.MouseEvent) => {
|
|
||||||
if (!showTranslation) return;
|
|
||||||
|
|
||||||
// Normalize word: lowercase, remove punctuation
|
|
||||||
const normalizedWord = word.toLowerCase().replace(/[^\wäöüß]/g, '');
|
|
||||||
|
|
||||||
// Look up in dictionary
|
|
||||||
const foundTranslation = GERMAN_WORD_DICTIONARY[normalizedWord];
|
|
||||||
if (foundTranslation) {
|
|
||||||
setSelectedWord(word);
|
|
||||||
setTranslation(foundTranslation);
|
|
||||||
setTooltipPosition({ x: event.clientX, y: event.clientY });
|
|
||||||
setShowTranslationTooltip(true);
|
|
||||||
|
|
||||||
// Call external handler if provided
|
|
||||||
onWordClick?.(word);
|
|
||||||
}
|
|
||||||
}, [showTranslation, onWordClick]);
|
|
||||||
|
|
||||||
const handleCloseTooltip = useCallback(() => {
|
|
||||||
setShowTranslationTooltip(false);
|
|
||||||
setSelectedWord(null);
|
|
||||||
setTranslation(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Parse content into words and preserve formatting
|
|
||||||
const renderContent = () => {
|
|
||||||
if (!isUnlocked) {
|
|
||||||
return (
|
|
||||||
<div className="locked-content">
|
|
||||||
<p>🔒 This story segment is locked. Complete the previous lessons to unlock it.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split content into words while preserving paragraphs and punctuation
|
|
||||||
const paragraphs = segment.content.split('\n').filter(p => p.trim().length > 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="story-content">
|
|
||||||
{paragraphs.map((paragraph, paraIndex) => (
|
|
||||||
<p key={paraIndex} className="story-paragraph">
|
|
||||||
{paragraph.split(/(\s+|[.,;!?])/).map((token, tokenIndex) => {
|
|
||||||
// Skip whitespace-only tokens for rendering (but keep them in the split)
|
|
||||||
if (/^\s+$/.test(token)) {
|
|
||||||
return <span key={tokenIndex} className="whitespace">{token}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle punctuation
|
|
||||||
if (/^[.,;!?]$/.test(token)) {
|
|
||||||
return <span key={tokenIndex} className="punctuation">{token}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// It's a word - make it clickable
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={tokenIndex}
|
|
||||||
className="story-word"
|
|
||||||
onClick={(e) => handleWordClick(token, e)}
|
|
||||||
style={{ cursor: showTranslation ? 'pointer' : 'default' }}
|
|
||||||
title={showTranslation ? 'Click to translate' : undefined}
|
|
||||||
>
|
|
||||||
{token}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="story-segment" data-testid="story-segment">
|
|
||||||
<div className="segment-header">
|
|
||||||
<h3 className="segment-title">{segment.title}</h3>
|
|
||||||
<span className="segment-theme">{segment.theme}</span>
|
|
||||||
<span className="segment-order">Segment {segment.order}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="segment-meta">
|
|
||||||
<span>⏱️ {segment.estimatedReadingMinutes} min read</span>
|
|
||||||
{isCompleted && <span className="completed-badge">✓ Completed</span>}
|
|
||||||
{segment.audioUrl && <span>🎧 Audio available</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="segment-body">
|
|
||||||
{renderContent()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isCompleted && isUnlocked && (
|
|
||||||
<button
|
|
||||||
className="mark-complete-btn"
|
|
||||||
onClick={onComplete}
|
|
||||||
data-testid="mark-complete-btn"
|
|
||||||
>
|
|
||||||
Mark as Read/Listen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Translation Tooltip */}
|
|
||||||
{showTranslationTooltip && translation && (
|
|
||||||
<div
|
|
||||||
className="translation-tooltip"
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
left: `${tooltipPosition.x}px`,
|
|
||||||
top: `${tooltipPosition.y - 60}px`,
|
|
||||||
zIndex: 1000,
|
|
||||||
backgroundColor: '#2d3748',
|
|
||||||
color: 'white',
|
|
||||||
padding: '10px 15px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '14px',
|
|
||||||
boxShadow: '0 2px 10px rgba(0,0,0,0.3)',
|
|
||||||
maxWidth: '300px',
|
|
||||||
}}
|
|
||||||
onMouseLeave={handleCloseTooltip}
|
|
||||||
data-testid="translation-tooltip"
|
|
||||||
>
|
|
||||||
<strong>{selectedWord}</strong>
|
|
||||||
<div style={{ marginTop: '5px' }}>
|
|
||||||
<em>{translation.partOfSpeech}</em>
|
|
||||||
{translation.gender && (
|
|
||||||
<span style={{ marginLeft: '10px', color: '#4fd1c7' }}>
|
|
||||||
({translation.gender})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: '5px' }}>
|
|
||||||
{translation.translation}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StorySegment;
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import type { StoryProgressDto, StorySegmentProgressDto } from '../../../types/api/story';
|
|
||||||
import { storyApi } from '../../../lib/api/story';
|
|
||||||
|
|
||||||
interface StoryTabProps {
|
|
||||||
levelId: number;
|
|
||||||
levelName: string;
|
|
||||||
onSegmentSelect: (segmentId: number) => void;
|
|
||||||
currentSegmentId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* StoryTab component - Displays a tabular view of story segments for a level.
|
|
||||||
* Shows progress (locked/unlocked/completed) and allows navigation between segments.
|
|
||||||
*/
|
|
||||||
export function StoryTab({
|
|
||||||
levelId,
|
|
||||||
levelName,
|
|
||||||
onSegmentSelect,
|
|
||||||
currentSegmentId,
|
|
||||||
}: StoryTabProps) {
|
|
||||||
const [progress, setProgress] = useState<StoryProgressDto | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Fetch user progress for this level
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchProgress = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const data = await storyApi.getUserProgress(levelId);
|
|
||||||
setProgress(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to load story progress');
|
|
||||||
console.error('Error fetching story progress:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchProgress();
|
|
||||||
}, [levelId]);
|
|
||||||
|
|
||||||
const handleSegmentClick = useCallback((segment: StorySegmentProgressDto) => {
|
|
||||||
if (segment.isUnlocked) {
|
|
||||||
onSegmentSelect(segment.segmentId);
|
|
||||||
}
|
|
||||||
}, [onSegmentSelect]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="story-tab" data-testid="story-tab">
|
|
||||||
<div className="loading-indicator">
|
|
||||||
<p>Loading story progress...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="story-tab" data-testid="story-tab">
|
|
||||||
<div className="error-message">
|
|
||||||
<p>⚠️ {error}</p>
|
|
||||||
<button onClick={() => setError(null)}>Retry</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!progress) {
|
|
||||||
return (
|
|
||||||
<div className="story-tab" data-testid="story-tab">
|
|
||||||
<div className="no-story-notice">
|
|
||||||
<p>No story available for {levelName}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="story-tab" data-testid="story-tab">
|
|
||||||
<div className="tab-header">
|
|
||||||
<h3>{levelName} Story</h3>
|
|
||||||
<div className="progress-summary">
|
|
||||||
<span className="progress-count">
|
|
||||||
{progress.unlockedSegments} of {progress.totalSegments} unlocked
|
|
||||||
</span>
|
|
||||||
<div className="progress-bar">
|
|
||||||
<div
|
|
||||||
className="progress-fill"
|
|
||||||
style={{
|
|
||||||
width: `${(progress.unlockedSegments / progress.totalSegments) * 100}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="segments-grid">
|
|
||||||
{progress.segments.map((segment) => {
|
|
||||||
const isCurrent = segment.segmentId === currentSegmentId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={segment.segmentId}
|
|
||||||
className={`segment-card ${
|
|
||||||
isCurrent ? 'current' : ''
|
|
||||||
} ${
|
|
||||||
segment.isCompleted ? 'completed' : ''
|
|
||||||
} ${
|
|
||||||
!segment.isUnlocked ? 'locked' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => handleSegmentClick(segment)}
|
|
||||||
disabled={!segment.isUnlocked}
|
|
||||||
title={
|
|
||||||
!segment.isUnlocked
|
|
||||||
? 'Complete previous lessons to unlock'
|
|
||||||
: segment.isCompleted
|
|
||||||
? 'Completed'
|
|
||||||
: `Segment ${segment.order}: ${segment.title}`
|
|
||||||
}
|
|
||||||
data-testid={`segment-card-${segment.segmentId}`}
|
|
||||||
>
|
|
||||||
<div className="segment-card-content">
|
|
||||||
<span className="segment-order">{segment.order}</span>
|
|
||||||
<span className="segment-title">{segment.title}</span>
|
|
||||||
<span className="segment-status">
|
|
||||||
{segment.isCompleted ? '✓' : !segment.isUnlocked ? '🔒' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StoryTab;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
// Story feature components
|
|
||||||
export { default as StorySegment } from './StorySegment';
|
|
||||||
export { default as StoryPlayer } from './StoryPlayer';
|
|
||||||
export { default as StoryTab } from './StoryTab';
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,416 +0,0 @@
|
||||||
/**
|
|
||||||
* Admin API client for the DeutschLernen frontend
|
|
||||||
* Handles admin requests to the backend
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AdminUserListItem,
|
|
||||||
AdminUserDetails,
|
|
||||||
DashboardStats,
|
|
||||||
UserProgressReport,
|
|
||||||
LessonCompletion,
|
|
||||||
UserQuizResult,
|
|
||||||
FullUserReport,
|
|
||||||
UpdateUserRoleRequest,
|
|
||||||
DeleteUserResponse,
|
|
||||||
CsvExportResponse,
|
|
||||||
LevelWithStories,
|
|
||||||
StorySegment,
|
|
||||||
StoryGenerationRequest,
|
|
||||||
StoryGenerationResponse,
|
|
||||||
CreateStorySegmentRequest,
|
|
||||||
UpdateStorySegmentRequest,
|
|
||||||
} from '@/types/api/admin';
|
|
||||||
import { getAuthHeader } from '@/stores/authStore';
|
|
||||||
|
|
||||||
// Base API URL - uses /api prefix for Docker proxy
|
|
||||||
const BASE_URL = import.meta.env.PROD
|
|
||||||
? '/api'
|
|
||||||
: import.meta.env.VITE_API_URL || 'http://localhost:5000/api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all users (Admin only)
|
|
||||||
*/
|
|
||||||
export async function getAllUsers(token: string): Promise<AdminUserListItem[]> {
|
|
||||||
const response = await fetch(`${BASE_URL}/admin/users`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to get users');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a specific user by ID (Admin only)
|
|
||||||
*/
|
|
||||||
export async function getUserById(token: string, userId: number): Promise<AdminUserDetails> {
|
|
||||||
const response = await fetch(`${BASE_URL}/admin/users/${userId}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to get user');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a user's role (Admin only)
|
|
||||||
*/
|
|
||||||
export async function updateUserRole(
|
|
||||||
token: string,
|
|
||||||
userId: number,
|
|
||||||
role: string
|
|
||||||
): Promise<AdminUserDetails> {
|
|
||||||
const response = await fetch(`${BASE_URL}/admin/users/${userId}/role`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ role } as UpdateUserRoleRequest),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to update user role');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a user (Admin only)
|
|
||||||
*/
|
|
||||||
export async function deleteUser(token: string, userId: number): Promise<DeleteUserResponse> {
|
|
||||||
const response = await fetch(`${BASE_URL}/admin/users/${userId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to delete user');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get dashboard statistics (Admin only)
|
|
||||||
*/
|
|
||||||
export async function getDashboardStats(token: string): Promise<DashboardStats> {
|
|
||||||
const response = await fetch(`${BASE_URL}/admin/dashboard/stats`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to get dashboard stats');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user progress report (Admin only)
|
|
||||||
*/
|
|
||||||
export async function getUserReport(token: string, userId: number): Promise<UserProgressReport> {
|
|
||||||
const response = await fetch(`${BASE_URL}/admin/reports/users/${userId}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to get user report');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all user reports (Admin only)
|
|
||||||
*/
|
|
||||||
export async function getAllUserReports(token: string): Promise<UserProgressReport[]> {
|
|
||||||
const response = await fetch(`${BASE_URL}/admin/reports/all-users`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to get all user reports');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user lesson progress (Admin only)
|
|
||||||
*/
|
|
||||||
export async function getUserLessonProgress(
|
|
||||||
token: string,
|
|
||||||
userId: number
|
|
||||||
): Promise<LessonCompletion[]> {
|
|
||||||
const response = await fetch(`${BASE_URL}/admin/reports/users/${userId}/lessons`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to get user lesson progress');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user quiz results (Admin only)
|
|
||||||
*/
|
|
||||||
export async function getUserQuizResults(
|
|
||||||
token: string,
|
|
||||||
userId: number
|
|
||||||
): Promise<UserQuizResult[]> {
|
|
||||||
const response = await fetch(`${BASE_URL}/admin/reports/users/${userId}/quizzes`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to get user quiz results');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get full user report (Admin only)
|
|
||||||
*/
|
|
||||||
export async function getFullUserReport(token: string, userId: number): Promise<FullUserReport> {
|
|
||||||
const response = await fetch(`${BASE_URL}/admin/reports/users/${userId}/full`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to get full user report');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export all user reports as CSV (Admin only)
|
|
||||||
*/
|
|
||||||
export async function exportReportsAsCsv(token: string): Promise<CsvExportResponse> {
|
|
||||||
const response = await fetch(`${BASE_URL}/admin/reports/export/csv`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to export reports');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// STORY GENERATION ENDPOINTS
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all levels with story information
|
|
||||||
*/
|
|
||||||
export async function getLevelsWithStories(token: string): Promise<LevelWithStories[]> {
|
|
||||||
const response = await fetch(`${BASE_URL}/story/levels`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to get levels with stories');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all story segments for a specific level
|
|
||||||
*/
|
|
||||||
export async function getStorySegmentsByLevel(token: string, levelId: number): Promise<StorySegment[]> {
|
|
||||||
const response = await fetch(`${BASE_URL}/story/levels/${levelId}/segments`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to get story segments');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a story for a level using AI
|
|
||||||
*/
|
|
||||||
export async function generateStory(
|
|
||||||
token: string,
|
|
||||||
levelId: number,
|
|
||||||
request: StoryGenerationRequest
|
|
||||||
): Promise<StoryGenerationResponse> {
|
|
||||||
const response = await fetch(`${BASE_URL}/story/levels/${levelId}/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
body: JSON.stringify(request),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to generate story');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new story segment manually
|
|
||||||
*/
|
|
||||||
export async function createStorySegment(
|
|
||||||
token: string,
|
|
||||||
segment: CreateStorySegmentRequest
|
|
||||||
): Promise<StorySegment> {
|
|
||||||
const response = await fetch(`${BASE_URL}/story/segments`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
body: JSON.stringify(segment),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to create story segment');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing story segment
|
|
||||||
*/
|
|
||||||
export async function updateStorySegment(
|
|
||||||
token: string,
|
|
||||||
segmentId: number,
|
|
||||||
segment: UpdateStorySegmentRequest
|
|
||||||
): Promise<StorySegment> {
|
|
||||||
const response = await fetch(`${BASE_URL}/story/segments/${segmentId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
body: JSON.stringify(segment),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to update story segment');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a story segment
|
|
||||||
*/
|
|
||||||
export async function deleteStorySegment(token: string, segmentId: number): Promise<void> {
|
|
||||||
const response = await fetch(`${BASE_URL}/story/segments/${segmentId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to delete story segment');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate audio for a story segment
|
|
||||||
*/
|
|
||||||
export async function generateSegmentAudio(token: string, segmentId: number): Promise<StorySegment> {
|
|
||||||
const response = await fetch(`${BASE_URL}/story/segments/${segmentId}/audio`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getAuthHeader(token),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to generate segment audio');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
/**
|
|
||||||
* Auth API client for the DeutschLernen frontend
|
|
||||||
* Handles authentication requests to the backend
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
RegisterRequest,
|
|
||||||
LoginRequest,
|
|
||||||
AuthResponse,
|
|
||||||
CurrentUser,
|
|
||||||
RefreshTokenRequest,
|
|
||||||
RefreshTokenResponse,
|
|
||||||
} from '@/types/api/auth';
|
|
||||||
|
|
||||||
// Base API URL - uses /api prefix for Docker proxy
|
|
||||||
const BASE_URL = import.meta.env.PROD
|
|
||||||
? '/api'
|
|
||||||
: import.meta.env.VITE_API_URL || 'http://localhost:5000/api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a new user
|
|
||||||
*/
|
|
||||||
export async function register(request: RegisterRequest): Promise<AuthResponse> {
|
|
||||||
const response = await fetch(`${BASE_URL}/auth/register`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(request),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Registration failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Login an existing user
|
|
||||||
*/
|
|
||||||
export async function login(request: LoginRequest): Promise<AuthResponse> {
|
|
||||||
const response = await fetch(`${BASE_URL}/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(request),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Login failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current authenticated user info
|
|
||||||
*/
|
|
||||||
export async function getCurrentUser(token: string): Promise<CurrentUser> {
|
|
||||||
const response = await fetch(`${BASE_URL}/auth/me`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to get current user');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh access token using refresh token
|
|
||||||
*/
|
|
||||||
export async function refreshToken(request: RefreshTokenRequest): Promise<RefreshTokenResponse> {
|
|
||||||
const response = await fetch(`${BASE_URL}/auth/refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(request.refreshToken),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Token refresh failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke refresh token
|
|
||||||
*/
|
|
||||||
export async function revokeRefreshToken(refreshToken: string, accessToken: string): Promise<void> {
|
|
||||||
const response = await fetch(`${BASE_URL}/auth/revoke-refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(refreshToken),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to revoke refresh token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an admin user exists (for bootstrap)
|
|
||||||
*/
|
|
||||||
export async function checkAdminExists(): Promise<boolean> {
|
|
||||||
const response = await fetch(`${BASE_URL}/bootstrap/admin-exists`, {
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create first admin user (bootstrap endpoint)
|
|
||||||
*/
|
|
||||||
export async function createAdminUser(request: RegisterRequest): Promise<AuthResponse> {
|
|
||||||
const response = await fetch(`${BASE_URL}/bootstrap/admin`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(request),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(error || 'Failed to create admin user');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
@ -1,253 +0,0 @@
|
||||||
import type {
|
|
||||||
StorySegmentDto,
|
|
||||||
CreateStorySegmentDto,
|
|
||||||
UpdateStorySegmentDto,
|
|
||||||
StoryGenerationRequestDto,
|
|
||||||
StoryGenerationResponseDto,
|
|
||||||
StoryProgressDto,
|
|
||||||
LevelWithStoriesDto,
|
|
||||||
LessonWithStoryStatusDto,
|
|
||||||
StoryAudioResponse,
|
|
||||||
} from '../../types/api/story';
|
|
||||||
|
|
||||||
// Base API URL - should match your backend configuration
|
|
||||||
// In Docker (production), use relative path since nginx proxies /api to backend
|
|
||||||
// In development, use the VITE_API_URL or default to localhost:5000
|
|
||||||
const API_BASE_URL = import.meta.env.PROD
|
|
||||||
? '/api' // Production (Docker): nginx proxies /api to backend:8080
|
|
||||||
: (import.meta.env.VITE_API_URL || 'http://localhost:5000/api');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches with automatic token handling for authenticated requests
|
|
||||||
*/
|
|
||||||
async function authenticatedFetch(
|
|
||||||
input: RequestInfo | URL,
|
|
||||||
init?: RequestInit
|
|
||||||
): Promise<Response> {
|
|
||||||
const token = localStorage.getItem('accessToken');
|
|
||||||
|
|
||||||
const headers = new Headers(init?.headers);
|
|
||||||
if (token) {
|
|
||||||
headers.append('Authorization', `Bearer ${token}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't include credentials if no token (avoids sending empty cookies)
|
|
||||||
const useCredentials = token ? 'include' : 'omit';
|
|
||||||
|
|
||||||
const response = await fetch(input, {
|
|
||||||
...init,
|
|
||||||
headers,
|
|
||||||
credentials: useCredentials,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Story API Client
|
|
||||||
* Provides methods to interact with the Story API endpoints
|
|
||||||
*/
|
|
||||||
export const storyApi = {
|
|
||||||
// ============ Levels ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all levels that have stories
|
|
||||||
*/
|
|
||||||
async getLevelsWithStories(): Promise<LevelWithStoriesDto[]> {
|
|
||||||
const response = await authenticatedFetch(`${API_BASE_URL}/story/levels`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch levels with stories: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============ Story Segments ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all story segments for a specific level
|
|
||||||
*/
|
|
||||||
async getSegmentsByLevel(
|
|
||||||
levelId: number,
|
|
||||||
includeInactive: boolean = false
|
|
||||||
): Promise<StorySegmentDto[]> {
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${API_BASE_URL}/story/levels/${levelId}/segments?includeInactive=${includeInactive}`
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch segments for level ${levelId}: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a specific story segment by ID
|
|
||||||
*/
|
|
||||||
async getSegmentById(segmentId: number): Promise<StorySegmentDto> {
|
|
||||||
const response = await authenticatedFetch(`${API_BASE_URL}/story/segments/${segmentId}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch segment ${segmentId}: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new story segment (Admin only)
|
|
||||||
*/
|
|
||||||
async createSegment(dto: CreateStorySegmentDto): Promise<StorySegmentDto> {
|
|
||||||
const response = await authenticatedFetch(`${API_BASE_URL}/story/segments`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(dto),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to create segment: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates an existing story segment (Admin only)
|
|
||||||
*/
|
|
||||||
async updateSegment(
|
|
||||||
segmentId: number,
|
|
||||||
dto: UpdateStorySegmentDto
|
|
||||||
): Promise<StorySegmentDto> {
|
|
||||||
const response = await authenticatedFetch(`${API_BASE_URL}/story/segments/${segmentId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(dto),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to update segment ${segmentId}: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a story segment (Admin only)
|
|
||||||
*/
|
|
||||||
async deleteSegment(segmentId: number): Promise<void> {
|
|
||||||
const response = await authenticatedFetch(`${API_BASE_URL}/story/segments/${segmentId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to delete segment ${segmentId}: ${response.status}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============ Story Generation ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a story for a level using AI (Admin only)
|
|
||||||
*/
|
|
||||||
async generateStory(
|
|
||||||
levelId: number,
|
|
||||||
request: StoryGenerationRequestDto
|
|
||||||
): Promise<StoryGenerationResponseDto> {
|
|
||||||
const response = await authenticatedFetch(`${API_BASE_URL}/story/levels/${levelId}/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(request),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to generate story for level ${levelId}: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates audio for a specific story segment (Admin only)
|
|
||||||
*/
|
|
||||||
async generateSegmentAudio(segmentId: number): Promise<StorySegmentDto> {
|
|
||||||
const response = await authenticatedFetch(`${API_BASE_URL}/story/segments/${segmentId}/audio`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to generate audio for segment ${segmentId}: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============ User Progress ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the user's progress through a story for a level
|
|
||||||
*/
|
|
||||||
async getUserProgress(levelId: number): Promise<StoryProgressDto> {
|
|
||||||
const response = await authenticatedFetch(`${API_BASE_URL}/story/levels/${levelId}/progress`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch progress for level ${levelId}: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks a story segment as completed
|
|
||||||
*/
|
|
||||||
async markSegmentAsCompleted(segmentId: number): Promise<void> {
|
|
||||||
const response = await authenticatedFetch(`${API_BASE_URL}/story/segments/${segmentId}/complete`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to mark segment ${segmentId} as completed: ${response.status}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the next story segment for a user to read
|
|
||||||
*/
|
|
||||||
async getNextSegment(levelId: number): Promise<StorySegmentDto | null> {
|
|
||||||
const response = await authenticatedFetch(`${API_BASE_URL}/story/levels/${levelId}/next`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch next segment for level ${levelId}: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a specific segment is unlocked for the current user
|
|
||||||
*/
|
|
||||||
async isSegmentUnlocked(segmentId: number): Promise<boolean> {
|
|
||||||
const response = await authenticatedFetch(`${API_BASE_URL}/story/segments/${segmentId}/unlocked`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to check if segment ${segmentId} is unlocked: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============ Audio ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the audio URL for a story segment
|
|
||||||
*/
|
|
||||||
async getSegmentAudio(segmentId: number): Promise<StoryAudioResponse> {
|
|
||||||
const response = await authenticatedFetch(`${API_BASE_URL}/story/segments/${segmentId}/audio`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch audio for segment ${segmentId}: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============ Lessons with Story Status ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all lessons in a level with their associated story segments
|
|
||||||
*/
|
|
||||||
async getLessonsWithStoryStatus(levelId: number): Promise<LessonWithStoryStatusDto[]> {
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${API_BASE_URL}/story/levels/${levelId}/lessons-with-stories`
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch lessons with story status for level ${levelId}: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default storyApi;
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
/**
|
|
||||||
* Admin Dashboard page
|
|
||||||
* Shows overview statistics and quick actions
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useAuth } from '@/stores/authStore';
|
|
||||||
import { getDashboardStats } from '@/lib/api/admin';
|
|
||||||
import type { DashboardStats } from '@/types/api/admin';
|
|
||||||
import { AdminLayout } from '@/components/features/admin/AdminLayout';
|
|
||||||
|
|
||||||
export function AdminDashboardPage() {
|
|
||||||
const { token } = useAuth();
|
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchStats = async () => {
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const data = await getDashboardStats(token);
|
|
||||||
setStats(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load dashboard stats');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchStats();
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<div className="loading-overlay">
|
|
||||||
<div className="spinner"></div>
|
|
||||||
<p>Loading dashboard...</p>
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<div className="error-message">{error}</div>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<div className="admin-dashboard">
|
|
||||||
<header className="dashboard-header">
|
|
||||||
<h1>Dashboard</h1>
|
|
||||||
<p className="dashboard-subtitle">Overview of your DeutschLernen application</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="dashboard-stats-grid">
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon users">👥</div>
|
|
||||||
<div className="stat-content">
|
|
||||||
<h3>Total Users</h3>
|
|
||||||
<p className="stat-value">{stats?.totalUsers || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon lessons">📚</div>
|
|
||||||
<div className="stat-content">
|
|
||||||
<h3>Total Lessons</h3>
|
|
||||||
<p className="stat-value">{stats?.totalLessons || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon quizzes">🎯</div>
|
|
||||||
<div className="stat-content">
|
|
||||||
<h3>Total Quizzes</h3>
|
|
||||||
<p className="stat-value">{stats?.totalQuizzes || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon stories">📖</div>
|
|
||||||
<div className="stat-content">
|
|
||||||
<h3>Story Segments</h3>
|
|
||||||
<p className="stat-value">{stats?.totalStorySegments || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon active">✅</div>
|
|
||||||
<div className="stat-content">
|
|
||||||
<h3>Active Users (Week)</h3>
|
|
||||||
<p className="stat-value">{stats?.activeUsersThisWeek || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon progress">📈</div>
|
|
||||||
<div className="stat-content">
|
|
||||||
<h3>Avg. Progress</h3>
|
|
||||||
<p className="stat-value">{stats ? stats.averageUserProgress.toFixed(1) + '%' : '0%'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="dashboard-quick-actions">
|
|
||||||
<h2>Quick Actions</h2>
|
|
||||||
<div className="quick-action-cards">
|
|
||||||
<a href="/admin/users" className="quick-action-card">
|
|
||||||
<div className="action-icon">👥</div>
|
|
||||||
<div className="action-content">
|
|
||||||
<h3>Manage Users</h3>
|
|
||||||
<p>View, edit, and manage all users</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="/admin/reports" className="quick-action-card">
|
|
||||||
<div className="action-icon">📊</div>
|
|
||||||
<div className="action-content">
|
|
||||||
<h3>View Reports</h3>
|
|
||||||
<p>Generate and export user progress reports</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="/admin/stories" className="quick-action-card">
|
|
||||||
<div className="action-icon">📖</div>
|
|
||||||
<div className="action-content">
|
|
||||||
<h3>Generate Stories</h3>
|
|
||||||
<p>Create new story segments for levels</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="dashboard-info">
|
|
||||||
<h2>About</h2>
|
|
||||||
<p>
|
|
||||||
This is the DeutschLernen admin panel. Here you can manage users,
|
|
||||||
view progress reports, and generate story content for different CEFR levels.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Note:</strong> All actions in this panel require administrator privileges.
|
|
||||||
Please ensure you are logged in as an admin user.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,343 +0,0 @@
|
||||||
/**
|
|
||||||
* Admin Reports page
|
|
||||||
* View and export user progress reports
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { useAuth } from '@/stores/authStore';
|
|
||||||
import { getAllUserReports, getFullUserReport, exportReportsAsCsv } from '@/lib/api/admin';
|
|
||||||
import type { UserProgressReport, FullUserReport } from '@/types/api/admin';
|
|
||||||
import { AdminLayout } from '@/components/features/admin/AdminLayout';
|
|
||||||
|
|
||||||
export function AdminReportsPage() {
|
|
||||||
const { token } = useAuth();
|
|
||||||
const [reports, setReports] = useState<UserProgressReport[]>([]);
|
|
||||||
const [selectedUser, setSelectedUser] = useState<FullUserReport | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [reportLoading, setReportLoading] = useState<number | null>(null);
|
|
||||||
const [exporting, setExporting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchReports = useCallback(async () => {
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const data = await getAllUserReports(token);
|
|
||||||
setReports(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load reports');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchReports();
|
|
||||||
}, [fetchReports]);
|
|
||||||
|
|
||||||
const fetchUserReport = async (userId: number) => {
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setReportLoading(userId);
|
|
||||||
setError(null);
|
|
||||||
const data = await getFullUserReport(token, userId);
|
|
||||||
setSelectedUser(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load user report');
|
|
||||||
} finally {
|
|
||||||
setReportLoading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportCsv = async () => {
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setExporting(true);
|
|
||||||
setError(null);
|
|
||||||
const { csvContent } = await exportReportsAsCsv(token);
|
|
||||||
|
|
||||||
// Download CSV file
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `user-reports-${new Date().toISOString().split('T')[0]}.csv`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to export reports');
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string): string => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateAverageScore = (score: number): string => {
|
|
||||||
return score > 0 ? score.toFixed(1) : 'N/A';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<div className="loading-overlay">
|
|
||||||
<div className="spinner"></div>
|
|
||||||
<p>Loading reports...</p>
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<div className="admin-reports">
|
|
||||||
<header className="reports-header">
|
|
||||||
<h1>User Progress Reports</h1>
|
|
||||||
<p className="reports-subtitle">
|
|
||||||
View and export progress reports for all users
|
|
||||||
</p>
|
|
||||||
<div className="reports-actions">
|
|
||||||
<button
|
|
||||||
onClick={fetchReports}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Refresh Reports
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleExportCsv}
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={exporting || reports.length === 0}
|
|
||||||
>
|
|
||||||
{exporting ? 'Exporting...' : 'Export All as CSV'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="error-message admin-error">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="reports-container">
|
|
||||||
{/* Reports Summary Table */}
|
|
||||||
<div className="reports-summary">
|
|
||||||
<h2>All User Reports</h2>
|
|
||||||
<div className="reports-table-container">
|
|
||||||
<table className="reports-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>User</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Level</th>
|
|
||||||
<th>Lessons Completed</th>
|
|
||||||
<th>Quizzes Completed</th>
|
|
||||||
<th>Avg. Score</th>
|
|
||||||
<th>Points</th>
|
|
||||||
<th>Streak</th>
|
|
||||||
<th>Last Activity</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{reports.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={10} className="no-reports">
|
|
||||||
No user reports available
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
reports.map((report) => (
|
|
||||||
<tr key={report.userId}>
|
|
||||||
<td>{report.username}</td>
|
|
||||||
<td>{report.email}</td>
|
|
||||||
<td>{report.currentLevel}</td>
|
|
||||||
<td>{report.totalLessonsCompleted}</td>
|
|
||||||
<td>{report.totalQuizzesCompleted}</td>
|
|
||||||
<td>{calculateAverageScore(report.averageQuizScore)}</td>
|
|
||||||
<td>{report.totalPoints}</td>
|
|
||||||
<td>{report.currentStreak}</td>
|
|
||||||
<td>{formatDate(report.lastActivityDate)}</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
onClick={() => fetchUserReport(report.userId)}
|
|
||||||
className="btn btn-small"
|
|
||||||
disabled={reportLoading === report.userId}
|
|
||||||
>
|
|
||||||
{reportLoading === report.userId ? 'Loading...' : 'View Details'}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User Detail Modal */}
|
|
||||||
{selectedUser && (
|
|
||||||
<div className="modal-overlay" onClick={() => setSelectedUser(null)}>
|
|
||||||
<div className="modal modal-large" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="modal-header">
|
|
||||||
<h2>User Progress Report</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedUser(null)}
|
|
||||||
className="modal-close"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-content">
|
|
||||||
{/* User Overview */}
|
|
||||||
<div className="report-section">
|
|
||||||
<h3>User Overview</h3>
|
|
||||||
<div className="user-overview-grid">
|
|
||||||
<div className="overview-item">
|
|
||||||
<span className="label">Username:</span>
|
|
||||||
<span className="value">{selectedUser.progressReport.username}</span>
|
|
||||||
</div>
|
|
||||||
<div className="overview-item">
|
|
||||||
<span className="label">Email:</span>
|
|
||||||
<span className="value">{selectedUser.progressReport.email}</span>
|
|
||||||
</div>
|
|
||||||
<div className="overview-item">
|
|
||||||
<span className="label">Current Level:</span>
|
|
||||||
<span className="value">{selectedUser.progressReport.currentLevel}</span>
|
|
||||||
</div>
|
|
||||||
<div className="overview-item">
|
|
||||||
<span className="label">Total Points:</span>
|
|
||||||
<span className="value">{selectedUser.progressReport.totalPoints}</span>
|
|
||||||
</div>
|
|
||||||
<div className="overview-item">
|
|
||||||
<span className="label">Streak:</span>
|
|
||||||
<span className="value">{selectedUser.progressReport.currentStreak} days</span>
|
|
||||||
</div>
|
|
||||||
<div className="overview-item">
|
|
||||||
<span className="label">Avg. Quiz Score:</span>
|
|
||||||
<span className="value">
|
|
||||||
{calculateAverageScore(selectedUser.progressReport.averageQuizScore)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Summary */}
|
|
||||||
<div className="report-section">
|
|
||||||
<h3>Progress Summary</h3>
|
|
||||||
<div className="progress-summary-grid">
|
|
||||||
<div className="progress-item">
|
|
||||||
<h4>Lessons Completed</h4>
|
|
||||||
<p>{selectedUser.progressReport.totalLessonsCompleted}</p>
|
|
||||||
</div>
|
|
||||||
<div className="progress-item">
|
|
||||||
<h4>Quizzes Completed</h4>
|
|
||||||
<p>{selectedUser.progressReport.totalQuizzesCompleted}</p>
|
|
||||||
</div>
|
|
||||||
<div className="progress-item">
|
|
||||||
<h4>Last Activity</h4>
|
|
||||||
<p>{formatDate(selectedUser.progressReport.lastActivityDate)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lesson Progress */}
|
|
||||||
{selectedUser.lessonProgress.length > 0 && (
|
|
||||||
<div className="report-section">
|
|
||||||
<h3>Lesson Progress</h3>
|
|
||||||
<div className="lesson-progress-table">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Lesson</th>
|
|
||||||
<th>Level</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Completed</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{selectedUser.lessonProgress.map((lesson) => (
|
|
||||||
<tr key={lesson.lessonId}>
|
|
||||||
<td>{lesson.lessonTitle}</td>
|
|
||||||
<td>{lesson.levelCode}</td>
|
|
||||||
<td>
|
|
||||||
<span className={`status-badge ${lesson.isCompleted ? 'completed' : 'pending'}`}>
|
|
||||||
{lesson.isCompleted ? 'Completed' : 'Pending'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{lesson.completedAt ? formatDate(lesson.completedAt) : 'N/A'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quiz Results */}
|
|
||||||
{selectedUser.quizResults.length > 0 && (
|
|
||||||
<div className="report-section">
|
|
||||||
<h3>Quiz Results</h3>
|
|
||||||
<div className="quiz-results-table">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Quiz</th>
|
|
||||||
<th>Score</th>
|
|
||||||
<th>Passing Score</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Date</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{selectedUser.quizResults.map((result) => (
|
|
||||||
<tr key={result.quizId}>
|
|
||||||
<td>{result.quizTitle}</td>
|
|
||||||
<td>{result.score}%</td>
|
|
||||||
<td>{result.passingScore}%</td>
|
|
||||||
<td>
|
|
||||||
<span className={`status-badge ${result.passed ? 'passed' : 'failed'}`}>
|
|
||||||
{result.passed ? 'Passed' : 'Failed'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{formatDate(result.attemptDate)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedUser(null)}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,390 +0,0 @@
|
||||||
/**
|
|
||||||
* Admin Story Generator page
|
|
||||||
* Allows admin to generate stories for levels using AI
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { useAuth } from '@/stores/authStore';
|
|
||||||
import { AdminLayout } from '@/components/features/admin/AdminLayout';
|
|
||||||
import {
|
|
||||||
getLevelsWithStories,
|
|
||||||
generateStory,
|
|
||||||
getStorySegmentsByLevel,
|
|
||||||
deleteStorySegment,
|
|
||||||
generateSegmentAudio,
|
|
||||||
} from '@/lib/api/admin';
|
|
||||||
import type { LevelWithStories, StorySegment, StoryGenerationRequest, StoryGenerationResponse } from '@/types/api/admin';
|
|
||||||
|
|
||||||
export function AdminStoryGeneratorPage() {
|
|
||||||
const { token } = useAuth();
|
|
||||||
const [levels, setLevels] = useState<LevelWithStories[]>([]);
|
|
||||||
const [selectedLevel, setSelectedLevel] = useState<number | null>(null);
|
|
||||||
const [theme, setTheme] = useState('');
|
|
||||||
const [segmentCount, setSegmentCount] = useState(5);
|
|
||||||
const [customPrompt, setCustomPrompt] = useState('');
|
|
||||||
const [segments, setSegments] = useState<StorySegment[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [generating, setGenerating] = useState(false);
|
|
||||||
const [generationProgress, setGenerationProgress] = useState<string>('');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const fetchLevels = useCallback(async () => {
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const data = await getLevelsWithStories(token);
|
|
||||||
setLevels(data);
|
|
||||||
if (data.length > 0) {
|
|
||||||
setSelectedLevel(data[0].id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load levels');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchLevels();
|
|
||||||
}, [fetchLevels]);
|
|
||||||
|
|
||||||
const fetchSegments = useCallback(async (levelId: number) => {
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const data = await getStorySegmentsByLevel(token, levelId);
|
|
||||||
setSegments(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load story segments');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedLevel) {
|
|
||||||
fetchSegments(selectedLevel);
|
|
||||||
}
|
|
||||||
}, [selectedLevel, fetchSegments]);
|
|
||||||
|
|
||||||
const handleGenerateStory = async () => {
|
|
||||||
if (!token || !selectedLevel) return;
|
|
||||||
|
|
||||||
if (!theme.trim()) {
|
|
||||||
setError('Please enter a theme');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segmentCount < 1 || segmentCount > 20) {
|
|
||||||
setError('Segment count must be between 1 and 20');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setGenerating(true);
|
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
|
||||||
setGenerationProgress('Starting story generation...');
|
|
||||||
|
|
||||||
const request: StoryGenerationRequest = {
|
|
||||||
levelId: selectedLevel,
|
|
||||||
theme,
|
|
||||||
segmentCount,
|
|
||||||
customPrompt: customPrompt.trim() || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
setGenerationProgress('Calling AI service to generate story...');
|
|
||||||
const response: StoryGenerationResponse = await generateStory(token, selectedLevel, request);
|
|
||||||
|
|
||||||
setGenerationProgress(`Story generated! ${response.segments.length} segments created.`);
|
|
||||||
setSuccess(`Successfully generated story with ${response.segments.length} segments for ${response.theme}`);
|
|
||||||
|
|
||||||
// Refresh segments
|
|
||||||
await fetchSegments(selectedLevel);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to generate story');
|
|
||||||
} finally {
|
|
||||||
setGenerating(false);
|
|
||||||
setGenerationProgress('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenerateAudio = async (segmentId: number) => {
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
|
||||||
setSegments(segments.map(s =>
|
|
||||||
s.id === segmentId ? { ...s, audioUrl: 'Generating...' } : s
|
|
||||||
));
|
|
||||||
|
|
||||||
const updatedSegment = await generateSegmentAudio(token, segmentId);
|
|
||||||
setSegments(segments.map(s => s.id === segmentId ? updatedSegment : s));
|
|
||||||
setSuccess(`Audio generated for segment ${segmentId}`);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to generate audio');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteSegment = async (segmentId: number) => {
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
await deleteStorySegment(token, segmentId);
|
|
||||||
setSegments(segments.filter(s => s.id !== segmentId));
|
|
||||||
setConfirmDelete(null);
|
|
||||||
setSuccess(`Segment ${segmentId} deleted successfully`);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to delete segment');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string): string => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading && levels.length === 0) {
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<div className="loading-overlay">
|
|
||||||
<div className="spinner"></div>
|
|
||||||
<p>Loading levels...</p>
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<div className="admin-story-generator">
|
|
||||||
<header className="story-generator-header">
|
|
||||||
<h1>Story Generator</h1>
|
|
||||||
<p className="story-generator-subtitle">
|
|
||||||
Generate AI-powered stories for German language learning
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="error-message admin-error">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<div className="success-message admin-success">{success}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Story Generation Form */}
|
|
||||||
<section className="story-generator-form">
|
|
||||||
<h2>Generate New Story</h2>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="level-select">Level:</label>
|
|
||||||
<select
|
|
||||||
id="level-select"
|
|
||||||
value={selectedLevel || ''}
|
|
||||||
onChange={(e) => setSelectedLevel(Number(e.target.value))}
|
|
||||||
disabled={loading || generating}
|
|
||||||
className="form-select"
|
|
||||||
>
|
|
||||||
{levels.map((level) => (
|
|
||||||
<option key={level.id} value={level.id}>
|
|
||||||
{level.code} - {level.name} {level.hasStories && `(Has Stories)`}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="theme-input">Theme:</label>
|
|
||||||
<input
|
|
||||||
id="theme-input"
|
|
||||||
type="text"
|
|
||||||
value={theme}
|
|
||||||
onChange={(e) => setTheme(e.target.value)}
|
|
||||||
placeholder="e.g., A Week in Berlin, Summer Vacation, Starting a New Job"
|
|
||||||
disabled={generating}
|
|
||||||
className="form-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="segment-count">Number of Segments:</label>
|
|
||||||
<input
|
|
||||||
id="segment-count"
|
|
||||||
type="number"
|
|
||||||
value={segmentCount}
|
|
||||||
onChange={(e) => setSegmentCount(Math.max(1, Math.min(20, Number(e.target.value))))}
|
|
||||||
min="1"
|
|
||||||
max="20"
|
|
||||||
disabled={generating}
|
|
||||||
className="form-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="custom-prompt">
|
|
||||||
Custom Prompt (Optional):
|
|
||||||
<span className="hint">
|
|
||||||
Custom instructions for the AI (e.g., "Use only A1 vocabulary")
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="custom-prompt"
|
|
||||||
value={customPrompt}
|
|
||||||
onChange={(e) => setCustomPrompt(e.target.value)}
|
|
||||||
placeholder="Enter custom AI prompt..."
|
|
||||||
disabled={generating}
|
|
||||||
className="form-textarea"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleGenerateStory}
|
|
||||||
className="btn btn-primary btn-large"
|
|
||||||
disabled={generating || !theme.trim()}
|
|
||||||
>
|
|
||||||
{generating ? 'Generating...' : 'Generate Story'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{generating && generationProgress && (
|
|
||||||
<div className="generation-progress">
|
|
||||||
<p>{generationProgress}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Existing Segments */}
|
|
||||||
<section className="story-segments-list">
|
|
||||||
<h2>Existing Story Segments</h2>
|
|
||||||
<p className="segments-subtitle">
|
|
||||||
For level: {levels.find(l => l.id === selectedLevel)?.name || 'Select a level'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{segments.length === 0 ? (
|
|
||||||
<div className="no-segments">
|
|
||||||
<p>No story segments found for this level. Generate a story to create segments.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="segments-table-container">
|
|
||||||
<table className="segments-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Order</th>
|
|
||||||
<th>Title</th>
|
|
||||||
<th>Theme</th>
|
|
||||||
<th>Content Preview</th>
|
|
||||||
<th>Audio</th>
|
|
||||||
<th>Created</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{segments.map((segment) => (
|
|
||||||
<tr key={segment.id} className="segment-row">
|
|
||||||
<td>{segment.order}</td>
|
|
||||||
<td className="segment-title">{segment.title}</td>
|
|
||||||
<td>{segment.theme}</td>
|
|
||||||
<td className="segment-content-preview">
|
|
||||||
{segment.content.substring(0, 100)}{segment.content.length > 100 ? '...' : ''}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{segment.audioUrl ? (
|
|
||||||
<audio controls className="audio-player-small">
|
|
||||||
<source src={segment.audioUrl} type="audio/mpeg" />
|
|
||||||
Your browser does not support the audio element.
|
|
||||||
</audio>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => handleGenerateAudio(segment.id)}
|
|
||||||
className="btn btn-small btn-secondary"
|
|
||||||
title="Generate Audio"
|
|
||||||
>
|
|
||||||
Generate Audio
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{formatDate(segment.createdAt)}</td>
|
|
||||||
<td className="segment-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDelete(segment.id)}
|
|
||||||
className="btn btn-danger btn-small"
|
|
||||||
title="Delete Segment"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{segments.length > 0 && (
|
|
||||||
<div className="segments-summary">
|
|
||||||
<p>
|
|
||||||
Showing {segments.length} segment{segments.length !== 1 ? 's' : ''}
|
|
||||||
for this level
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
|
||||||
{confirmDelete && (
|
|
||||||
<div className="modal-overlay" onClick={() => setConfirmDelete(null)}>
|
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<h2>Confirm Delete</h2>
|
|
||||||
<p>
|
|
||||||
Are you sure you want to delete this story segment? This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
<p className="warning-text">
|
|
||||||
All associated data will be permanently removed.
|
|
||||||
</p>
|
|
||||||
<div className="modal-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDelete(null)}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteSegment(confirmDelete)}
|
|
||||||
className="btn btn-danger"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tips Section */}
|
|
||||||
<section className="story-generator-tips">
|
|
||||||
<h2>Tips for Story Generation</h2>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Theme:</strong> Be specific. "A Week in Berlin" works better than "Travel".</li>
|
|
||||||
<li><strong>Segments:</strong> Each segment should be 1-2 paragraphs (150-300 words).</li>
|
|
||||||
<li><strong>Custom Prompt:</strong> Use this to specify CEFR level, vocabulary constraints, or style.</li>
|
|
||||||
<li><strong>Audio:</strong> Generated audio uses Coqui TTS with a German voice.</li>
|
|
||||||
<li><strong>Preview:</strong> Generated stories will use vocabulary from the level's lessons.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,333 +0,0 @@
|
||||||
/**
|
|
||||||
* Admin User Detail page
|
|
||||||
* Shows detailed information and progress for a specific user
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
|
||||||
import { useAuth } from '@/stores/authStore';
|
|
||||||
import { AdminLayout } from '@/components/features/admin/AdminLayout';
|
|
||||||
import { getUserById, getFullUserReport } from '@/lib/api/admin';
|
|
||||||
import type { AdminUserDetails, FullUserReport } from '@/types/api/admin';
|
|
||||||
|
|
||||||
export function AdminUserDetailPage() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const { token } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [user, setUser] = useState<AdminUserDetails | null>(null);
|
|
||||||
const [report, setReport] = useState<FullUserReport | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const userId = id ? parseInt(id, 10) : 0;
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
if (!token || !userId || isNaN(userId)) {
|
|
||||||
setError('Invalid user ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Fetch user details and full report in parallel
|
|
||||||
const [userData, reportData] = await Promise.all([
|
|
||||||
getUserById(token, userId),
|
|
||||||
getFullUserReport(token, userId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setUser(userData);
|
|
||||||
setReport(reportData);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load user data');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [token, userId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
}, [fetchData]);
|
|
||||||
|
|
||||||
const formatDate = (dateString: string | null): string => {
|
|
||||||
if (!dateString) return 'N/A';
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateString: string | null): string => {
|
|
||||||
if (!dateString) return 'N/A';
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleTimeString('en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateProgressPercentage = (completed: number, total: number): string => {
|
|
||||||
if (total === 0) return '0%';
|
|
||||||
return `${Math.round((completed / total) * 100)}%`;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<div className="loading-overlay">
|
|
||||||
<div className="spinner"></div>
|
|
||||||
<p>Loading user details...</p>
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<div className="error-message admin-error">{error}</div>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<div className="no-data">
|
|
||||||
<h2>User Not Found</h2>
|
|
||||||
<p>The user you're looking for doesn't exist or has been deleted.</p>
|
|
||||||
<button onClick={() => navigate('/admin/users')} className="btn btn-primary">
|
|
||||||
Back to Users
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<div className="admin-user-detail">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="user-detail-header">
|
|
||||||
<button onClick={() => navigate('/admin/users')} className="btn btn-secondary back-button">
|
|
||||||
← Back to Users
|
|
||||||
</button>
|
|
||||||
<div className="user-header-content">
|
|
||||||
<h1>{user.username}</h1>
|
|
||||||
<span className={`user-role-badge ${user.role.toLowerCase()}`}>{user.role}</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* User Overview */}
|
|
||||||
<section className="user-overview">
|
|
||||||
<h2>User Overview</h2>
|
|
||||||
<div className="overview-grid">
|
|
||||||
<div className="overview-card">
|
|
||||||
<div className="overview-icon">👤</div>
|
|
||||||
<div className="overview-info">
|
|
||||||
<h3>Basic Information</h3>
|
|
||||||
<p><strong>Email:</strong> {user.email}</p>
|
|
||||||
<p><strong>Joined:</strong> {formatDate(user.createdAt)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overview-card">
|
|
||||||
<div className="overview-icon">📚</div>
|
|
||||||
<div className="overview-info">
|
|
||||||
<h3>Learning Progress</h3>
|
|
||||||
<p><strong>Current Level:</strong> {user.currentLevel || 'None'}</p>
|
|
||||||
<p><strong>Total Points:</strong> {user.totalPoints}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overview-card">
|
|
||||||
<div className="overview-icon">🔥</div>
|
|
||||||
<div className="overview-info">
|
|
||||||
<h3>Activity</h3>
|
|
||||||
<p><strong>Current Streak:</strong> {user.streak} days</p>
|
|
||||||
<p><strong>Last Active:</strong> {report?.progressReport.lastActivityDate ? formatDate(report.progressReport.lastActivityDate) : 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Progress Summary */}
|
|
||||||
{report && (
|
|
||||||
<section className="user-progress-summary">
|
|
||||||
<h2>Progress Summary</h2>
|
|
||||||
<div className="progress-cards">
|
|
||||||
<div className="progress-card">
|
|
||||||
<h3>Lessons Completed</h3>
|
|
||||||
<p className="progress-value">{report.progressReport.totalLessonsCompleted}</p>
|
|
||||||
<p className="progress-label">Total</p>
|
|
||||||
</div>
|
|
||||||
<div className="progress-card">
|
|
||||||
<h3>Quizzes Completed</h3>
|
|
||||||
<p className="progress-value">{report.progressReport.totalQuizzesCompleted}</p>
|
|
||||||
<p className="progress-label">Total</p>
|
|
||||||
</div>
|
|
||||||
<div className="progress-card">
|
|
||||||
<h3>Average Quiz Score</h3>
|
|
||||||
<p className="progress-value">{report.progressReport.averageQuizScore > 0 ? report.progressReport.averageQuizScore.toFixed(1) + '%' : 'N/A'}</p>
|
|
||||||
<p className="progress-label">Average</p>
|
|
||||||
</div>
|
|
||||||
<div className="progress-card">
|
|
||||||
<h3>Overall Progress</h3>
|
|
||||||
<p className="progress-value">{calculateProgressPercentage(report.progressReport.totalLessonsCompleted, 100)}</p>
|
|
||||||
<p className="progress-label">Estimated</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Lesson Progress */}
|
|
||||||
{report && report.lessonProgress.length > 0 && (
|
|
||||||
<section className="user-lesson-progress">
|
|
||||||
<h2>Lesson Progress</h2>
|
|
||||||
<div className="lesson-progress-table-container">
|
|
||||||
<table className="lesson-progress-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Lesson</th>
|
|
||||||
<th>Level</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Completed</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{report.lessonProgress.map((lesson) => (
|
|
||||||
<tr key={lesson.lessonId} className={lesson.isCompleted ? 'lesson-completed' : 'lesson-pending'}>
|
|
||||||
<td>{lesson.lessonTitle}</td>
|
|
||||||
<td>{lesson.levelCode}</td>
|
|
||||||
<td>
|
|
||||||
<span className={`status-badge ${lesson.isCompleted ? 'completed' : 'pending'}`}>
|
|
||||||
{lesson.isCompleted ? '✓ Completed' : '⏳ Pending'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{lesson.completedAt ? formatDate(lesson.completedAt) : 'N/A'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p className="lesson-progress-summary">
|
|
||||||
{report.lessonProgress.filter(l => l.isCompleted).length} of {report.lessonProgress.length} lessons completed
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quiz Results */}
|
|
||||||
{report && report.quizResults.length > 0 && (
|
|
||||||
<section className="user-quiz-results">
|
|
||||||
<h2>Quiz Results</h2>
|
|
||||||
<div className="quiz-results-table-container">
|
|
||||||
<table className="quiz-results-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Quiz</th>
|
|
||||||
<th>Score</th>
|
|
||||||
<th>Passing Score</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Date</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{report.quizResults.map((result) => (
|
|
||||||
<tr key={result.quizId} className={result.passed ? 'quiz-passed' : 'quiz-failed'}>
|
|
||||||
<td>{result.quizTitle}</td>
|
|
||||||
<td>{result.score}%</td>
|
|
||||||
<td>{result.passingScore}%</td>
|
|
||||||
<td>
|
|
||||||
<span className={`status-badge ${result.passed ? 'passed' : 'failed'}`}>
|
|
||||||
{result.passed ? '✓ Passed' : '✗ Failed'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{formatDate(result.attemptDate)} at {formatTime(result.attemptDate)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p className="quiz-results-summary">
|
|
||||||
{report.quizResults.filter(r => r.passed).length} of {report.quizResults.length} quizzes passed
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* No Data States */}
|
|
||||||
{report && report.lessonProgress.length === 0 && (
|
|
||||||
<section className="no-data-section">
|
|
||||||
<h2>Lesson Progress</h2>
|
|
||||||
<p>This user hasn't started any lessons yet.</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{report && report.quizResults.length === 0 && (
|
|
||||||
<section className="no-data-section">
|
|
||||||
<h2>Quiz Results</h2>
|
|
||||||
<p>This user hasn't taken any quizzes yet.</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activity Timeline */}
|
|
||||||
{report && (
|
|
||||||
<section className="user-activity-timeline">
|
|
||||||
<h2>Activity Timeline</h2>
|
|
||||||
<div className="timeline">
|
|
||||||
<div className="timeline-event">
|
|
||||||
<div className="timeline-dot"></div>
|
|
||||||
<div className="timeline-content">
|
|
||||||
<h4>Joined</h4>
|
|
||||||
<p>{formatDate(user.createdAt)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{report.lessonProgress
|
|
||||||
.filter(l => l.completedAt)
|
|
||||||
.sort((a, b) => new Date(b.completedAt!).getTime() - new Date(a.completedAt!).getTime())
|
|
||||||
.slice(0, 5)
|
|
||||||
.map((lesson) => (
|
|
||||||
<div key={lesson.lessonId} className="timeline-event">
|
|
||||||
<div className="timeline-dot"></div>
|
|
||||||
<div className="timeline-content">
|
|
||||||
<h4>Completed: {lesson.lessonTitle}</h4>
|
|
||||||
<p>{formatDate(lesson.completedAt!)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{report.quizResults
|
|
||||||
.sort((a, b) => new Date(b.attemptDate).getTime() - new Date(a.attemptDate).getTime())
|
|
||||||
.slice(0, 5)
|
|
||||||
.map((result) => (
|
|
||||||
<div key={result.quizId} className="timeline-event">
|
|
||||||
<div className="timeline-dot"></div>
|
|
||||||
<div className="timeline-content">
|
|
||||||
<h4>Quiz: {result.quizTitle}</h4>
|
|
||||||
<p>{formatDate(result.attemptDate)} - Score: {result.score}%</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Export Options */}
|
|
||||||
<section className="user-export-options">
|
|
||||||
<h2>Export Options</h2>
|
|
||||||
<div className="export-buttons">
|
|
||||||
<button onClick={fetchData} className="btn btn-secondary">
|
|
||||||
Refresh Data
|
|
||||||
</button>
|
|
||||||
<button onClick={() => navigate('/admin/users')} className="btn btn-secondary">
|
|
||||||
Back to Users
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
/**
|
|
||||||
* Admin Users page
|
|
||||||
* List all users with ability to manage them
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { useAuth } from '@/stores/authStore';
|
|
||||||
import { getAllUsers, updateUserRole, deleteUser } from '@/lib/api/admin';
|
|
||||||
import type { AdminUserListItem } from '@/types/api/admin';
|
|
||||||
import { AdminLayout } from '@/components/features/admin/AdminLayout';
|
|
||||||
|
|
||||||
export function AdminUsersPage() {
|
|
||||||
const { token } = useAuth();
|
|
||||||
const [users, setUsers] = useState<AdminUserListItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const fetchUsers = useCallback(async () => {
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const data = await getAllUsers(token);
|
|
||||||
setUsers(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load users');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchUsers();
|
|
||||||
}, [fetchUsers]);
|
|
||||||
|
|
||||||
const handleRoleChange = async (userId: number, newRole: string) => {
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setActionLoading(`role-${userId}`);
|
|
||||||
const updatedUser = await updateUserRole(token, userId, newRole);
|
|
||||||
setUsers(users.map(u => u.id === userId ? updatedUser : u));
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to update user role');
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (userId: number) => {
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setActionLoading(`delete-${userId}`);
|
|
||||||
await deleteUser(token, userId);
|
|
||||||
setUsers(users.filter(u => u.id !== userId));
|
|
||||||
setConfirmDelete(null);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to delete user');
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string): string => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<div className="loading-overlay">
|
|
||||||
<div className="spinner"></div>
|
|
||||||
<p>Loading users...</p>
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminLayout>
|
|
||||||
<div className="admin-users">
|
|
||||||
<header className="users-header">
|
|
||||||
<h1>User Management</h1>
|
|
||||||
<p className="users-subtitle">
|
|
||||||
View and manage all registered users
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={fetchUsers}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Refresh List
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="error-message admin-error">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="users-table-container">
|
|
||||||
<table className="users-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Username</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Role</th>
|
|
||||||
<th>Level</th>
|
|
||||||
<th>Points</th>
|
|
||||||
<th>Streak</th>
|
|
||||||
<th>Joined</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={9} className="no-users">
|
|
||||||
No users found
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
users.map((user) => (
|
|
||||||
<tr key={user.id}>
|
|
||||||
<td>{user.id}</td>
|
|
||||||
<td className="user-username">{user.username}</td>
|
|
||||||
<td className="user-email">{user.email}</td>
|
|
||||||
<td>
|
|
||||||
<select
|
|
||||||
value={user.role}
|
|
||||||
onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
|
||||||
disabled={actionLoading === `role-${user.id}`}
|
|
||||||
className="role-select"
|
|
||||||
>
|
|
||||||
<option value="User">User</option>
|
|
||||||
<option value="Admin">Admin</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>{user.currentLevel}</td>
|
|
||||||
<td>{user.totalPoints}</td>
|
|
||||||
<td>{user.streak}</td>
|
|
||||||
<td>{formatDate(user.createdAt)}</td>
|
|
||||||
<td className="user-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDelete(user.id)}
|
|
||||||
className="btn btn-danger btn-small"
|
|
||||||
disabled={actionLoading === `delete-${user.id}`}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{users.length > 0 && (
|
|
||||||
<div className="users-summary">
|
|
||||||
<p>
|
|
||||||
Showing {users.length} user{users.length !== 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
|
||||||
{confirmDelete && (
|
|
||||||
<div className="modal-overlay" onClick={() => setConfirmDelete(null)}>
|
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<h2>Confirm Delete</h2>
|
|
||||||
<p>
|
|
||||||
Are you sure you want to delete this user? This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
<p className="warning-text">
|
|
||||||
Note: You cannot delete your own account or the last admin user.
|
|
||||||
</p>
|
|
||||||
<div className="modal-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDelete(null)}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(confirmDelete)}
|
|
||||||
className="btn btn-danger"
|
|
||||||
disabled={actionLoading?.startsWith('delete')}
|
|
||||||
>
|
|
||||||
{actionLoading?.startsWith('delete') ? 'Deleting...' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
/**
|
|
||||||
* Home page for authenticated users
|
|
||||||
* Shows overview of available content
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useAuth, useIsAdmin } from '@/stores/authStore';
|
|
||||||
|
|
||||||
export function HomePage() {
|
|
||||||
const { user, logout } = useAuth();
|
|
||||||
const isAdmin = useIsAdmin();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="home-page">
|
|
||||||
<header className="home-header">
|
|
||||||
<h1>Welcome to DeutschLernen</h1>
|
|
||||||
<p className="welcome-message">
|
|
||||||
Hello, {user?.username}! You're currently at level {user?.currentLevel}.
|
|
||||||
</p>
|
|
||||||
<p className="user-stats">
|
|
||||||
Points: {user?.totalPoints} | Streak: {user?.streak} days
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="home-content">
|
|
||||||
<section className="content-section">
|
|
||||||
<h2>Continue Learning</h2>
|
|
||||||
<div className="quick-access">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/lessons')}
|
|
||||||
className="quick-access-card"
|
|
||||||
>
|
|
||||||
<span className="card-icon">📚</span>
|
|
||||||
<span className="card-title">Lessons</span>
|
|
||||||
<span className="card-description">Practice vocabulary and grammar</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/story/1')}
|
|
||||||
className="quick-access-card"
|
|
||||||
>
|
|
||||||
<span className="card-icon">📖</span>
|
|
||||||
<span className="card-title">Stories</span>
|
|
||||||
<span className="card-description">Read engaging German stories</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/quizzes')}
|
|
||||||
className="quick-access-card"
|
|
||||||
>
|
|
||||||
<span className="card-icon">🎯</span>
|
|
||||||
<span className="card-title">Quizzes</span>
|
|
||||||
<span className="card-description">Test your knowledge</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="content-section">
|
|
||||||
<h2>Your Progress</h2>
|
|
||||||
<div className="progress-overview">
|
|
||||||
<div className="progress-card">
|
|
||||||
<h3>Level {user?.currentLevel}</h3>
|
|
||||||
<p>Current CEFR level</p>
|
|
||||||
</div>
|
|
||||||
<div className="progress-card">
|
|
||||||
<h3>{user?.totalPoints} pts</h3>
|
|
||||||
<p>Total points earned</p>
|
|
||||||
</div>
|
|
||||||
<div className="progress-card">
|
|
||||||
<h3>{user?.streak} days</h3>
|
|
||||||
<p>Current learning streak</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<section className="content-section admin-section">
|
|
||||||
<h2>Admin Dashboard</h2>
|
|
||||||
<p>You have administrator access to manage users and content.</p>
|
|
||||||
<div className="admin-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/admin')}
|
|
||||||
className="btn btn-admin"
|
|
||||||
>
|
|
||||||
Go to Admin Panel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className="content-section">
|
|
||||||
<div className="user-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => logout()}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
>
|
|
||||||
Sign Out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
/**
|
|
||||||
* Public landing page for DeutschLernen
|
|
||||||
* Visitors see this page before authentication
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useAuth } from '@/stores/authStore';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
export function LandingPage() {
|
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// Redirect authenticated users to home
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoading && isAuthenticated) {
|
|
||||||
navigate('/');
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, isLoading, navigate]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="loading-overlay">
|
|
||||||
<div className="spinner"></div>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="landing-page">
|
|
||||||
<div className="landing-hero">
|
|
||||||
<h1 className="landing-title">DeutschLernen</h1>
|
|
||||||
<p className="landing-subtitle">
|
|
||||||
Learn German with interactive stories, lessons, and quizzes
|
|
||||||
</p>
|
|
||||||
<p className="landing-description">
|
|
||||||
Join our community and track your progress through CEFR levels A1 to C1.
|
|
||||||
All learning content requires registration and sign-in.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="landing-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/login')}
|
|
||||||
className="btn btn-primary btn-large"
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/register')}
|
|
||||||
className="btn btn-secondary btn-large"
|
|
||||||
>
|
|
||||||
Create Account
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="landing-features">
|
|
||||||
<h2>What You Get</h2>
|
|
||||||
<div className="features-grid">
|
|
||||||
<div className="feature-card">
|
|
||||||
<h3>📚 Interactive Lessons</h3>
|
|
||||||
<p>Learn German vocabulary and grammar through structured lessons</p>
|
|
||||||
</div>
|
|
||||||
<div className="feature-card">
|
|
||||||
<h3>📖 Engaging Stories</h3>
|
|
||||||
<p>Read German stories at your level with audio support</p>
|
|
||||||
</div>
|
|
||||||
<div className="feature-card">
|
|
||||||
<h3>🎯 Progress Tracking</h3>
|
|
||||||
<p>Track your completion of lessons, quizzes, and stories</p>
|
|
||||||
</div>
|
|
||||||
<div className="feature-card">
|
|
||||||
<h3>✅ Personalized Learning</h3>
|
|
||||||
<p>Your progress is saved individually across all devices</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="landing-footer">
|
|
||||||
<p>All content requires authentication. No public access available.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
/**
|
|
||||||
* Login page for user authentication
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
|
||||||
import { useAuth } from '@/stores/authStore';
|
|
||||||
import type { FormEvent } from 'react';
|
|
||||||
|
|
||||||
export function LoginPage() {
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const { login, error, clearError } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
// Get redirect path from location state or default to home
|
|
||||||
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/';
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsSubmitting(true);
|
|
||||||
clearError();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await login(email, password);
|
|
||||||
navigate(from, { replace: true });
|
|
||||||
} catch (err) {
|
|
||||||
// Error is already handled by the auth store
|
|
||||||
console.error('Login error:', err);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="auth-page">
|
|
||||||
<div className="auth-card">
|
|
||||||
<h1 className="auth-title">Login</h1>
|
|
||||||
<p className="auth-subtitle">Sign in to access your German learning content</p>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="error-message">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="auth-form">
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="email">Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
placeholder="Enter your email"
|
|
||||||
className="form-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="password">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
placeholder="Enter your password"
|
|
||||||
className="form-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="btn btn-primary btn-full"
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="auth-links">
|
|
||||||
<p>
|
|
||||||
Don't have an account?{' '}
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/register')}
|
|
||||||
className="link-btn"
|
|
||||||
>
|
|
||||||
Register
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
/**
|
|
||||||
* Register page for new user registration
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
|
||||||
import { useAuth } from '@/stores/authStore';
|
|
||||||
import type { FormEvent } from 'react';
|
|
||||||
|
|
||||||
export function RegisterPage() {
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { register, error, clearError } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
// Get redirect path from location state or default to home
|
|
||||||
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/';
|
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
setValidationError('Passwords do not match');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (password.length < 8) {
|
|
||||||
setValidationError('Password must be at least 8 characters');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
clearError();
|
|
||||||
setValidationError(null);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!validateForm()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
clearError();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await register(username, email, password);
|
|
||||||
navigate(from, { replace: true });
|
|
||||||
} catch (err) {
|
|
||||||
// Error is already handled by the auth store
|
|
||||||
console.error('Registration error:', err);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="auth-page">
|
|
||||||
<div className="auth-card">
|
|
||||||
<h1 className="auth-title">Create Account</h1>
|
|
||||||
<p className="auth-subtitle">Join DeutschLernen and start your German learning journey</p>
|
|
||||||
|
|
||||||
{(error || validationError) && (
|
|
||||||
<div className="error-message">
|
|
||||||
{error || validationError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="auth-form">
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="username">Username</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="username"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={3}
|
|
||||||
placeholder="Enter your username"
|
|
||||||
className="form-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="email">Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
placeholder="Enter your email"
|
|
||||||
className="form-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="password">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
placeholder="Enter your password (min 8 characters)"
|
|
||||||
className="form-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="confirmPassword">Confirm Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="confirmPassword"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
placeholder="Confirm your password"
|
|
||||||
className="form-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="btn btn-primary btn-full"
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Creating account...' : 'Create Account'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="auth-links">
|
|
||||||
<p>
|
|
||||||
Already have an account?{' '}
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/login')}
|
|
||||||
className="link-btn"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
|
||||||
import type { StorySegmentDto, LevelWithStoriesDto } from '../types/api/story';
|
|
||||||
import { storyApi } from '../lib/api/story';
|
|
||||||
import StorySegment from '../components/features/story/StorySegment';
|
|
||||||
import StoryPlayer from '../components/features/story/StoryPlayer';
|
|
||||||
import StoryTab from '../components/features/story/StoryTab';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* StoryPage - Main page for viewing and interacting with story segments.
|
|
||||||
* Displays story segments for a level, allows navigation, and tracks progress.
|
|
||||||
*/
|
|
||||||
export function StoryPage() {
|
|
||||||
const { levelId: levelIdParam } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const levelId = parseInt(levelIdParam || '1', 10);
|
|
||||||
|
|
||||||
const [levels, setLevels] = useState<LevelWithStoriesDto[]>([]);
|
|
||||||
const [currentLevel, setCurrentLevel] = useState<LevelWithStoriesDto | null>(null);
|
|
||||||
const [segments, setSegments] = useState<StorySegmentDto[]>([]);
|
|
||||||
const [currentSegment, setCurrentSegment] = useState<StorySegmentDto | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Fetch data on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Fetch levels with stories
|
|
||||||
const levelsData = await storyApi.getLevelsWithStories();
|
|
||||||
setLevels(levelsData);
|
|
||||||
|
|
||||||
// Find current level
|
|
||||||
const currentLevelData = levelsData.find(l => l.id === levelId) || levelsData[0];
|
|
||||||
setCurrentLevel(currentLevelData);
|
|
||||||
|
|
||||||
// Fetch segments for the level
|
|
||||||
const segmentsData = await storyApi.getSegmentsByLevel(currentLevelData.id, false);
|
|
||||||
setSegments(segmentsData);
|
|
||||||
|
|
||||||
// Set first segment as current
|
|
||||||
if (segmentsData.length > 0) {
|
|
||||||
// Try to find the first unlocked segment, or use the first one
|
|
||||||
setCurrentSegment(segmentsData[0]);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to load story data');
|
|
||||||
console.error('Error fetching story data:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, [levelId]);
|
|
||||||
|
|
||||||
// Fetch next segment when level or segments change
|
|
||||||
const fetchNextSegment = useCallback(async () => {
|
|
||||||
if (!currentLevel) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const nextSegment = await storyApi.getNextSegment(currentLevel.id);
|
|
||||||
if (nextSegment) {
|
|
||||||
setCurrentSegment(nextSegment);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching next segment:', err);
|
|
||||||
}
|
|
||||||
}, [currentLevel]);
|
|
||||||
|
|
||||||
const handleSegmentSelect = useCallback((segmentId: number) => {
|
|
||||||
const segment = segments.find(s => s.id === segmentId);
|
|
||||||
if (segment) {
|
|
||||||
setCurrentSegment(segment);
|
|
||||||
}
|
|
||||||
}, [segments]);
|
|
||||||
|
|
||||||
const handleMarkAsCompleted = useCallback(async () => {
|
|
||||||
if (!currentSegment) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await storyApi.markSegmentAsCompleted(currentSegment.id);
|
|
||||||
// Refresh to get updated progress
|
|
||||||
const updatedSegments = await storyApi.getSegmentsByLevel(levelId, false);
|
|
||||||
setSegments(updatedSegments);
|
|
||||||
|
|
||||||
// Move to next segment
|
|
||||||
await fetchNextSegment();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error marking segment as completed:', err);
|
|
||||||
setError('Failed to mark segment as completed');
|
|
||||||
}
|
|
||||||
}, [currentSegment, levelId, fetchNextSegment]);
|
|
||||||
|
|
||||||
const handleLevelChange = useCallback((newLevelId: number) => {
|
|
||||||
navigate(`/story/${newLevelId}`);
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
const handlePreviousSegment = useCallback(() => {
|
|
||||||
if (!currentSegment || !segments.length) return;
|
|
||||||
|
|
||||||
const currentIndex = segments.findIndex(s => s.id === currentSegment.id);
|
|
||||||
if (currentIndex > 0) {
|
|
||||||
setCurrentSegment(segments[currentIndex - 1]);
|
|
||||||
}
|
|
||||||
}, [currentSegment, segments]);
|
|
||||||
|
|
||||||
const handleNextSegment = useCallback(() => {
|
|
||||||
if (!currentSegment || !segments.length) return;
|
|
||||||
|
|
||||||
const currentIndex = segments.findIndex(s => s.id === currentSegment.id);
|
|
||||||
if (currentIndex < segments.length - 1) {
|
|
||||||
setCurrentSegment(segments[currentIndex + 1]);
|
|
||||||
}
|
|
||||||
}, [currentSegment, segments]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="story-page" data-testid="story-page">
|
|
||||||
<div className="loading-indicator">
|
|
||||||
<p>Loading story...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="story-page" data-testid="story-page">
|
|
||||||
<div className="error-message">
|
|
||||||
<p>⚠️ {error}</p>
|
|
||||||
<button onClick={() => window.location.reload()}>Retry</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentLevel) {
|
|
||||||
return (
|
|
||||||
<div className="story-page" data-testid="story-page">
|
|
||||||
<div className="no-story-notice">
|
|
||||||
<p>No story available for this level</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="story-page" data-testid="story-page">
|
|
||||||
<div className="page-header">
|
|
||||||
<div className="level-selector">
|
|
||||||
<select
|
|
||||||
value={levelId}
|
|
||||||
onChange={(e) => handleLevelChange(Number(e.target.value))}
|
|
||||||
data-testid="level-selector"
|
|
||||||
>
|
|
||||||
{levels.map((level) => (
|
|
||||||
<option key={level.id} value={level.id}>
|
|
||||||
{level.name} ({level.code}) - {level.storySegmentCount} segments
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1>{currentLevel.name} Story</h1>
|
|
||||||
<p className="level-description">
|
|
||||||
Follow along with this continuous story as you progress through your German lessons.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="story-layout">
|
|
||||||
{/* Left sidebar - Navigation */}
|
|
||||||
<div className="story-sidebar">
|
|
||||||
<StoryTab
|
|
||||||
levelId={currentLevel.id}
|
|
||||||
levelName={currentLevel.name}
|
|
||||||
onSegmentSelect={handleSegmentSelect}
|
|
||||||
currentSegmentId={currentSegment?.id}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main content - Story segment */}
|
|
||||||
<div className="story-main">
|
|
||||||
{currentSegment ? (
|
|
||||||
<div className="story-content-wrapper">
|
|
||||||
<div className="segment-navigation">
|
|
||||||
<button
|
|
||||||
onClick={handlePreviousSegment}
|
|
||||||
disabled={!segments || segments.indexOf(currentSegment) === 0}
|
|
||||||
className="nav-btn"
|
|
||||||
data-testid="prev-segment-btn"
|
|
||||||
>
|
|
||||||
← Previous
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleNextSegment}
|
|
||||||
disabled={!segments || segments.indexOf(currentSegment) === segments.length - 1}
|
|
||||||
className="nav-btn"
|
|
||||||
data-testid="next-segment-btn"
|
|
||||||
>
|
|
||||||
Next →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="story-card">
|
|
||||||
<StorySegment
|
|
||||||
segment={currentSegment}
|
|
||||||
isUnlocked={true}
|
|
||||||
isCompleted={false} // Will be determined by progress
|
|
||||||
showTranslation={true}
|
|
||||||
onComplete={handleMarkAsCompleted}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{currentSegment.audioUrl && (
|
|
||||||
<div className="audio-section">
|
|
||||||
<h4>Listen to the Story</h4>
|
|
||||||
<StoryPlayer
|
|
||||||
segment={currentSegment}
|
|
||||||
autoPlay={false}
|
|
||||||
onEnded={handleNextSegment}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="no-segment-notice">
|
|
||||||
<p>No segments available for this level</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StoryPage;
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
/**
|
|
||||||
* Page exports for the DeutschLernen frontend
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { HomePage } from './HomePage';
|
|
||||||
export { LandingPage } from './LandingPage';
|
|
||||||
export { LoginPage } from './LoginPage';
|
|
||||||
export { RegisterPage } from './RegisterPage';
|
|
||||||
export { StoryPage } from './StoryPage';
|
|
||||||
export { AdminDashboardPage } from './AdminDashboardPage';
|
|
||||||
export { AdminUsersPage } from './AdminUsersPage';
|
|
||||||
export { AdminReportsPage } from './AdminReportsPage';
|
|
||||||
export { AdminStoryGeneratorPage } from './AdminStoryGeneratorPage';
|
|
||||||
export { AdminUserDetailPage } from './AdminUserDetailPage';
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
/**
|
|
||||||
* Auth store for managing authentication state
|
|
||||||
* Uses React context for state management
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import type { CurrentUser } from '@/types/api/auth';
|
|
||||||
import * as authApi from '@/lib/api/auth';
|
|
||||||
|
|
||||||
// Token storage keys
|
|
||||||
const TOKEN_KEY = 'deutschlernen_token';
|
|
||||||
const REFRESH_TOKEN_KEY = 'deutschlernen_refresh_token';
|
|
||||||
const TOKEN_EXPIRY_KEY = 'deutschlernen_token_expiry';
|
|
||||||
|
|
||||||
interface AuthState {
|
|
||||||
token: string | null;
|
|
||||||
refreshToken: string | null;
|
|
||||||
user: CurrentUser | null;
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthContextType extends AuthState {
|
|
||||||
login: (email: string, password: string) => Promise<void>;
|
|
||||||
register: (username: string, email: string, password: string) => Promise<void>;
|
|
||||||
logout: () => void;
|
|
||||||
refreshAuthToken: () => Promise<void>;
|
|
||||||
clearError: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
// Helper to check if token is expired
|
|
||||||
function isTokenExpired(expiry: string | null): boolean {
|
|
||||||
if (!expiry) return true;
|
|
||||||
const expiryDate = new Date(expiry);
|
|
||||||
// Add 30 second buffer
|
|
||||||
return expiryDate.getTime() - 30000 < Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to get token from storage
|
|
||||||
function getTokenFromStorage(): { token: string | null; refreshToken: string | null; expiry: string | null } {
|
|
||||||
const token = localStorage.getItem(TOKEN_KEY);
|
|
||||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
|
||||||
const expiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
|
|
||||||
return { token, refreshToken, expiry };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to store token
|
|
||||||
function storeToken(token: string, refreshToken: string, expiry: string): void {
|
|
||||||
localStorage.setItem(TOKEN_KEY, token);
|
|
||||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
|
||||||
localStorage.setItem(TOKEN_EXPIRY_KEY, expiry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to clear token
|
|
||||||
function clearToken(): void {
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
||||||
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider component
|
|
||||||
interface AuthProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: AuthProviderProps) {
|
|
||||||
const [state, setState] = useState<AuthState>({
|
|
||||||
token: null,
|
|
||||||
refreshToken: null,
|
|
||||||
user: null,
|
|
||||||
isAuthenticated: false,
|
|
||||||
isLoading: true,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize from storage
|
|
||||||
useEffect(() => {
|
|
||||||
const initializeAuth = async () => {
|
|
||||||
const { token, refreshToken, expiry } = getTokenFromStorage();
|
|
||||||
|
|
||||||
if (!token || isTokenExpired(expiry)) {
|
|
||||||
clearToken();
|
|
||||||
setState({
|
|
||||||
token: null,
|
|
||||||
refreshToken: null,
|
|
||||||
user: null,
|
|
||||||
isAuthenticated: false,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to get current user
|
|
||||||
const user = await authApi.getCurrentUser(token);
|
|
||||||
setState({
|
|
||||||
token,
|
|
||||||
refreshToken,
|
|
||||||
user,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
clearToken();
|
|
||||||
setState({
|
|
||||||
token: null,
|
|
||||||
refreshToken: null,
|
|
||||||
user: null,
|
|
||||||
isAuthenticated: false,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initializeAuth();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Login function
|
|
||||||
const login = useCallback(async (email: string, password: string) => {
|
|
||||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await authApi.login({ email, password });
|
|
||||||
storeToken(response.token, response.refreshToken, response.expiresAt);
|
|
||||||
|
|
||||||
// Get current user info
|
|
||||||
const user = await authApi.getCurrentUser(response.token);
|
|
||||||
|
|
||||||
setState({
|
|
||||||
token: response.token,
|
|
||||||
refreshToken: response.refreshToken,
|
|
||||||
user,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
isLoading: false,
|
|
||||||
error: err instanceof Error ? err.message : 'Login failed',
|
|
||||||
}));
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Register function
|
|
||||||
const register = useCallback(async (username: string, email: string, password: string) => {
|
|
||||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await authApi.register({ username, email, password });
|
|
||||||
storeToken(response.token, response.refreshToken, response.expiresAt);
|
|
||||||
|
|
||||||
// Get current user info
|
|
||||||
const user = await authApi.getCurrentUser(response.token);
|
|
||||||
|
|
||||||
setState({
|
|
||||||
token: response.token,
|
|
||||||
refreshToken: response.refreshToken,
|
|
||||||
user,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
isLoading: false,
|
|
||||||
error: err instanceof Error ? err.message : 'Registration failed',
|
|
||||||
}));
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Logout function
|
|
||||||
const logout = useCallback(() => {
|
|
||||||
clearToken();
|
|
||||||
setState({
|
|
||||||
token: null,
|
|
||||||
refreshToken: null,
|
|
||||||
user: null,
|
|
||||||
isAuthenticated: false,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Refresh token function
|
|
||||||
const refreshAuthToken = useCallback(async () => {
|
|
||||||
const { refreshToken } = getTokenFromStorage();
|
|
||||||
|
|
||||||
if (!refreshToken) {
|
|
||||||
logout();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await authApi.refreshToken({ refreshToken });
|
|
||||||
storeToken(response.token, response.refreshToken, response.expiresAt);
|
|
||||||
|
|
||||||
// Update state with new token
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
token: response.token,
|
|
||||||
refreshToken: response.refreshToken,
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
|
||||||
logout();
|
|
||||||
}
|
|
||||||
}, [logout]);
|
|
||||||
|
|
||||||
// Clear error function
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setState(prev => ({ ...prev, error: null }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value: AuthContextType = {
|
|
||||||
...state,
|
|
||||||
login,
|
|
||||||
register,
|
|
||||||
logout,
|
|
||||||
refreshAuthToken,
|
|
||||||
clearError,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook to use auth context
|
|
||||||
export function useAuth(): AuthContextType {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper hook to check if user is admin
|
|
||||||
export function useIsAdmin(): boolean {
|
|
||||||
const { user } = useAuth();
|
|
||||||
return user?.role === 'Admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to get auth header
|
|
||||||
export function getAuthHeader(token: string | null): Record<string, string> {
|
|
||||||
if (!token) return {};
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
/**
|
|
||||||
* Admin API types for the DeutschLernen frontend
|
|
||||||
* Mirrors the backend DTOs in GermanApp/Application/DTOs/Admin/
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Admin user list item
|
|
||||||
export interface AdminUserListItem {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
currentLevel: string;
|
|
||||||
totalPoints: number;
|
|
||||||
streak: number;
|
|
||||||
createdAt: string; // ISO date string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin user details
|
|
||||||
export interface AdminUserDetails {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
currentLevel: string;
|
|
||||||
streak: number;
|
|
||||||
totalPoints: number;
|
|
||||||
createdAt: string; // ISO date string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dashboard statistics
|
|
||||||
export interface DashboardStats {
|
|
||||||
totalUsers: number;
|
|
||||||
totalLessons: number;
|
|
||||||
totalQuizzes: number;
|
|
||||||
totalStorySegments: number;
|
|
||||||
activeUsersThisWeek: number;
|
|
||||||
averageUserProgress: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// User progress report
|
|
||||||
export interface UserProgressReport {
|
|
||||||
userId: number;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
currentLevel: string;
|
|
||||||
totalLessonsCompleted: number;
|
|
||||||
totalQuizzesCompleted: number;
|
|
||||||
averageQuizScore: number;
|
|
||||||
totalPoints: number;
|
|
||||||
currentStreak: number;
|
|
||||||
lastActivityDate: string; // ISO date string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lesson completion data
|
|
||||||
export interface LessonCompletion {
|
|
||||||
lessonId: number;
|
|
||||||
lessonTitle: string;
|
|
||||||
levelCode: string;
|
|
||||||
isCompleted: boolean;
|
|
||||||
completedAt: string | null; // ISO date string or null
|
|
||||||
}
|
|
||||||
|
|
||||||
// User quiz result
|
|
||||||
export interface UserQuizResult {
|
|
||||||
quizId: number;
|
|
||||||
quizTitle: string;
|
|
||||||
score: number;
|
|
||||||
passingScore: number;
|
|
||||||
passed: boolean;
|
|
||||||
attemptDate: string; // ISO date string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full user report
|
|
||||||
export interface FullUserReport {
|
|
||||||
progressReport: UserProgressReport;
|
|
||||||
lessonProgress: LessonCompletion[];
|
|
||||||
quizResults: UserQuizResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin create story request
|
|
||||||
export interface AdminCreateStoryRequest {
|
|
||||||
levelId: number;
|
|
||||||
theme: string;
|
|
||||||
segmentCount: number;
|
|
||||||
generateAudio: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin create story response
|
|
||||||
export interface AdminCreateStoryResponse {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
segmentsCreated: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user role request
|
|
||||||
export interface UpdateUserRoleRequest {
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete user response
|
|
||||||
export interface DeleteUserResponse {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSV export response
|
|
||||||
export interface CsvExportResponse {
|
|
||||||
csvContent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Story generation types
|
|
||||||
export interface LevelWithStories {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
code: string;
|
|
||||||
order: number;
|
|
||||||
hasStories: boolean;
|
|
||||||
storySegmentCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorySegment {
|
|
||||||
id: number;
|
|
||||||
levelId: number;
|
|
||||||
lessonId: number | null;
|
|
||||||
content: string;
|
|
||||||
audioUrl: string | null;
|
|
||||||
order: number;
|
|
||||||
title: string;
|
|
||||||
theme: string;
|
|
||||||
estimatedReadingMinutes: number;
|
|
||||||
isActive: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoryGenerationRequest {
|
|
||||||
levelId: number;
|
|
||||||
theme: string;
|
|
||||||
segmentCount: number;
|
|
||||||
customPrompt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoryGenerationResponse {
|
|
||||||
levelId: number;
|
|
||||||
theme: string;
|
|
||||||
segmentCount: number;
|
|
||||||
fullStoryText: string;
|
|
||||||
segments: StorySegment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateStorySegmentRequest {
|
|
||||||
levelId: number;
|
|
||||||
lessonId?: number;
|
|
||||||
content: string;
|
|
||||||
order: number;
|
|
||||||
title: string;
|
|
||||||
theme: string;
|
|
||||||
estimatedReadingMinutes?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateStorySegmentRequest {
|
|
||||||
content?: string;
|
|
||||||
order?: number;
|
|
||||||
title?: string;
|
|
||||||
theme?: string;
|
|
||||||
estimatedReadingMinutes?: number;
|
|
||||||
lessonId?: number;
|
|
||||||
isActive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
/**
|
|
||||||
* Auth API types for the DeutschLernen frontend
|
|
||||||
* Mirrors the backend DTOs in GermanApp/Application/DTOs/Auth/
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Register request
|
|
||||||
export interface RegisterRequest {
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login request
|
|
||||||
export interface LoginRequest {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth response (from backend)
|
|
||||||
export interface AuthResponse {
|
|
||||||
userId: number;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
token: string;
|
|
||||||
refreshToken: string;
|
|
||||||
expiresAt: string; // ISO date string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current user info
|
|
||||||
export interface CurrentUser {
|
|
||||||
userId: number;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
currentLevel: string;
|
|
||||||
streak: number;
|
|
||||||
totalPoints: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token refresh request
|
|
||||||
export interface RefreshTokenRequest {
|
|
||||||
refreshToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token refresh response
|
|
||||||
export interface RefreshTokenResponse {
|
|
||||||
token: string;
|
|
||||||
refreshToken: string;
|
|
||||||
expiresAt: string; // ISO date string
|
|
||||||
}
|
|
||||||
|
|
||||||
// User profile with role
|
|
||||||
export interface UserProfile {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
currentLevel: string;
|
|
||||||
streak: number;
|
|
||||||
totalPoints: number;
|
|
||||||
createdAt: string; // ISO date string
|
|
||||||
}
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
// Story Segment DTO types
|
|
||||||
// Mirror of GermanApp.Application.DTOs.StorySegmentDto
|
|
||||||
|
|
||||||
export interface StorySegmentDto {
|
|
||||||
id: number;
|
|
||||||
levelId: number;
|
|
||||||
lessonId: number | null;
|
|
||||||
content: string;
|
|
||||||
audioUrl: string | null;
|
|
||||||
order: number;
|
|
||||||
title: string;
|
|
||||||
theme: string;
|
|
||||||
estimatedReadingMinutes: number;
|
|
||||||
isActive: boolean;
|
|
||||||
createdAt: string; // ISO date string
|
|
||||||
updatedAt: string | null; // ISO date string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateStorySegmentDto {
|
|
||||||
levelId: number;
|
|
||||||
lessonId: number | null;
|
|
||||||
content: string;
|
|
||||||
order: number;
|
|
||||||
title: string;
|
|
||||||
theme: string;
|
|
||||||
estimatedReadingMinutes?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateStorySegmentDto {
|
|
||||||
content?: string | null;
|
|
||||||
order?: number | null;
|
|
||||||
title?: string | null;
|
|
||||||
theme?: string | null;
|
|
||||||
estimatedReadingMinutes?: number | null;
|
|
||||||
lessonId?: number | null;
|
|
||||||
isActive?: boolean | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoryGenerationRequestDto {
|
|
||||||
levelId: number;
|
|
||||||
theme: string;
|
|
||||||
segmentCount: number;
|
|
||||||
customPrompt?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoryGenerationResponseDto {
|
|
||||||
levelId: number;
|
|
||||||
theme: string;
|
|
||||||
segmentCount: number;
|
|
||||||
fullStoryText: string;
|
|
||||||
segments: StorySegmentDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Story Progress DTO types
|
|
||||||
export interface StorySegmentProgressDto {
|
|
||||||
segmentId: number;
|
|
||||||
order: number;
|
|
||||||
title: string;
|
|
||||||
isUnlocked: boolean;
|
|
||||||
isCompleted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoryProgressDto {
|
|
||||||
levelId: number;
|
|
||||||
levelName: string;
|
|
||||||
totalSegments: number;
|
|
||||||
unlockedSegments: number;
|
|
||||||
currentSegmentOrder: number;
|
|
||||||
segments: StorySegmentProgressDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Level and Lesson DTO types for story integration
|
|
||||||
export interface LevelWithStoriesDto {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
code: string;
|
|
||||||
order: number;
|
|
||||||
hasStories: boolean;
|
|
||||||
storySegmentCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LessonWithStoryStatusDto {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
order: number;
|
|
||||||
topic: string;
|
|
||||||
hasStorySegment: boolean;
|
|
||||||
storySegmentCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API Response types
|
|
||||||
export interface StoryAudioResponse {
|
|
||||||
audioUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vocabulary for word translation
|
|
||||||
export interface WordTranslation {
|
|
||||||
word: string;
|
|
||||||
translation: string;
|
|
||||||
partOfSpeech: string;
|
|
||||||
gender?: string; // for German nouns (der/die/das)
|
|
||||||
}
|
|
||||||
|
|
@ -19,14 +19,7 @@
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true
|
||||||
|
|
||||||
/* Path aliases */
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["src/*"]
|
|
||||||
},
|
|
||||||
"ignoreDeprecations": "6.0"
|
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, 'src'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue