DeutschLernen/GermanApp/Application/Services/StoryService.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

487 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GermanApp.Application.DTOs;
using GermanApp.Domain.Entities;
using GermanApp.Domain.Interfaces;
using Microsoft.Extensions.Logging;
namespace GermanApp.Application.Services;
/// <summary>
/// Application service for managing story segments.
/// This is part of the Application layer.
/// </summary>
public class StoryService
{
private readonly IStoryRepository _storyRepository;
private readonly IStoryProgressRepository _progressRepository;
private readonly ILogger<StoryService> _logger;
/// <summary>
/// Creates a new StoryService.
/// </summary>
/// <param name="storyRepository">Repository for story segments</param>
/// <param name="progressRepository">Repository for story progress</param>
/// <param name="logger">Logger for service operations</param>
public StoryService(
IStoryRepository storyRepository,
IStoryProgressRepository progressRepository,
ILogger<StoryService> logger)
{
_storyRepository = storyRepository;
_progressRepository = progressRepository;
_logger = logger;
}
/// <summary>
/// Gets a story segment by its ID.
/// </summary>
/// <param name="id">The segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The story segment DTO, or null if not found</returns>
public virtual async Task<StorySegmentDto?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Getting story segment by ID: {Id}", id);
var segment = await _storyRepository.GetByIdAsync(id, cancellationToken);
if (segment == null)
{
_logger.LogWarning("Story segment not found: {Id}", id);
return null;
}
return StorySegmentDto.FromEntity(segment);
}
/// <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 segment DTOs</returns>
public virtual async Task<IReadOnlyList<StorySegmentDto>> GetByLevelAsync(
int levelId,
bool includeInactive = false,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Getting story segments for level: {LevelId}", levelId);
var segments = await _storyRepository.GetByLevelAsync(levelId, includeInactive, cancellationToken);
return segments.Select(StorySegmentDto.FromEntity).ToList();
}
/// <summary>
/// Gets all story segments for a specific lesson.
/// </summary>
/// <param name="lessonId">The lesson ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of story segment DTOs</returns>
public virtual async Task<IReadOnlyList<StorySegmentDto>> GetByLessonAsync(
int lessonId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Getting story segments for lesson: {LessonId}", lessonId);
var segments = await _storyRepository.GetByLessonAsync(lessonId, cancellationToken);
return segments.Select(StorySegmentDto.FromEntity).ToList();
}
/// <summary>
/// Creates a new story segment.
/// </summary>
/// <param name="dto">The DTO containing segment data</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The created story segment DTO</returns>
public virtual async Task<StorySegmentDto> CreateAsync(
CreateStorySegmentDto dto,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Creating story segment: LevelId={LevelId}, Order={Order}, Title={Title}",
dto.LevelId, dto.Order, dto.Title);
// Check if a segment with the same level and order already exists
var existing = await _storyRepository.GetByOrderRangeAsync(
dto.LevelId, dto.Order, dto.Order, cancellationToken);
if (existing.Any())
{
_logger.LogError(
"Story segment with LevelId={LevelId} and Order={Order} already exists",
dto.LevelId, dto.Order);
throw new InvalidOperationException(
$"A story segment with Order={dto.Order} already exists for level {dto.LevelId}");
}
// Create the entity
var segment = StorySegment.Create(
dto.LevelId,
dto.LessonId,
dto.Content,
dto.Order,
dto.Title,
dto.Theme,
dto.EstimatedReadingMinutes);
// Save to repository
var created = await _storyRepository.AddAsync(segment, cancellationToken);
_logger.LogInformation("Created story segment with ID: {Id}", created.Id);
return StorySegmentDto.FromEntity(created);
}
/// <summary>
/// Updates an existing story segment.
/// </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 DTO, or null if not found</returns>
public virtual async Task<StorySegmentDto?> UpdateAsync(
int id,
UpdateStorySegmentDto dto,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Updating story segment: {Id}", id);
var segment = await _storyRepository.GetByIdAsync(id, cancellationToken);
if (segment == null)
{
_logger.LogWarning("Story segment not found for update: {Id}", id);
return null;
}
// Apply updates
if (dto.Content != null)
segment.UpdateContent(dto.Content);
if (dto.Title != null)
segment.UpdateTitle(dto.Title);
if (dto.Theme != null)
segment.UpdateTheme(dto.Theme);
if (dto.Order != null)
segment.UpdateOrder(dto.Order.Value);
if (dto.EstimatedReadingMinutes != null)
segment.UpdateEstimatedReadingMinutes(dto.EstimatedReadingMinutes.Value);
if (dto.LessonId != null)
segment.UpdateLesson(dto.LessonId);
if (dto.IsActive != null)
{
if (dto.IsActive.Value)
segment.Activate();
else
segment.Deactivate();
}
// Save changes
await _storyRepository.UpdateAsync(segment, cancellationToken);
_logger.LogInformation("Updated story segment: {Id}", id);
return StorySegmentDto.FromEntity(segment);
}
/// <summary>
/// Deletes a story segment by its ID.
/// </summary>
/// <param name="id">The segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if the segment was deleted, false if not found</returns>
public virtual async Task<bool> DeleteAsync(int id, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Deleting story segment: {Id}", id);
var exists = await _storyRepository.ExistsAsync(id, cancellationToken);
if (!exists)
{
_logger.LogWarning("Story segment not found for deletion: {Id}", id);
return false;
}
await _storyRepository.DeleteAsync(id, cancellationToken);
_logger.LogInformation("Deleted story segment: {Id}", id);
return true;
}
/// <summary>
/// Gets the next segment to unlock for a user after completing a lesson.
/// </summary>
/// <param name="levelId">The level ID</param>
/// <param name="completedLessonOrder">The order of the completed lesson</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The next story segment DTO, or null if none</returns>
public virtual async Task<StorySegmentDto?> GetNextSegmentToUnlockAsync(
int levelId,
int completedLessonOrder,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Getting next segment to unlock for level {LevelId} after lesson {LessonOrder}",
levelId, completedLessonOrder);
var segment = await _storyRepository.GetNextSegmentToUnlockAsync(
levelId, completedLessonOrder, cancellationToken);
if (segment == null)
{
_logger.LogInformation("No segment to unlock for level {LevelId} after lesson {LessonOrder}",
levelId, completedLessonOrder);
return null;
}
return StorySegmentDto.FromEntity(segment);
}
/// <summary>
/// Checks if a user has unlocked a specific story segment.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="segmentId">The segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if the segment is unlocked</returns>
public virtual async Task<bool> IsSegmentUnlockedAsync(
int userId,
int segmentId,
CancellationToken cancellationToken = default)
{
return await _progressRepository.IsSegmentUnlockedAsync(userId, segmentId, cancellationToken);
}
/// <summary>
/// Checks if a user has completed (read/listened to) a specific story segment.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="segmentId">The segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if the segment is completed</returns>
public virtual async Task<bool> IsSegmentCompletedAsync(
int userId,
int segmentId,
CancellationToken cancellationToken = default)
{
return await _progressRepository.IsSegmentCompletedAsync(userId, segmentId, cancellationToken);
}
/// <summary>
/// Gets all segments that need audio generation.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of story segment DTOs needing audio</returns>
public virtual async Task<IReadOnlyList<StorySegmentDto>> GetSegmentsNeedingAudioAsync(
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Getting story segments needing audio generation");
var segments = await _storyRepository.GetSegmentsNeedingAudioAsync(cancellationToken);
return segments.Select(StorySegmentDto.FromEntity).ToList();
}
/// <summary>
/// Updates the audio URL for a story segment.
/// </summary>
/// <param name="id">The segment ID</param>
/// <param name="audioUrl">The audio URL</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The updated story segment DTO, or null if not found</returns>
public virtual async Task<StorySegmentDto?> UpdateAudioUrlAsync(
int id,
string audioUrl,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Updating audio URL for story segment: {Id}", id);
var segment = await _storyRepository.GetByIdAsync(id, cancellationToken);
if (segment == null)
{
_logger.LogWarning("Story segment not found for audio update: {Id}", id);
return null;
}
segment.UpdateAudioUrl(audioUrl);
await _storyRepository.UpdateAsync(segment, cancellationToken);
_logger.LogInformation("Updated audio URL for story segment: {Id}", id);
return StorySegmentDto.FromEntity(segment);
}
/// <summary>
/// Gets a user's progress through a story.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="levelId">The level ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Story progress DTO</returns>
public virtual async Task<StoryProgressDto> GetUserProgressAsync(
int userId,
int levelId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Getting story progress for user {UserId} in level {LevelId}",
userId, levelId);
// Get all segments for the level
var segments = await _storyRepository.GetByLevelAsync(levelId, false, cancellationToken);
if (!segments.Any())
{
_logger.LogWarning("No story segments found for level: {LevelId}", levelId);
return new StoryProgressDto(
levelId,
"Unknown",
0,
0,
0,
Array.Empty<StorySegmentProgressDto>());
}
// Get user's progress for this level
var userProgress = await _progressRepository.GetByUserAndLevelAsync(userId, levelId, cancellationToken);
var totalSegments = segments.Count;
var unlockedSegments = userProgress.Count;
var highestUnlocked = await _progressRepository.GetHighestUnlockedOrderAsync(
userId, levelId, cancellationToken);
// Build segment progress list
var segmentProgress = new List<StorySegmentProgressDto>();
foreach (var segment in segments)
{
var isUnlocked = await _progressRepository.IsSegmentUnlockedAsync(
userId, segment.Id, cancellationToken);
var isCompleted = await _progressRepository.IsSegmentCompletedAsync(
userId, segment.Id, cancellationToken);
segmentProgress.Add(new StorySegmentProgressDto(
segment.Id,
segment.Order,
segment.Title,
isUnlocked,
isCompleted));
}
// Get level name from first segment (or use code as fallback)
var levelName = segments.First().Level?.Name ?? segments.First().Level?.Code ?? "Unknown";
return new StoryProgressDto(
levelId,
levelName,
totalSegments,
unlockedSegments,
highestUnlocked,
segmentProgress);
}
/// <summary>
/// Unlocks the next story segment for a user after completing a lesson.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="levelId">The level ID</param>
/// <param name="completedLessonOrder">The order of the completed lesson</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The unlocked story segment DTO, or null if none to unlock</returns>
public virtual async Task<StorySegmentDto?> UnlockNextSegmentAsync(
int userId,
int levelId,
int completedLessonOrder,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Unlocking next story segment for user {UserId} in level {LevelId} after lesson {LessonOrder}",
userId, levelId, completedLessonOrder);
// Get the next segment to unlock
var segment = await _storyRepository.GetNextSegmentToUnlockAsync(
levelId, completedLessonOrder, cancellationToken);
if (segment == null)
{
_logger.LogInformation(
"No segment to unlock for user {UserId} in level {LevelId} after lesson {LessonOrder}",
userId, levelId, completedLessonOrder);
return null;
}
// Check if user already has this segment unlocked
var alreadyUnlocked = await _progressRepository.IsSegmentUnlockedAsync(
userId, segment.Id, cancellationToken);
if (alreadyUnlocked)
{
_logger.LogInformation(
"Segment {SegmentId} already unlocked for user {UserId}",
segment.Id, userId);
return StorySegmentDto.FromEntity(segment);
}
// Create progress record
var progress = StoryProgress.Create(userId, levelId, segment.Id);
await _progressRepository.AddAsync(progress, cancellationToken);
_logger.LogInformation(
"Unlocked segment {SegmentId} for user {UserId} in level {LevelId}",
segment.Id, userId, levelId);
return StorySegmentDto.FromEntity(segment);
}
/// <summary>
/// Marks a story segment as completed (read/listened to) for a user.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="segmentId">The segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if the segment was marked as completed, false if not found or already completed</returns>
public virtual async Task<bool> MarkSegmentAsCompletedAsync(
int userId,
int segmentId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Marking segment {SegmentId} as completed for user {UserId}",
segmentId, userId);
var progress = await _progressRepository.GetByUserAndSegmentAsync(
userId, segmentId, cancellationToken);
if (progress == null)
{
_logger.LogWarning(
"Progress record not found for user {UserId} and segment {SegmentId}",
userId, segmentId);
return false;
}
if (progress.IsCompleted)
{
_logger.LogInformation("Segment already completed for user {UserId}", userId);
return false;
}
progress.MarkAsCompleted();
await _progressRepository.UpdateAsync(progress, cancellationToken);
_logger.LogInformation("Marked segment {SegmentId} as completed for user {UserId}",
segmentId, userId);
return true;
}
}