DeutschLernen/GermanApp/Presentation/Controllers/StoryController.cs
Lasse Rune Hansen be692886a9 feat(docs/admin): Add Admin Module feature specification and update roadmap
- Created admin-module.md with comprehensive feature documentation
- Updated README.md roadmap with current status and progress
- Added [Authorize] to StoryController GET endpoints
- Documented requirements:
  - Mandatory user registration before accessing content
  - Individual user progress tracking
  - Admin module (Lasse only)
  - Admin story generation functionality
  - Admin user progress reports
- Updated development roadmap with Phase 4 (Admin Module)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-14 11:45:55 +02:00

490 lines
17 KiB
C#

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GermanApp.Application.DTOs;
using GermanApp.Application.Services;
using GermanApp.Domain.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace GermanApp.Presentation.Controllers;
/// <summary>
/// API controller for story operations.
/// This is part of the Presentation layer.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize] // All story endpoints require authentication
public class StoryController : ControllerBase
{
private readonly StoryService _storyService;
private readonly StoryGenerationService _generationService;
private readonly StoryUnlockService _unlockService;
private readonly ILevelRepository _levelRepository;
private readonly ILessonRepository _lessonRepository;
private readonly ILogger<StoryController> _logger;
/// <summary>
/// Creates a new StoryController.
/// </summary>
/// <param name="storyService">Service for story operations</param>
/// <param name="generationService">Service for story generation</param>
/// <param name="unlockService">Service for story unlocking</param>
/// <param name="levelRepository">Repository for levels</param>
/// <param name="lessonRepository">Repository for lessons</param>
/// <param name="logger">Logger for controller operations</param>
public StoryController(
StoryService storyService,
StoryGenerationService generationService,
StoryUnlockService unlockService,
ILevelRepository levelRepository,
ILessonRepository lessonRepository,
ILogger<StoryController> logger)
{
_storyService = storyService;
_generationService = generationService;
_unlockService = unlockService;
_levelRepository = levelRepository;
_lessonRepository = lessonRepository;
_logger = logger;
}
/// <summary>
/// Gets all levels that have stories.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of levels with story segments</returns>
[HttpGet("levels")]
public async Task<ActionResult<IReadOnlyList<LevelWithStoriesDto>>> GetLevelsWithStoriesAsync(
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Getting levels with stories");
var levels = await _levelRepository.GetAllOrderedAsync(cancellationToken);
var result = new List<LevelWithStoriesDto>();
foreach (var level in levels)
{
var segments = await _storyService.GetByLevelAsync(level.Id, false, cancellationToken);
var hasStories = segments.Count > 0;
result.Add(new LevelWithStoriesDto(
level.Id,
level.Name,
level.Code,
level.Order,
hasStories,
segments.Count));
}
return Ok(result);
}
/// <summary>
/// Gets all story segments for a specific level.
/// </summary>
/// <param name="levelId">The level ID</param>
/// <param name="includeInactive">Whether to include inactive segments</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of story segments for the level</returns>
[HttpGet("levels/{levelId}/segments")]
public async Task<ActionResult<IReadOnlyList<StorySegmentDto>>> GetSegmentsByLevelAsync(
int levelId,
[FromQuery] bool includeInactive = false,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Getting story segments for level {LevelId}", levelId);
var segments = await _storyService.GetByLevelAsync(levelId, includeInactive, cancellationToken);
if (!segments.Any())
{
_logger.LogInformation("No story segments found for level {LevelId}", levelId);
return Ok(new List<StorySegmentDto>()); // Return empty list instead of NotFound
}
return Ok(segments);
}
/// <summary>
/// Gets a specific story segment by ID.
/// </summary>
/// <param name="id">The segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The story segment</returns>
[HttpGet("segments/{id}")]
public async Task<ActionResult<StorySegmentDto>> GetSegmentByIdAsync(
int id,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Getting story segment by ID: {Id}", id);
var segment = await _storyService.GetByIdAsync(id, cancellationToken);
if (segment == null)
{
_logger.LogWarning("Story segment not found: {Id}", id);
return NotFound();
}
return Ok(segment);
}
/// <summary>
/// Creates a new story segment (Admin only).
/// </summary>
/// <param name="dto">The DTO containing segment data</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The created story segment</returns>
[HttpPost("segments")]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<StorySegmentDto>> CreateSegmentAsync(
[FromBody] CreateStorySegmentDto dto,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Creating story segment");
try
{
var created = await _storyService.CreateAsync(dto, cancellationToken);
return CreatedAtAction(nameof(GetSegmentByIdAsync), new { id = created.Id }, created);
}
catch (InvalidOperationException ex)
{
_logger.LogError(ex, "Failed to create story segment");
return Conflict(ex.Message);
}
}
/// <summary>
/// Updates an existing story segment (Admin only).
/// </summary>
/// <param name="id">The segment ID</param>
/// <param name="dto">The DTO containing updated data</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The updated story segment</returns>
[HttpPut("segments/{id}")]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<StorySegmentDto>> UpdateSegmentAsync(
int id,
[FromBody] UpdateStorySegmentDto dto,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Updating story segment {Id}", id);
var updated = await _storyService.UpdateAsync(id, dto, cancellationToken);
if (updated == null)
{
_logger.LogWarning("Story segment not found for update: {Id}", id);
return NotFound();
}
return Ok(updated);
}
/// <summary>
/// Deletes a story segment (Admin only).
/// </summary>
/// <param name="id">The segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>No content if successful</returns>
[HttpDelete("segments/{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteSegmentAsync(
int id,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Deleting story segment {Id}", id);
var deleted = await _storyService.DeleteAsync(id, cancellationToken);
if (!deleted)
{
_logger.LogWarning("Story segment not found for deletion: {Id}", id);
return NotFound();
}
return NoContent();
}
/// <summary>
/// Generates a story for a level using AI.
/// </summary>
/// <param name="levelId">The level ID</param>
/// <param name="request">The generation request</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The generated story with segments</returns>
[HttpPost("levels/{levelId}/generate")]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<StoryGenerationResponseDto>> GenerateStoryAsync(
int levelId,
[FromBody] StoryGenerationRequestDto request,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Generating story for level {LevelId} with theme '{Theme}'",
levelId, request.Theme);
try
{
// Get all lessons for the level
var lessons = await _lessonRepository.GetByLevelAsync(levelId, cancellationToken);
if (!lessons.Any())
{
_logger.LogWarning("No lessons found for level {LevelId}", levelId);
return BadRequest("No lessons found for this level");
}
var response = await _generationService.GenerateStoryAsync(
levelId,
request.Theme,
lessons,
cancellationToken);
return Ok(response);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate story for level {LevelId}", levelId);
return StatusCode(500, ex.Message);
}
}
/// <summary>
/// Generates audio for a specific story segment.
/// </summary>
/// <param name="segmentId">The segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The updated segment with audio URL</returns>
[HttpPost("segments/{segmentId}/audio")]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<StorySegmentDto>> GenerateSegmentAudioAsync(
int segmentId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Generating audio for story segment {SegmentId}", segmentId);
try
{
var result = await _generationService.GenerateAudioAsync(segmentId, cancellationToken);
if (result == null)
{
_logger.LogWarning("Segment not found or audio generation failed: {SegmentId}", segmentId);
return NotFound();
}
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate audio for segment {SegmentId}", segmentId);
return StatusCode(500, ex.Message);
}
}
/// <summary>
/// Gets the user's progress through a story for a level.
/// </summary>
/// <param name="levelId">The level ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The user's story progress</returns>
[HttpGet("levels/{levelId}/progress")]
public async Task<ActionResult<StoryProgressDto>> GetUserProgressAsync(
int levelId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Getting story progress for user in level {LevelId}", levelId);
var userId = GetUserId();
var progress = await _unlockService.GetUserProgressAsync(userId, levelId, cancellationToken);
return Ok(progress);
}
/// <summary>
/// Marks a story segment as completed (read/listened to).
/// </summary>
/// <param name="segmentId">The segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>No content if successful</returns>
[HttpPost("segments/{segmentId}/complete")]
public async Task<IActionResult> MarkSegmentAsCompletedAsync(
int segmentId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Marking segment {SegmentId} as completed", segmentId);
var userId = GetUserId();
var success = await _unlockService.MarkSegmentAsCompletedAsync(
userId, segmentId, cancellationToken);
if (!success)
{
_logger.LogWarning("Failed to mark segment {SegmentId} as completed for user {UserId}",
segmentId, userId);
return NotFound();
}
return NoContent();
}
/// <summary>
/// Gets the next story segment for a user to read.
/// </summary>
/// <param name="levelId">The level ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The next segment to read</returns>
[HttpGet("levels/{levelId}/next")]
public async Task<ActionResult<StorySegmentDto?>> GetNextSegmentAsync(
int levelId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Getting next story segment for user in level {LevelId}", levelId);
var userId = GetUserId();
// Get user's progress
var progress = await _unlockService.GetUserProgressAsync(userId, levelId, cancellationToken);
// Find the first unlocked but not completed segment
foreach (var segmentProgress in progress.Segments.OrderBy(s => s.Order))
{
if (segmentProgress.IsUnlocked && !segmentProgress.IsCompleted)
{
var segment = await _storyService.GetByIdAsync(segmentProgress.SegmentId, cancellationToken);
if (segment != null)
{
return Ok(segment);
}
}
}
// If no segment is unlocked, check if we can unlock one
// This would typically be triggered by lesson completion, but we can check here too
return Ok(null); // No segment available
}
/// <summary>
/// Checks if a specific segment is unlocked for the current user.
/// </summary>
/// <param name="segmentId">The segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if unlocked</returns>
[HttpGet("segments/{segmentId}/unlocked")]
public async Task<ActionResult<bool>> IsSegmentUnlockedAsync(
int segmentId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Checking if segment {SegmentId} is unlocked", segmentId);
var userId = GetUserId();
var isUnlocked = await _unlockService.IsSegmentUnlockedAsync(userId, segmentId, cancellationToken);
return Ok(isUnlocked);
}
/// <summary>
/// Gets the audio file for a story segment.
/// </summary>
/// <param name="segmentId">The segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The audio file</returns>
[HttpGet("segments/{segmentId}/audio")]
public async Task<IActionResult> GetSegmentAudioAsync(
int segmentId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Getting audio for segment {SegmentId}", segmentId);
var segment = await _storyService.GetByIdAsync(segmentId, cancellationToken);
if (segment == null || string.IsNullOrEmpty(segment.AudioUrl))
{
_logger.LogWarning("Segment not found or no audio: {SegmentId}", segmentId);
return NotFound();
}
// Static files are served from wwwroot, and audio files are stored at wwwroot/audio/story/
// The segment.AudioUrl is already in the format "/audio/story/levelN-segmentM.wav"
// The static file middleware will serve it automatically
return Ok(new { AudioUrl = segment.AudioUrl });
}
/// <summary>
/// Gets all lessons in a level with their associated story segments.
/// </summary>
/// <param name="levelId">The level ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of lessons with story segment status</returns>
[HttpGet("levels/{levelId}/lessons-with-stories")]
public async Task<ActionResult<IReadOnlyList<LessonWithStoryStatusDto>>> GetLessonsWithStoryStatusAsync(
int levelId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Getting lessons with story status for level {LevelId}", levelId);
var lessons = await _lessonRepository.GetByLevelAsync(levelId, cancellationToken);
var segments = await _storyService.GetByLevelAsync(levelId, false, cancellationToken);
var result = new List<LessonWithStoryStatusDto>();
foreach (var lesson in lessons)
{
var lessonSegments = segments.Where(s => s.LessonId == lesson.Id).ToList();
result.Add(new LessonWithStoryStatusDto(
lesson.Id,
lesson.Title,
lesson.Order,
lesson.Topic,
lessonSegments.Count > 0,
lessonSegments.Count));
}
return Ok(result);
}
/// <summary>
/// Gets the current user's ID from the JWT claims.
/// </summary>
/// <returns>The user ID</returns>
private int GetUserId()
{
var userIdClaim = User.FindFirst("sub") ?? User.FindFirst("nameidentifier");
if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId))
{
throw new UnauthorizedAccessException("User ID not found in token");
}
return userId;
}
}
/// <summary>
/// DTO for level with story information.
/// </summary>
public record LevelWithStoriesDto(
int Id,
string Name,
string Code,
int Order,
bool HasStories,
int StorySegmentCount);
/// <summary>
/// DTO for lesson with story segment status.
/// </summary>
public record LessonWithStoryStatusDto(
int Id,
string Title,
int Order,
string Topic,
bool HasStorySegment,
int StorySegmentCount);