using System.Collections.Generic;
using System.Security.Claims;
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;
///
/// API controller for story operations.
/// This is part of the Presentation layer.
///
[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 _logger;
///
/// Creates a new StoryController.
///
/// Service for story operations
/// Service for story generation
/// Service for story unlocking
/// Repository for levels
/// Repository for lessons
/// Logger for controller operations
public StoryController(
StoryService storyService,
StoryGenerationService generationService,
StoryUnlockService unlockService,
ILevelRepository levelRepository,
ILessonRepository lessonRepository,
ILogger logger)
{
_storyService = storyService;
_generationService = generationService;
_unlockService = unlockService;
_levelRepository = levelRepository;
_lessonRepository = lessonRepository;
_logger = logger;
}
///
/// Gets all levels that have stories.
///
/// Cancellation token
/// List of levels with story segments
[HttpGet("levels")]
public async Task>> GetLevelsWithStoriesAsync(
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Getting levels with stories");
var levels = await _levelRepository.GetAllOrderedAsync(cancellationToken);
var result = new List();
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);
}
///
/// Gets all story segments for a specific level.
///
/// The level ID
/// Whether to include inactive segments
/// Cancellation token
/// List of story segments for the level
[HttpGet("levels/{levelId}/segments")]
public async Task>> 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()); // Return empty list instead of NotFound
}
return Ok(segments);
}
///
/// Gets a specific story segment by ID.
///
/// The segment ID
/// Cancellation token
/// The story segment
[HttpGet("segments/{id}")]
public async Task> 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);
}
///
/// Creates a new story segment (Admin only).
///
/// The DTO containing segment data
/// Cancellation token
/// The created story segment
[HttpPost("segments")]
[Authorize(Roles = "Admin")]
public async Task> 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);
}
}
///
/// Updates an existing story segment (Admin only).
///
/// The segment ID
/// The DTO containing updated data
/// Cancellation token
/// The updated story segment
[HttpPut("segments/{id}")]
[Authorize(Roles = "Admin")]
public async Task> 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);
}
///
/// Deletes a story segment (Admin only).
///
/// The segment ID
/// Cancellation token
/// No content if successful
[HttpDelete("segments/{id}")]
[Authorize(Roles = "Admin")]
public async Task 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();
}
///
/// Generates a story for a level using AI.
///
/// The level ID
/// The generation request
/// Cancellation token
/// The generated story with segments
[HttpPost("levels/{levelId}/generate")]
[Authorize(Roles = "Admin")]
public async Task> 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,
request.SegmentCount,
request.CustomPrompt,
cancellationToken);
return Ok(response);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate story for level {LevelId}", levelId);
return StatusCode(500, ex.Message);
}
}
///
/// Generates audio for a specific story segment.
///
/// The segment ID
/// Cancellation token
/// The updated segment with audio URL
[HttpPost("segments/{segmentId}/audio")]
[Authorize(Roles = "Admin")]
public async Task> 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);
}
}
///
/// Gets the user's progress through a story for a level.
///
/// The level ID
/// Cancellation token
/// The user's story progress
[HttpGet("levels/{levelId}/progress")]
public async Task> 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);
}
///
/// Marks a story segment as completed (read/listened to).
///
/// The segment ID
/// Cancellation token
/// No content if successful
[HttpPost("segments/{segmentId}/complete")]
public async Task 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();
}
///
/// Gets the next story segment for a user to read.
///
/// The level ID
/// Cancellation token
/// The next segment to read
[HttpGet("levels/{levelId}/next")]
public async Task> 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
}
///
/// Checks if a specific segment is unlocked for the current user.
///
/// The segment ID
/// Cancellation token
/// True if unlocked
[HttpGet("segments/{segmentId}/unlocked")]
public async Task> 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);
}
///
/// Gets the audio file for a story segment.
///
/// The segment ID
/// Cancellation token
/// The audio file
[HttpGet("segments/{segmentId}/audio")]
public async Task 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 });
}
///
/// Gets all lessons in a level with their associated story segments.
///
/// The level ID
/// Cancellation token
/// List of lessons with story segment status
[HttpGet("levels/{levelId}/lessons-with-stories")]
public async Task>> 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();
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);
}
///
/// Gets the current user's ID from the JWT claims.
///
/// The user ID
private int GetUserId()
{
// Note: JWT "sub" claim is mapped by ASP.NET Core JWT middleware to ClaimTypes.NameIdentifier
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier) ?? User.FindFirst("sub");
if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId))
{
throw new UnauthorizedAccessException("User ID not found in token");
}
return userId;
}
}
///
/// DTO for level with story information.
///
public record LevelWithStoriesDto(
int Id,
string Name,
string Code,
int Order,
bool HasStories,
int StorySegmentCount);
///
/// DTO for lesson with story segment status.
///
public record LessonWithStoryStatusDto(
int Id,
string Title,
int Order,
string Topic,
bool HasStorySegment,
int StorySegmentCount);