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>
487 lines
18 KiB
C#
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;
|
|
}
|
|
}
|