DeutschLernen/GermanApp/Presentation/Controllers/StoryController.cs
Lasse Rune Hansen 6693165f83 feat(backend/story-integration): Phase 2 - Backend Services
Implement story integration backend services:
- Create StoryService (Application/Services/StoryService.cs) with full CRUD operations
- Create StoryGenerationService (Application/Services/StoryGenerationService.cs) for AI-powered story generation
  - Uses IMistralService for text generation
  - Uses ITtsService for audio generation
  - Splits stories into segments per lesson
- Create StoryUnlockService (Application/Services/StoryUnlockService.cs) for progress management
  - Handles lesson completion → story segment unlocking
- Create StoryController (Presentation/Controllers/StoryController.cs) with 12 endpoints:
  - GET /api/story/levels - list levels with stories
  - GET /api/story/levels/{levelId}/segments - get segments for level
  - GET /api/story/segments/{id} - get specific segment
  - POST /api/story/segments - create segment (Admin)
  - PUT /api/story/segments/{id} - update segment (Admin)
  - DELETE /api/story/segments/{id} - delete segment (Admin)
  - POST /api/story/levels/{levelId}/generate - generate story with AI (Admin)
  - POST /api/story/segments/{segmentId}/audio - generate audio (Admin)
  - GET /api/story/levels/{levelId}/progress - get user progress
  - POST /api/story/segments/{segmentId}/complete - mark as completed
  - GET /api/story/levels/{levelId}/next - get next segment
  - GET /api/story/segments/{segmentId}/unlocked - check if unlocked
  - GET /api/story/segments/{segmentId}/audio - get audio URL
  - GET /api/story/levels/{levelId}/lessons-with-stories - get lessons with story status
- Register all services in Program.cs DI container
- Update feature document to reflect Phase 2 completion

Next: Phase 3 - AI Integration (unit tests for services)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-13 13:37:16 +02:00

489 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]
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.LogWarning("No story segments found for level {LevelId}", levelId);
return 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();
}
// In a real implementation, return the actual file
// For now, return the URL
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);