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>
489 lines
17 KiB
C#
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);
|