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; /// /// API controller for story operations. /// This is part of the Presentation layer. /// [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 _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.LogWarning("No story segments found for level {LevelId}", levelId); return 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, 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() { 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; } } /// /// 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);