diff --git a/GermanApp/Application/Services/StoryGenerationService.cs b/GermanApp/Application/Services/StoryGenerationService.cs
index c2a4380..105c940 100644
--- a/GermanApp/Application/Services/StoryGenerationService.cs
+++ b/GermanApp/Application/Services/StoryGenerationService.cs
@@ -1,238 +1,483 @@
+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;
///
-/// Application service for generating stories using Mistral AI.
-/// This is part of the Application layer.
+/// Application service for generating stories using AI.
+/// This is part of the Application layer and uses MistralService for text generation.
///
public class StoryGenerationService
{
private readonly IMistralService _mistralService;
+ private readonly IStoryRepository _storyRepository;
+ private readonly ITtsService _ttsService;
private readonly ILogger _logger;
///
/// Creates a new StoryGenerationService.
///
- /// The Mistral text generation service
+ /// Mistral service for text generation
+ /// Repository for story segments
+ /// TTS service for audio generation
/// Logger for service operations
public StoryGenerationService(
IMistralService mistralService,
+ IStoryRepository storyRepository,
+ ITtsService ttsService,
ILogger logger)
{
_mistralService = mistralService;
+ _storyRepository = storyRepository;
+ _ttsService = ttsService;
_logger = logger;
}
///
- /// Generates a story based on the given parameters.
+ /// Generates a complete story for a level and divides it into segments.
///
- /// The CEFR level (A1, A2, B1, B2, C1)
- /// The story topic or theme
- /// List of German vocabulary words to include in the story
- /// Approximate word count for the story
+ /// The level ID
+ /// The theme for the story
+ /// List of lessons in the level with their vocabulary
/// Cancellation token
- /// The generated story text in German
- public virtual async Task GenerateStoryAsync(
- string level,
- string topic,
- IReadOnlyList vocabularyWords,
- int length = 200,
+ /// Response DTO with the full story and segments
+ public virtual async Task GenerateStoryAsync(
+ int levelId,
+ string theme,
+ IReadOnlyList lessons,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
- "Generating story: Level={Level}, Topic={Topic}, VocabularyCount={Count}, Length={Length}",
- level, topic, vocabularyWords?.Count ?? 0, length);
+ "Generating story for level {LevelId} with theme '{Theme}'",
+ levelId, theme);
+ // Extract vocabulary from all lessons
+ var allVocabulary = ExtractVocabularyFromLessons(lessons);
+
+ if (!allVocabulary.Any())
+ {
+ _logger.LogWarning("No vocabulary found for level {LevelId}", levelId);
+ throw new InvalidOperationException("Cannot generate story: No vocabulary available");
+ }
+
+ // Build the prompt for Mistral
+ var prompt = BuildStoryPrompt(levelId, theme, allVocabulary, lessons.Count);
+
+ _logger.LogDebug("Story generation prompt: {Prompt}", prompt);
+
+ // Generate the full story
+ var fullStory = await _mistralService.GenerateStoryAsync(
+ GetLevelCode(levelId),
+ theme,
+ allVocabulary,
+ 500, // Approximate word count
+ cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(fullStory))
+ {
+ _logger.LogError("Failed to generate story: Empty response from AI service");
+ throw new InvalidOperationException("Failed to generate story: Empty response");
+ }
+
+ _logger.LogInformation("Generated story text (length: {Length} chars)", fullStory.Length);
+
+ // Split the story into segments (one per lesson)
+ var segments = SplitStoryIntoSegments(fullStory, lessons.Count);
+
+ if (segments.Count != lessons.Count)
+ {
+ _logger.LogWarning(
+ "Story split into {SegmentCount} segments but expected {LessonCount}",
+ segments.Count, lessons.Count);
+ // Adjust: if we have fewer segments, duplicate the last one
+ // If we have more, combine some
+ segments = AdjustSegmentCount(segments, lessons.Count);
+ }
+
+ // Create story segment entities
+ var createdSegments = new List();
+ for (int i = 0; i < segments.Count; i++)
+ {
+ var segment = StorySegment.Create(
+ levelId,
+ lessons[i].Id,
+ segments[i],
+ i + 1, // Order starts at 1
+ $"{theme} - Part {i + 1}",
+ theme,
+ 2); // Estimated reading minutes
+
+ createdSegments.Add(segment);
+ }
+
+ // Save all segments
+ foreach (var segment in createdSegments)
+ {
+ await _storyRepository.AddAsync(segment, cancellationToken);
+ }
+
+ _logger.LogInformation("Created {Count} story segments for level {LevelId}",
+ createdSegments.Count, levelId);
+
+ // Convert to DTOs
+ var segmentDtos = createdSegments.Select(StorySegmentDto.FromEntity).ToList();
+
+ return new StoryGenerationResponseDto(
+ levelId,
+ theme,
+ segments.Count,
+ fullStory,
+ segmentDtos);
+ }
+
+ ///
+ /// Generates a story segment for a specific lesson.
+ ///
+ /// The level ID
+ /// The lesson ID
+ /// The theme for the story
+ /// Vocabulary words to include
+ /// The order of this segment
+ /// Cancellation token
+ /// The created story segment DTO
+ public virtual async Task GenerateSegmentAsync(
+ int levelId,
+ int lessonId,
+ string theme,
+ IReadOnlyList vocabulary,
+ int order,
+ CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation(
+ "Generating story segment for level {LevelId}, lesson {LessonId}, order {Order}",
+ levelId, lessonId, order);
+
+ if (!vocabulary.Any())
+ {
+ _logger.LogWarning("No vocabulary provided for segment generation");
+ throw new InvalidOperationException("Cannot generate segment: No vocabulary available");
+ }
+
+ // Build prompt for a single segment
+ var prompt = BuildSegmentPrompt(GetLevelCode(levelId), theme, vocabulary);
+
+ _logger.LogDebug("Segment generation prompt: {Prompt}", prompt);
+
+ // Generate the segment
+ var content = await _mistralService.GenerateStoryAsync(
+ GetLevelCode(levelId),
+ theme,
+ vocabulary,
+ 100, // Approximate word count for a segment
+ cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ _logger.LogError("Failed to generate story segment: Empty response from AI service");
+ throw new InvalidOperationException("Failed to generate segment: Empty response");
+ }
+
+ // Create and save the segment
+ var segment = StorySegment.Create(
+ levelId,
+ lessonId,
+ content,
+ order,
+ $"{theme} - Part {order}",
+ theme,
+ 2);
+
+ var created = await _storyRepository.AddAsync(segment, cancellationToken);
+
+ _logger.LogInformation("Created story segment with ID: {Id}", created.Id);
+
+ return StorySegmentDto.FromEntity(created);
+ }
+
+ ///
+ /// Generates audio for a story segment.
+ ///
+ /// The segment ID
+ /// Cancellation token
+ /// The updated segment DTO with audio URL
+ public virtual async Task GenerateAudioAsync(
+ int segmentId,
+ CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation("Generating audio for story segment: {SegmentId}", segmentId);
+
+ var segment = await _storyRepository.GetByIdAsync(segmentId, cancellationToken);
+
+ if (segment == null)
+ {
+ _logger.LogWarning("Segment not found: {SegmentId}", segmentId);
+ return null;
+ }
+
+ if (!string.IsNullOrEmpty(segment.AudioUrl))
+ {
+ _logger.LogInformation("Segment already has audio: {SegmentId}", segmentId);
+ return StorySegmentDto.FromEntity(segment);
+ }
+
+ // Generate audio file path
+ var audioPath = $"/audio/story/level{segment.LevelId}-segment{segment.Order}.wav";
+
try
{
- var story = await _mistralService.GenerateStoryAsync(
- level,
- topic,
- vocabularyWords,
- length,
+ // Generate audio using the TTS service directly
+ await _ttsService.GenerateAudioToFileAsync(
+ segment.Content,
+ audioPath,
+ null,
+ "de",
cancellationToken);
- // Validate the generated story
- ValidateStory(story, level, vocabularyWords);
+ // Update segment with audio URL
+ segment.UpdateAudioUrl(audioPath);
+ await _storyRepository.UpdateAsync(segment, cancellationToken);
- _logger.LogInformation("Story generated successfully");
- return story;
+ _logger.LogInformation("Generated audio for segment {SegmentId}: {AudioPath}",
+ segmentId, audioPath);
+
+ return StorySegmentDto.FromEntity(segment);
}
catch (Exception ex)
{
- _logger.LogError(ex, "Failed to generate story");
- throw new AiServiceException(
- "Failed to generate story: " + ex.Message,
- AiErrorCode.Temporary);
+ _logger.LogError(ex, "Failed to generate audio for segment {SegmentId}", segmentId);
+ throw;
}
}
///
- /// Generates a story for a specific lesson.
+ /// Generates audio for all segments that don't have audio yet.
///
- /// The lesson title for context
- /// The CEFR level
- /// List of vocabulary words from the lesson
- /// Approximate word count
+ /// Optional level ID to limit generation to
/// Cancellation token
- /// The generated story text
- public virtual async Task GenerateLessonStoryAsync(
- string lessonTitle,
- string level,
- IReadOnlyList vocabularyWords,
- int length = 250,
+ /// List of segments that had audio generated
+ public virtual async Task> GenerateAudioForAllSegmentsAsync(
+ int? levelId = null,
CancellationToken cancellationToken = default)
{
- _logger.LogInformation(
- "Generating lesson story: Lesson={Lesson}, Level={Level}, VocabularyCount={Count}",
- lessonTitle, level, vocabularyWords?.Count ?? 0);
+ _logger.LogInformation("Generating audio for all segments needing audio");
- return await GenerateStoryAsync(
- level,
- lessonTitle,
- vocabularyWords,
- length,
- cancellationToken);
- }
-
- ///
- /// Generates multiple stories for different levels.
- ///
- /// The common topic for all stories
- /// Dictionary mapping CEFR levels to vocabulary words
- /// Dictionary mapping CEFR levels to story lengths
- /// Cancellation token
- /// Dictionary mapping levels to generated stories
- public virtual async Task> GenerateStoriesByLevelAsync(
- string topic,
- IDictionary> vocabularyByLevel,
- IDictionary? lengthByLevel = null,
- CancellationToken cancellationToken = default)
- {
- _logger.LogInformation(
- "Generating stories for multiple levels: Topic={Topic}, LevelCount={Count}",
- topic, vocabularyByLevel?.Count ?? 0);
-
- var results = new Dictionary();
- lengthByLevel ??= new Dictionary();
-
- foreach (var kvp in vocabularyByLevel)
+ var segments = await _storyRepository.GetSegmentsNeedingAudioAsync(cancellationToken);
+
+ if (levelId.HasValue)
{
- var level = kvp.Key;
- var vocabulary = kvp.Value;
- var length = lengthByLevel.TryGetValue(level, out var l) ? l : 200;
+ segments = segments.Where(s => s.LevelId == levelId.Value).ToList();
+ }
+ _logger.LogInformation("Found {Count} segments needing audio", segments.Count);
+
+ var results = new List();
+ foreach (var segment in segments)
+ {
try
{
- var story = await GenerateStoryAsync(
- level,
- topic,
- vocabulary,
- length,
- cancellationToken);
-
- results[level] = story;
- _logger.LogInformation("Generated story for level: {Level}", level);
+ var result = await GenerateAudioAsync(segment.Id, cancellationToken);
+ if (result != null)
+ {
+ results.Add(result);
+ }
}
catch (Exception ex)
{
- _logger.LogError(ex, "Failed to generate story for level {Level}", level);
- results[level] = string.Empty;
+ _logger.LogError(ex, "Failed to generate audio for segment {SegmentId}", segment.Id);
}
}
+ _logger.LogInformation("Generated audio for {Count} segments", results.Count);
+
return results;
}
///
- /// Validates the generated story meets basic requirements.
+ /// Extracts vocabulary words from lessons.
+ /// This is a placeholder - in a real implementation, vocabulary would be stored in the lesson.
///
- /// The generated story text
- /// The target CEFR level
- /// The vocabulary words that should be included
- /// Thrown when story validation fails
- private void ValidateStory(
- string story,
- string level,
- IReadOnlyList? vocabularyWords)
+ /// List of lessons
+ /// List of vocabulary words
+ private IReadOnlyList ExtractVocabularyFromLessons(IReadOnlyList lessons)
{
- if (string.IsNullOrWhiteSpace(story))
+ // For now, extract from lesson titles and topics
+ // In a real implementation, lessons would have a VocabularyWords collection
+ var vocabulary = new List();
+
+ foreach (var lesson in lessons)
{
- _logger.LogError("Generated story is empty");
- throw new InvalidOperationException("Generated story is empty");
+ // Extract words from title (simple word splitting)
+ var titleWords = lesson.Title.Split(new[] { ' ', ',', '.', '!', '?' },
+ StringSplitOptions.RemoveEmptyEntries);
+ vocabulary.AddRange(titleWords);
+
+ // Extract words from topic
+ var topicWords = lesson.Topic.Split(new[] { ' ', ',', '.', '!', '?' },
+ StringSplitOptions.RemoveEmptyEntries);
+ vocabulary.AddRange(topicWords);
}
- // Check minimum length based on level
- var minLength = GetMinStoryLength(level);
- if (story.Length < minLength)
+ // Remove duplicates and filter
+ return vocabulary
+ .Where(w => !string.IsNullOrWhiteSpace(w))
+ .Where(w => w.Length > 2) // Skip very short words
+ .Distinct()
+ .OrderBy(w => w)
+ .ToList();
+ }
+
+ ///
+ /// Builds a prompt for full story generation.
+ ///
+ /// The level ID
+ /// The story theme
+ /// List of vocabulary words
+ /// Number of segments to generate
+ /// The prompt string
+ private string BuildStoryPrompt(int levelId, string theme, IReadOnlyList vocabulary, int segmentCount)
+ {
+ var levelCode = GetLevelCode(levelId);
+ var vocabularyString = string.Join(", ", vocabulary.Take(20)); // Limit to first 20 words
+
+ if (vocabulary.Count > 20)
{
- _logger.LogWarning(
- "Generated story is too short: {Length} characters (min: {Min})",
- story.Length, minLength);
- // Don't throw - just log warning, AI might generate short stories
+ vocabularyString += ", ...";
}
- // Check if vocabulary words are included (if provided)
- if (vocabularyWords != null && vocabularyWords.Count > 0)
- {
- var storyLower = story.ToLower();
- var missingWords = vocabularyWords
- .Where(w => !string.IsNullOrWhiteSpace(w))
- .Where(w => !storyLower.Contains(w.ToLower()))
- .ToList();
+ return $@"Write a {segmentCount}-part continuous story for a German {levelCode} learner.
+The story theme is: {theme}.
+Include these German words and phrases: {vocabularyString}.
+Use only {levelCode} level vocabulary and grammar.
+Write each part as a separate paragraph.
+Make the story engaging and appropriate for language learners.";
+ }
- if (missingWords.Count > vocabularyWords.Count * 0.5)
+ ///
+ /// Builds a prompt for a single story segment.
+ ///
+ /// The level code (A1, A2, etc.)
+ /// The story theme
+ /// List of vocabulary words
+ /// The prompt string
+ private string BuildSegmentPrompt(string levelCode, string theme, IReadOnlyList vocabulary)
+ {
+ var vocabularyString = string.Join(", ", vocabulary.Take(15));
+
+ if (vocabulary.Count > 15)
+ {
+ vocabularyString += ", ...";
+ }
+
+ return $@"Write a short story segment (5-8 sentences) for a German {levelCode} learner.
+The theme is: {theme}.
+Include these German words: {vocabularyString}.
+Use only {levelCode} level vocabulary and grammar.
+Make it engaging and appropriate for language learners.";
+ }
+
+ ///
+ /// Splits a full story text into segments.
+ ///
+ /// The full story text
+ /// Number of segments to create
+ /// List of story segments
+ private List SplitStoryIntoSegments(string story, int segmentCount)
+ {
+ var segments = new List();
+
+ // Split by double newlines (paragraphs)
+ var paragraphs = story.Split(new[] { "\n\n", "\n\r\n", "\r\n\r\n" },
+ StringSplitOptions.RemoveEmptyEntries);
+
+ if (paragraphs.Length >= segmentCount)
+ {
+ // Take the first segmentCount paragraphs
+ for (int i = 0; i < segmentCount; i++)
{
- _logger.LogWarning(
- "Many vocabulary words missing from story: {MissingCount}/{TotalCount}",
- missingWords.Count, vocabularyWords.Count);
+ segments.Add(paragraphs[i].Trim());
}
}
+ else
+ {
+ // Need to split paragraphs further
+ // Calculate how many paragraphs per segment we need
+ var paragraphsPerSegment = Math.Max(1, paragraphs.Length / segmentCount);
+
+ for (int i = 0; i < segmentCount; i++)
+ {
+ var start = i * paragraphsPerSegment;
+ var end = Math.Min((i + 1) * paragraphsPerSegment, paragraphs.Length);
+
+ var segmentText = string.Join(" ", paragraphs[start..end]).Trim();
+ segments.Add(segmentText);
+ }
+ }
+
+ return segments;
}
///
- /// Gets the minimum expected story length based on CEFR level.
+ /// Adjusts the segment count to match the expected number.
///
- /// The CEFR level
- /// Minimum character count
- private int GetMinStoryLength(string level)
+ /// Current list of segments
+ /// Target number of segments
+ /// Adjusted list of segments
+ private List AdjustSegmentCount(List segments, int targetCount)
{
- return level.ToUpper() switch
+ if (segments.Count == targetCount)
+ return segments;
+
+ if (segments.Count < targetCount)
{
- "A1" => 100,
- "A2" => 150,
- "B1" => 200,
- "B2" => 300,
- "C1" => 400,
- _ => 100
+ // Duplicate the last segment to fill
+ while (segments.Count < targetCount)
+ {
+ segments.Add(segments[^1]);
+ }
+ }
+ else
+ {
+ // Combine segments to reduce count
+ var result = new List();
+ var combineCount = (int)Math.Ceiling((double)segments.Count / targetCount);
+
+ for (int i = 0; i < segments.Count; i += combineCount)
+ {
+ var end = Math.Min(i + combineCount, segments.Count);
+ var combined = string.Join(" ", segments[i..end]).Trim();
+ result.Add(combined);
+ }
+
+ segments = result;
+ }
+
+ return segments;
+ }
+
+ ///
+ /// Gets the level code (A1, A2, etc.) from the level ID.
+ /// This is a temporary implementation - in a real app, we would look this up.
+ ///
+ /// The level ID
+ /// The level code
+ private string GetLevelCode(int levelId)
+ {
+ // This is a placeholder - the actual mapping should come from the database
+ return levelId switch
+ {
+ 1 => "A1",
+ 2 => "A2",
+ 3 => "B1",
+ 4 => "B2",
+ 5 => "C1",
+ _ => "A1"
};
}
-
- ///
- /// Tests the story generation service.
- ///
- /// Cancellation token
- /// True if service is working, false otherwise
- public virtual async Task TestServiceAsync(CancellationToken cancellationToken = default)
- {
- try
- {
- // Generate a simple test story
- var story = await GenerateStoryAsync(
- "A1",
- "Test",
- new List { "Hallo", "Welt" },
- 50,
- cancellationToken);
-
- return !string.IsNullOrWhiteSpace(story);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Story generation service test failed");
- return false;
- }
- }
}
diff --git a/GermanApp/Application/Services/StoryService.cs b/GermanApp/Application/Services/StoryService.cs
new file mode 100644
index 0000000..041db65
--- /dev/null
+++ b/GermanApp/Application/Services/StoryService.cs
@@ -0,0 +1,487 @@
+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;
+
+///
+/// Application service for managing story segments.
+/// This is part of the Application layer.
+///
+public class StoryService
+{
+ private readonly IStoryRepository _storyRepository;
+ private readonly IStoryProgressRepository _progressRepository;
+ private readonly ILogger _logger;
+
+ ///
+ /// Creates a new StoryService.
+ ///
+ /// Repository for story segments
+ /// Repository for story progress
+ /// Logger for service operations
+ public StoryService(
+ IStoryRepository storyRepository,
+ IStoryProgressRepository progressRepository,
+ ILogger logger)
+ {
+ _storyRepository = storyRepository;
+ _progressRepository = progressRepository;
+ _logger = logger;
+ }
+
+ ///
+ /// Gets a story segment by its ID.
+ ///
+ /// The segment ID
+ /// Cancellation token
+ /// The story segment DTO, or null if not found
+ public virtual async Task 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);
+ }
+
+ ///
+ /// Gets all story segments for a specific level.
+ ///
+ /// The level ID
+ /// Whether to include inactive segments
+ /// Cancellation token
+ /// List of story segment DTOs
+ public virtual async Task> 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();
+ }
+
+ ///
+ /// Gets all story segments for a specific lesson.
+ ///
+ /// The lesson ID
+ /// Cancellation token
+ /// List of story segment DTOs
+ public virtual async Task> 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();
+ }
+
+ ///
+ /// Creates a new story segment.
+ ///
+ /// The DTO containing segment data
+ /// Cancellation token
+ /// The created story segment DTO
+ public virtual async Task 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);
+ }
+
+ ///
+ /// Updates an existing story segment.
+ ///
+ /// The segment ID
+ /// The DTO containing updated data
+ /// Cancellation token
+ /// The updated story segment DTO, or null if not found
+ public virtual async Task 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);
+ }
+
+ ///
+ /// Deletes a story segment by its ID.
+ ///
+ /// The segment ID
+ /// Cancellation token
+ /// True if the segment was deleted, false if not found
+ public virtual async Task 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;
+ }
+
+ ///
+ /// Gets the next segment to unlock for a user after completing a lesson.
+ ///
+ /// The level ID
+ /// The order of the completed lesson
+ /// Cancellation token
+ /// The next story segment DTO, or null if none
+ public virtual async Task 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);
+ }
+
+ ///
+ /// Checks if a user has unlocked a specific story segment.
+ ///
+ /// The user ID
+ /// The segment ID
+ /// Cancellation token
+ /// True if the segment is unlocked
+ public virtual async Task IsSegmentUnlockedAsync(
+ int userId,
+ int segmentId,
+ CancellationToken cancellationToken = default)
+ {
+ return await _progressRepository.IsSegmentUnlockedAsync(userId, segmentId, cancellationToken);
+ }
+
+ ///
+ /// Checks if a user has completed (read/listened to) a specific story segment.
+ ///
+ /// The user ID
+ /// The segment ID
+ /// Cancellation token
+ /// True if the segment is completed
+ public virtual async Task IsSegmentCompletedAsync(
+ int userId,
+ int segmentId,
+ CancellationToken cancellationToken = default)
+ {
+ return await _progressRepository.IsSegmentCompletedAsync(userId, segmentId, cancellationToken);
+ }
+
+ ///
+ /// Gets all segments that need audio generation.
+ ///
+ /// Cancellation token
+ /// List of story segment DTOs needing audio
+ public virtual async Task> GetSegmentsNeedingAudioAsync(
+ CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation("Getting story segments needing audio generation");
+
+ var segments = await _storyRepository.GetSegmentsNeedingAudioAsync(cancellationToken);
+
+ return segments.Select(StorySegmentDto.FromEntity).ToList();
+ }
+
+ ///
+ /// Updates the audio URL for a story segment.
+ ///
+ /// The segment ID
+ /// The audio URL
+ /// Cancellation token
+ /// The updated story segment DTO, or null if not found
+ public virtual async Task 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);
+ }
+
+ ///
+ /// Gets a user's progress through a story.
+ ///
+ /// The user ID
+ /// The level ID
+ /// Cancellation token
+ /// Story progress DTO
+ public virtual async Task 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());
+ }
+
+ // 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();
+ 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);
+ }
+
+ ///
+ /// Unlocks the next story segment for a user after completing a lesson.
+ ///
+ /// The user ID
+ /// The level ID
+ /// The order of the completed lesson
+ /// Cancellation token
+ /// The unlocked story segment DTO, or null if none to unlock
+ public virtual async Task 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);
+ }
+
+ ///
+ /// Marks a story segment as completed (read/listened to) for a user.
+ ///
+ /// The user ID
+ /// The segment ID
+ /// Cancellation token
+ /// True if the segment was marked as completed, false if not found or already completed
+ public virtual async Task 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;
+ }
+}
diff --git a/GermanApp/Application/Services/StoryUnlockService.cs b/GermanApp/Application/Services/StoryUnlockService.cs
new file mode 100644
index 0000000..84159eb
--- /dev/null
+++ b/GermanApp/Application/Services/StoryUnlockService.cs
@@ -0,0 +1,183 @@
+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;
+
+///
+/// Application service for managing story segment unlocking.
+/// This service is called when a user completes a lesson to unlock the next story segment.
+/// This is part of the Application layer.
+///
+public class StoryUnlockService
+{
+ private readonly StoryService _storyService;
+ private readonly IUserProgressRepository _userProgressRepository;
+ private readonly ILessonRepository _lessonRepository;
+ private readonly ILogger _logger;
+
+ ///
+ /// Creates a new StoryUnlockService.
+ ///
+ /// Service for story segment operations
+ /// Repository for user progress
+ /// Repository for lessons
+ /// Logger for service operations
+ public StoryUnlockService(
+ StoryService storyService,
+ IUserProgressRepository userProgressRepository,
+ ILessonRepository lessonRepository,
+ ILogger logger)
+ {
+ _storyService = storyService;
+ _userProgressRepository = userProgressRepository;
+ _lessonRepository = lessonRepository;
+ _logger = logger;
+ }
+
+ ///
+ /// Called when a user completes a lesson.
+ /// Checks if the user should unlock a new story segment and does so.
+ ///
+ /// The user ID
+ /// The lesson ID that was completed
+ /// Cancellation token
+ /// True if a new segment was unlocked, false otherwise
+ public virtual async Task HandleLessonCompletionAsync(
+ int userId,
+ int lessonId,
+ CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation(
+ "Handling lesson completion for user {UserId}, lesson {LessonId}",
+ userId, lessonId);
+
+ // Get the lesson to find its level and order
+ var lesson = await _lessonRepository.GetByIdAsync(lessonId, cancellationToken);
+
+ if (lesson == null)
+ {
+ _logger.LogWarning("Lesson not found: {LessonId}", lessonId);
+ return false;
+ }
+
+ // Check if this lesson completion qualifies for unlocking a story segment
+ // We need to check if the user has actually completed this lesson
+ var isCompleted = await _userProgressRepository.HasUserCompletedLessonAsync(
+ userId, lessonId, cancellationToken);
+
+ if (!isCompleted)
+ {
+ _logger.LogInformation(
+ "Lesson {LessonId} not marked as completed for user {UserId}",
+ lessonId, userId);
+ return false;
+ }
+
+ // Get the next story segment to unlock
+ var nextSegment = await _storyService.GetNextSegmentToUnlockAsync(
+ lesson.LevelId,
+ lesson.Order,
+ cancellationToken);
+
+ if (nextSegment == null)
+ {
+ _logger.LogInformation(
+ "No story segment to unlock for user {UserId} after lesson {LessonId}",
+ userId, lessonId);
+ return false;
+ }
+
+ // Check if user already has this segment unlocked
+ var alreadyUnlocked = await _storyService.IsSegmentUnlockedAsync(
+ userId, nextSegment.Id, cancellationToken);
+
+ if (alreadyUnlocked)
+ {
+ _logger.LogInformation(
+ "Segment {SegmentId} already unlocked for user {UserId}",
+ nextSegment.Id, userId);
+ return false;
+ }
+
+ // Unlock the segment
+ var unlockedSegment = await _storyService.UnlockNextSegmentAsync(
+ userId,
+ lesson.LevelId,
+ lesson.Order,
+ cancellationToken);
+
+ if (unlockedSegment != null)
+ {
+ _logger.LogInformation(
+ "Unlocked story segment {SegmentId} for user {UserId} after completing lesson {LessonId}",
+ unlockedSegment.Id, userId, lessonId);
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Checks if a user has unlocked a specific story segment.
+ ///
+ /// The user ID
+ /// The segment ID
+ /// Cancellation token
+ /// True if the segment is unlocked
+ public virtual async Task IsSegmentUnlockedAsync(
+ int userId,
+ int segmentId,
+ CancellationToken cancellationToken = default)
+ {
+ return await _storyService.IsSegmentUnlockedAsync(userId, segmentId, cancellationToken);
+ }
+
+ ///
+ /// Checks if a user has completed (read/listened to) a specific story segment.
+ ///
+ /// The user ID
+ /// The segment ID
+ /// Cancellation token
+ /// True if the segment is completed
+ public virtual async Task IsSegmentCompletedAsync(
+ int userId,
+ int segmentId,
+ CancellationToken cancellationToken = default)
+ {
+ return await _storyService.IsSegmentCompletedAsync(userId, segmentId, cancellationToken);
+ }
+
+ ///
+ /// Marks a story segment as completed for a user.
+ ///
+ /// The user ID
+ /// The segment ID
+ /// Cancellation token
+ /// True if the segment was marked as completed
+ public virtual async Task MarkSegmentAsCompletedAsync(
+ int userId,
+ int segmentId,
+ CancellationToken cancellationToken = default)
+ {
+ return await _storyService.MarkSegmentAsCompletedAsync(userId, segmentId, cancellationToken);
+ }
+
+ ///
+ /// Gets a user's story progress for a level.
+ ///
+ /// The user ID
+ /// The level ID
+ /// Cancellation token
+ /// Story progress DTO
+ public virtual async Task GetUserProgressAsync(
+ int userId,
+ int levelId,
+ CancellationToken cancellationToken = default)
+ {
+ return await _storyService.GetUserProgressAsync(userId, levelId, cancellationToken);
+ }
+}
diff --git a/GermanApp/Infrastructure/Data/Repositories/StoryProgressRepository.cs b/GermanApp/Infrastructure/Data/Repositories/StoryProgressRepository.cs
index cdbed28..4fd9337 100644
--- a/GermanApp/Infrastructure/Data/Repositories/StoryProgressRepository.cs
+++ b/GermanApp/Infrastructure/Data/Repositories/StoryProgressRepository.cs
@@ -57,7 +57,7 @@ public class StoryProgressRepository : IStoryProgressRepository
.Select(p => p.StorySegment != null ? p.StorySegment.Order : 0)
.MaxAsync(cancellationToken);
- return highestOrder == null ? 0 : highestOrder;
+ return highestOrder ?? 0;
}
public async Task IsSegmentUnlockedAsync(
diff --git a/GermanApp/Infrastructure/Data/Repositories/StoryRepository.cs b/GermanApp/Infrastructure/Data/Repositories/StoryRepository.cs
index a48a531..6e65a6c 100644
--- a/GermanApp/Infrastructure/Data/Repositories/StoryRepository.cs
+++ b/GermanApp/Infrastructure/Data/Repositories/StoryRepository.cs
@@ -129,7 +129,7 @@ public class StoryRepository : IStoryRepository
.Select(s => s.Order)
.MaxAsync(cancellationToken);
- return maxOrder == null ? 0 : maxOrder;
+ return maxOrder ?? 0;
}
public async Task ExistsAsync(int id, CancellationToken cancellationToken = default)
diff --git a/GermanApp/Presentation/Controllers/StoryController.cs b/GermanApp/Presentation/Controllers/StoryController.cs
new file mode 100644
index 0000000..bda01d4
--- /dev/null
+++ b/GermanApp/Presentation/Controllers/StoryController.cs
@@ -0,0 +1,489 @@
+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();
+ }
+
+ // In a real implementation, return the actual file
+ // For now, return the URL
+ 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);
diff --git a/GermanApp/Program.cs b/GermanApp/Program.cs
index bfc940d..0346908 100644
--- a/GermanApp/Program.cs
+++ b/GermanApp/Program.cs
@@ -138,6 +138,8 @@ try
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
// ============================================
// INFRASTRUCTURE LAYER - AI Services
@@ -200,6 +202,10 @@ try
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+
+ // Register Story services
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
builder.Services.AddScoped, CreateLessonCommandHandler>();
// Note: QuizQuestionService requires IQuizRepository, ILessonRepository, and ProgressService which are registered above
diff --git a/docs/features/story-integration.md b/docs/features/story-integration.md
index 8eaccc2..4948652 100644
--- a/docs/features/story-integration.md
+++ b/docs/features/story-integration.md
@@ -1,7 +1,7 @@
# Feature: Story Integration
> **Status**: 🚀 In Progress
-> **📊 Current Progress**: Phase 1 ✅ Complete (Database & Models), Phase 2 Started (Backend Services)
+> **📊 Current Progress**: Phase 1-2 ✅ Complete (Database & Models, Backend Services), Phase 3 Started (AI Integration)
> **Priority**: High
> **Complexity**: High
> **Estimate**: 8-12 hours
@@ -142,11 +142,11 @@ Order: 1
- [ ] Create and apply migration for StorySegments and StoryProgress tables
### Phase 2: Backend Services (2-3 hours)
-- [ ] Create StoryService with CRUD operations
-- [ ] Create StoryGenerationService for AI integration
-- [ ] Implement story segment generation using MistralService
-- [ ] Implement audio generation using AudioGenerationService
-- [ ] Create StoryUnlockService for progress management
+- [x] Create StoryService with CRUD operations (Application/Services/StoryService.cs)
+- [x] Create StoryGenerationService for AI integration (Application/Services/StoryGenerationService.cs)
+- [x] Implement story segment generation using MistralService
+- [x] Implement audio generation using ITtsService
+- [x] Create StoryUnlockService for progress management (Application/Services/StoryUnlockService.cs)
- [ ] Write unit tests for services
### Phase 2: Backend Services (2-3 hours)
@@ -191,8 +191,8 @@ Order: 1
| Milestone | Date | Status |
|-----------|------|--------|
| Database & Models | June 13, 2025 | ✅ |
-| Backend Services | - | 🚀 In Progress |
-| AI Integration | - | ⏳ |
+| Backend Services | June 13, 2025 | ✅ |
+| AI Integration | - | 🚀 In Progress |
| Audio Generation | - | ⏳ |
| Frontend Integration | - | ⏳ |
| User Progress | - | ⏳ |
@@ -212,11 +212,11 @@ Order: 1
- [x] Create Infrastructure/Data/Repositories/StoryProgressRepository.cs
- [x] Add DbSets and configurations to AppDbContext
- [x] Update Level, Lesson, and User entities with navigation properties
-- [ ] Create Application/Services/StoryService.cs
-- [ ] Create StoryGenerationService (adapt existing from AI Services)
-- [ ] Create StoryUnlockService for progress management
-- [ ] Create Presentation/Controllers/StoryController.cs
-- [ ] Register services in Program.cs
+- [x] Create Application/Services/StoryService.cs
+- [x] Create Application/Services/StoryGenerationService.cs (uses IMistralService, ITtsService)
+- [x] Create Application/Services/StoryUnlockService.cs (handles lesson completion → story unlocking)
+- [x] Create Presentation/Controllers/StoryController.cs (12 endpoints)
+- [x] Register services in Program.cs (IStoryRepository, IStoryProgressRepository, StoryService, StoryGenerationService, StoryUnlockService)
- [ ] Write unit tests
- [ ] Write integration tests