diff --git a/GermanApp/Application/DTOs/StorySegmentDto.cs b/GermanApp/Application/DTOs/StorySegmentDto.cs
new file mode 100644
index 0000000..8635599
--- /dev/null
+++ b/GermanApp/Application/DTOs/StorySegmentDto.cs
@@ -0,0 +1,104 @@
+namespace GermanApp.Application.DTOs;
+
+///
+/// Data Transfer Object for StorySegment.
+/// Used for API requests and responses.
+///
+public record StorySegmentDto(
+ int Id,
+ int LevelId,
+ int? LessonId,
+ string Content,
+ string? AudioUrl,
+ int Order,
+ string Title,
+ string Theme,
+ int EstimatedReadingMinutes,
+ bool IsActive,
+ DateTime CreatedAt,
+ DateTime? UpdatedAt)
+{
+ ///
+ /// Creates a StorySegmentDto from a domain entity.
+ ///
+ public static StorySegmentDto FromEntity(Domain.Entities.StorySegment segment)
+ {
+ return new StorySegmentDto(
+ segment.Id,
+ segment.LevelId,
+ segment.LessonId,
+ segment.Content,
+ segment.AudioUrl,
+ segment.Order,
+ segment.Title,
+ segment.Theme,
+ segment.EstimatedReadingMinutes,
+ segment.IsActive,
+ segment.CreatedAt,
+ segment.UpdatedAt);
+ }
+}
+
+///
+/// DTO for creating a new story segment.
+///
+public record CreateStorySegmentDto(
+ int LevelId,
+ int? LessonId,
+ string Content,
+ int Order,
+ string Title,
+ string Theme,
+ int EstimatedReadingMinutes = 2);
+
+///
+/// DTO for updating an existing story segment.
+///
+public record UpdateStorySegmentDto(
+ string? Content = null,
+ int? Order = null,
+ string? Title = null,
+ string? Theme = null,
+ int? EstimatedReadingMinutes = null,
+ int? LessonId = null,
+ bool? IsActive = null);
+
+///
+/// DTO for generating a story for a level.
+///
+public record StoryGenerationRequestDto(
+ int LevelId,
+ string Theme,
+ int SegmentCount,
+ string? CustomPrompt = null);
+
+///
+/// Response DTO for story generation.
+///
+public record StoryGenerationResponseDto(
+ int LevelId,
+ string Theme,
+ int SegmentCount,
+ string FullStoryText,
+ IReadOnlyList Segments);
+
+///
+/// DTO for user's story progress.
+///
+public record StoryProgressDto(
+ int LevelId,
+ string LevelName,
+ int TotalSegments,
+ int UnlockedSegments,
+ int CurrentSegmentOrder,
+ IReadOnlyList Segments);
+
+///
+/// DTO for individual segment progress.
+///
+public record StorySegmentProgressDto(
+ int SegmentId,
+ int Order,
+ string Title,
+ bool IsUnlocked,
+ bool IsCompleted);
diff --git a/GermanApp/Domain/Entities/Lesson.cs b/GermanApp/Domain/Entities/Lesson.cs
index c93371d..78e9725 100644
--- a/GermanApp/Domain/Entities/Lesson.cs
+++ b/GermanApp/Domain/Entities/Lesson.cs
@@ -107,4 +107,7 @@ public class Lesson
IsActive = false;
UpdatedAt = DateTime.UtcNow;
}
+
+ // Navigation properties
+ public virtual ICollection StorySegments { get; private set; } = new List();
}
diff --git a/GermanApp/Domain/Entities/Level.cs b/GermanApp/Domain/Entities/Level.cs
index b74e2ec..731ff87 100644
--- a/GermanApp/Domain/Entities/Level.cs
+++ b/GermanApp/Domain/Entities/Level.cs
@@ -54,4 +54,8 @@ public class Level
{
Order = newOrder;
}
+
+ // Navigation properties
+ public virtual ICollection Lessons { get; private set; } = new List();
+ public virtual ICollection StorySegments { get; private set; } = new List();
}
diff --git a/GermanApp/Domain/Entities/StoryProgress.cs b/GermanApp/Domain/Entities/StoryProgress.cs
new file mode 100644
index 0000000..f348926
--- /dev/null
+++ b/GermanApp/Domain/Entities/StoryProgress.cs
@@ -0,0 +1,106 @@
+namespace GermanApp.Domain.Entities;
+
+///
+/// Tracks a user's progress through story segments.
+/// A user unlocks story segments by completing lessons.
+///
+public class StoryProgress
+{
+ public int Id { get; private set; }
+
+ ///
+ /// The user ID who owns this progress.
+ ///
+ public int UserId { get; private set; }
+
+ ///
+ /// The level ID this progress is for.
+ ///
+ public int LevelId { get; private set; }
+
+ ///
+ /// The story segment ID that was unlocked.
+ ///
+ public int StorySegmentId { get; private set; }
+
+ ///
+ /// Whether the user has read/listened to this segment.
+ ///
+ public bool IsCompleted { get; private set; }
+
+ ///
+ /// When the segment was unlocked.
+ ///
+ public DateTime UnlockedAt { get; private set; }
+
+ ///
+ /// When the segment was completed (read/listened to).
+ ///
+ public DateTime? CompletedAt { get; private set; }
+
+ ///
+ /// Timestamp when the progress record was created.
+ ///
+ public DateTime CreatedAt { get; private set; }
+
+ ///
+ /// Timestamp when the progress record was last updated.
+ ///
+ public DateTime? UpdatedAt { get; private set; }
+
+ // Navigation properties
+ public virtual User? User { get; private set; }
+ public virtual Level? Level { get; private set; }
+ public virtual StorySegment? StorySegment { get; private set; }
+
+ ///
+ /// Constructor for EF Core deserialization.
+ ///
+ private StoryProgress() { }
+
+ ///
+ /// Factory method to create a new story progress record.
+ ///
+ /// The user ID
+ /// The level ID
+ /// The story segment ID
+ /// New StoryProgress instance
+ public static StoryProgress Create(int userId, int levelId, int storySegmentId)
+ {
+ return new StoryProgress
+ {
+ UserId = userId,
+ LevelId = levelId,
+ StorySegmentId = storySegmentId,
+ IsCompleted = false,
+ UnlockedAt = DateTime.UtcNow,
+ CreatedAt = DateTime.UtcNow
+ };
+ }
+
+ ///
+ /// Marks this story segment as completed (read/listened to).
+ ///
+ public void MarkAsCompleted()
+ {
+ if (!IsCompleted)
+ {
+ IsCompleted = true;
+ CompletedAt = DateTime.UtcNow;
+ UpdatedAt = DateTime.UtcNow;
+ }
+ }
+
+ ///
+ /// Marks this story segment as not completed.
+ ///
+ public void MarkAsIncomplete()
+ {
+ if (IsCompleted)
+ {
+ IsCompleted = false;
+ CompletedAt = null;
+ UpdatedAt = DateTime.UtcNow;
+ }
+ }
+}
diff --git a/GermanApp/Domain/Entities/StorySegment.cs b/GermanApp/Domain/Entities/StorySegment.cs
new file mode 100644
index 0000000..959300c
--- /dev/null
+++ b/GermanApp/Domain/Entities/StorySegment.cs
@@ -0,0 +1,200 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace GermanApp.Domain.Entities;
+
+///
+/// Represents a segment of a continuous story for a specific level and lesson.
+/// Story segments are unlocked sequentially as users complete lessons.
+///
+public class StorySegment
+{
+ public int Id { get; private set; }
+
+ ///
+ /// The CEFR level this story segment belongs to.
+ ///
+ public int LevelId { get; private set; }
+
+ ///
+ /// The lesson this story segment is associated with.
+ /// Can be null if the segment is an introduction or conclusion.
+ ///
+ public int? LessonId { get; private set; }
+
+ ///
+ /// The text content of the story segment.
+ ///
+ public string Content { get; private set; } = string.Empty;
+
+ ///
+ /// URL path to the audio file for this story segment.
+ ///
+ public string? AudioUrl { get; private set; }
+
+ ///
+ /// The order of this segment within the level's story.
+ /// Segments are displayed in ascending order.
+ ///
+ public int Order { get; private set; }
+
+ ///
+ /// Title or brief description of this story segment.
+ ///
+ public string Title { get; private set; } = string.Empty;
+
+ ///
+ /// Theme or topic of this story segment.
+ ///
+ public string Theme { get; private set; } = string.Empty;
+
+ ///
+ /// Estimated reading time in minutes.
+ ///
+ public int EstimatedReadingMinutes { get; private set; }
+
+ ///
+ /// Whether this segment is active and visible to users.
+ ///
+ public bool IsActive { get; private set; } = true;
+
+ ///
+ /// Timestamp when the segment was created.
+ ///
+ public DateTime CreatedAt { get; private set; }
+
+ ///
+ /// Timestamp when the segment was last updated.
+ ///
+ public DateTime? UpdatedAt { get; private set; }
+
+ // Navigation properties (EF Core will handle these)
+ public virtual Level? Level { get; private set; }
+ public virtual Lesson? Lesson { get; private set; }
+
+ ///
+ /// Constructor for EF Core deserialization.
+ ///
+ private StorySegment() { }
+
+ ///
+ /// Factory method to create a new story segment.
+ ///
+ /// ID of the level this segment belongs to
+ /// Optional ID of the associated lesson
+ /// The story text content
+ /// The order within the level's story
+ /// Title of the segment
+ /// Theme or topic of the segment
+ /// Estimated reading time in minutes
+ /// New StorySegment instance
+ public static StorySegment Create(
+ int levelId,
+ int? lessonId,
+ string content,
+ int order,
+ string title,
+ string theme,
+ int estimatedReadingMinutes = 2)
+ {
+ return new StorySegment
+ {
+ LevelId = levelId,
+ LessonId = lessonId,
+ Content = content,
+ Order = order,
+ Title = title,
+ Theme = theme,
+ EstimatedReadingMinutes = estimatedReadingMinutes,
+ CreatedAt = DateTime.UtcNow
+ };
+ }
+
+ ///
+ /// Updates the content of the story segment.
+ ///
+ /// New content text
+ public void UpdateContent(string newContent)
+ {
+ Content = newContent;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Updates the audio URL for this segment.
+ ///
+ /// URL path to the audio file
+ public void UpdateAudioUrl(string audioUrl)
+ {
+ AudioUrl = audioUrl;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Updates the title of the story segment.
+ ///
+ /// New title
+ public void UpdateTitle(string newTitle)
+ {
+ Title = newTitle;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Updates the theme of the story segment.
+ ///
+ /// New theme
+ public void UpdateTheme(string newTheme)
+ {
+ Theme = newTheme;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Updates the estimated reading time.
+ ///
+ /// Estimated reading time in minutes
+ public void UpdateEstimatedReadingMinutes(int minutes)
+ {
+ EstimatedReadingMinutes = minutes;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Updates the order of this segment within the level's story.
+ ///
+ /// New order value
+ public void UpdateOrder(int newOrder)
+ {
+ Order = newOrder;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Updates the associated lesson.
+ ///
+ /// New lesson ID (can be null)
+ public void UpdateLesson(int? newLessonId)
+ {
+ LessonId = newLessonId;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Activates this story segment.
+ ///
+ public void Activate()
+ {
+ IsActive = true;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Deactivates this story segment.
+ ///
+ public void Deactivate()
+ {
+ IsActive = false;
+ UpdatedAt = DateTime.UtcNow;
+ }
+}
diff --git a/GermanApp/Domain/Entities/User.cs b/GermanApp/Domain/Entities/User.cs
index e7bfd01..c1f0f9d 100644
--- a/GermanApp/Domain/Entities/User.cs
+++ b/GermanApp/Domain/Entities/User.cs
@@ -75,4 +75,7 @@ public class User
{
PasswordHash = newPasswordHash;
}
+
+ // Navigation properties
+ public virtual ICollection StoryProgress { get; private set; } = new List();
}
diff --git a/GermanApp/Domain/Interfaces/IStoryProgressRepository.cs b/GermanApp/Domain/Interfaces/IStoryProgressRepository.cs
new file mode 100644
index 0000000..48e1fe0
--- /dev/null
+++ b/GermanApp/Domain/Interfaces/IStoryProgressRepository.cs
@@ -0,0 +1,120 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using GermanApp.Domain.Entities;
+
+namespace GermanApp.Domain.Interfaces;
+
+///
+/// Repository interface for managing StoryProgress entities.
+/// This is part of the Domain layer.
+///
+public interface IStoryProgressRepository
+{
+ ///
+ /// Gets story progress for a specific user and level.
+ ///
+ /// The user ID
+ /// The level ID
+ /// Cancellation token
+ /// List of story progress records for the user and level
+ Task> GetByUserAndLevelAsync(
+ int userId,
+ int levelId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets story progress for a specific user and segment.
+ ///
+ /// The user ID
+ /// The story segment ID
+ /// Cancellation token
+ /// The story progress record, or null if not found
+ Task GetByUserAndSegmentAsync(
+ int userId,
+ int storySegmentId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the highest unlocked segment order for a user in a level.
+ ///
+ /// The user ID
+ /// The level ID
+ /// Cancellation token
+ /// The highest order of unlocked segments, or 0 if none
+ Task GetHighestUnlockedOrderAsync(
+ int userId,
+ int levelId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Checks if a user has unlocked a specific story segment.
+ ///
+ /// The user ID
+ /// The story segment ID
+ /// Cancellation token
+ /// True if the segment is unlocked
+ Task IsSegmentUnlockedAsync(
+ int userId,
+ int storySegmentId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Checks if a user has completed (read/listened to) a specific story segment.
+ ///
+ /// The user ID
+ /// The story segment ID
+ /// Cancellation token
+ /// True if the segment is completed
+ Task IsSegmentCompletedAsync(
+ int userId,
+ int storySegmentId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Adds a new story progress record.
+ ///
+ /// The story progress to add
+ /// Cancellation token
+ /// The added progress with generated ID
+ Task AddAsync(StoryProgress progress, CancellationToken cancellationToken = default);
+
+ ///
+ /// Updates an existing story progress record.
+ ///
+ /// The story progress to update
+ /// Cancellation token
+ Task UpdateAsync(StoryProgress progress, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the total count of story segments for a level.
+ ///
+ /// The level ID
+ /// Cancellation token
+ /// The total number of segments in the level
+ Task GetTotalSegmentsForLevelAsync(int levelId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the count of unlocked segments for a user in a level.
+ ///
+ /// The user ID
+ /// The level ID
+ /// Cancellation token
+ /// The number of unlocked segments
+ Task GetUnlockedCountAsync(
+ int userId,
+ int levelId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the count of completed segments for a user in a level.
+ ///
+ /// The user ID
+ /// The level ID
+ /// Cancellation token
+ /// The number of completed segments
+ Task GetCompletedCountAsync(
+ int userId,
+ int levelId,
+ CancellationToken cancellationToken = default);
+}
diff --git a/GermanApp/Domain/Interfaces/IStoryRepository.cs b/GermanApp/Domain/Interfaces/IStoryRepository.cs
new file mode 100644
index 0000000..0942b8a
--- /dev/null
+++ b/GermanApp/Domain/Interfaces/IStoryRepository.cs
@@ -0,0 +1,115 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using GermanApp.Domain.Entities;
+
+namespace GermanApp.Domain.Interfaces;
+
+///
+/// Repository interface for managing StorySegment entities.
+/// This is part of the Domain layer.
+///
+public interface IStoryRepository
+{
+ ///
+ /// Gets a story segment by its ID.
+ ///
+ /// The segment ID
+ /// Cancellation token
+ /// The story segment or null if not found
+ Task GetByIdAsync(int id, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets all story segments for a specific level.
+ ///
+ /// The level ID
+ /// Whether to include inactive segments
+ /// Cancellation token
+ /// List of story segments ordered by Order
+ Task> GetByLevelAsync(
+ int levelId,
+ bool includeInactive = false,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets all story segments for a specific lesson.
+ ///
+ /// The lesson ID
+ /// Cancellation token
+ /// List of story segments associated with the lesson
+ Task> GetByLessonAsync(
+ int lessonId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 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 to unlock, or null if none
+ Task GetNextSegmentToUnlockAsync(
+ int levelId,
+ int completedLessonOrder,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets story segments by their order range.
+ ///
+ /// The level ID
+ /// Starting order (inclusive)
+ /// Ending order (inclusive)
+ /// Cancellation token
+ /// List of story segments in the specified order range
+ Task> GetByOrderRangeAsync(
+ int levelId,
+ int startOrder,
+ int endOrder,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Adds a new story segment.
+ ///
+ /// The story segment to add
+ /// Cancellation token
+ /// The added segment with generated ID
+ Task AddAsync(StorySegment segment, CancellationToken cancellationToken = default);
+
+ ///
+ /// Updates an existing story segment.
+ ///
+ /// The story segment to update
+ /// Cancellation token
+ Task UpdateAsync(StorySegment segment, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a story segment by its ID.
+ ///
+ /// The segment ID
+ /// Cancellation token
+ Task DeleteAsync(int id, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the highest order value for segments in a level.
+ ///
+ /// The level ID
+ /// Cancellation token
+ /// The highest order value, or 0 if no segments exist
+ Task GetMaxOrderAsync(int levelId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Checks if a story segment exists for the given ID.
+ ///
+ /// The segment ID
+ /// Cancellation token
+ /// True if the segment exists
+ Task ExistsAsync(int id, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets all story segments that need audio generation.
+ ///
+ /// Cancellation token
+ /// List of segments with null or empty AudioUrl
+ Task> GetSegmentsNeedingAudioAsync(
+ CancellationToken cancellationToken = default);
+}
diff --git a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs
index 6add88e..533e192 100644
--- a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs
+++ b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs
@@ -22,6 +22,8 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
public DbSet Quizzes { get; set; } = null!;
public DbSet QuizQuestions { get; set; } = null!;
public DbSet QuizOptions { get; set; } = null!;
+ public DbSet StorySegments { get; set; } = null!;
+ public DbSet StoryProgress { get; set; } = null!;
// Note: Value objects are not stored directly as entities.
// They are owned by entities and stored as part of the entity's data.
@@ -212,6 +214,73 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
builder.HasIndex(o => new { o.QuizQuestionId, o.Order }).IsUnique();
});
+ // Configure StorySegment entity
+ modelBuilder.Entity(builder =>
+ {
+ builder.HasKey(s => s.Id);
+ builder.Property(s => s.LevelId).IsRequired();
+ builder.Property(s => s.LessonId).IsRequired(false);
+ builder.Property(s => s.Content).IsRequired();
+ builder.Property(s => s.AudioUrl).HasMaxLength(255).IsRequired(false);
+ builder.Property(s => s.Order).IsRequired();
+ builder.Property(s => s.Title).IsRequired().HasMaxLength(200);
+ builder.Property(s => s.Theme).IsRequired().HasMaxLength(100);
+ builder.Property(s => s.EstimatedReadingMinutes).IsRequired().HasDefaultValue(2);
+ builder.Property(s => s.IsActive).HasDefaultValue(true);
+ builder.Property(s => s.CreatedAt).IsRequired();
+ builder.Property(s => s.UpdatedAt).IsRequired(false);
+
+ // Foreign key to Level
+ builder.HasOne(s => s.Level)
+ .WithMany()
+ .HasForeignKey(s => s.LevelId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ // Foreign key to Lesson (optional)
+ builder.HasOne(s => s.Lesson)
+ .WithMany()
+ .HasForeignKey(s => s.LessonId)
+ .OnDelete(DeleteBehavior.SetNull);
+
+ // Unique constraint: one segment per level per order
+ builder.HasIndex(s => new { s.LevelId, s.Order }).IsUnique();
+ });
+
+ // Configure StoryProgress entity
+ modelBuilder.Entity(builder =>
+ {
+ builder.HasKey(sp => sp.Id);
+ builder.Property(sp => sp.UserId).IsRequired();
+ builder.Property(sp => sp.LevelId).IsRequired();
+ builder.Property(sp => sp.StorySegmentId).IsRequired();
+ builder.Property(sp => sp.IsCompleted).HasDefaultValue(false);
+ builder.Property(sp => sp.UnlockedAt).IsRequired();
+ builder.Property(sp => sp.CompletedAt).IsRequired(false);
+ builder.Property(sp => sp.CreatedAt).IsRequired();
+ builder.Property(sp => sp.UpdatedAt).IsRequired(false);
+
+ // Foreign key to User
+ builder.HasOne(sp => sp.User)
+ .WithMany()
+ .HasForeignKey(sp => sp.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ // Foreign key to Level
+ builder.HasOne(sp => sp.Level)
+ .WithMany()
+ .HasForeignKey(sp => sp.LevelId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ // Foreign key to StorySegment
+ builder.HasOne(sp => sp.StorySegment)
+ .WithMany()
+ .HasForeignKey(sp => sp.StorySegmentId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ // Unique constraint: one progress record per user per segment
+ builder.HasIndex(sp => new { sp.UserId, sp.StorySegmentId }).IsUnique();
+ });
+
// Seed data (optional) - Note: For EF Core, we need to set properties directly
// In a real application, use migrations or a separate seeding mechanism
// modelBuilder.Entity().HasData(
diff --git a/GermanApp/Infrastructure/Data/Repositories/StoryProgressRepository.cs b/GermanApp/Infrastructure/Data/Repositories/StoryProgressRepository.cs
new file mode 100644
index 0000000..cdbed28
--- /dev/null
+++ b/GermanApp/Infrastructure/Data/Repositories/StoryProgressRepository.cs
@@ -0,0 +1,127 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using GermanApp.Domain.Entities;
+using GermanApp.Domain.Interfaces;
+using GermanApp.Infrastructure.Data.DbContext;
+using Microsoft.EntityFrameworkCore;
+
+namespace GermanApp.Infrastructure.Data.Repositories;
+
+///
+/// Entity Framework Core implementation of IStoryProgressRepository.
+/// This is part of the Infrastructure layer.
+///
+public class StoryProgressRepository : IStoryProgressRepository
+{
+ private readonly AppDbContext _context;
+
+ public StoryProgressRepository(AppDbContext context)
+ {
+ _context = context;
+ }
+
+ public async Task> GetByUserAndLevelAsync(
+ int userId,
+ int levelId,
+ CancellationToken cancellationToken = default)
+ {
+ return await _context.StoryProgress
+ .Where(p => p.UserId == userId && p.LevelId == levelId)
+ .Include(p => p.StorySegment)
+ .OrderBy(p => p.StorySegment != null ? p.StorySegment.Order : 0)
+ .AsNoTracking()
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task GetByUserAndSegmentAsync(
+ int userId,
+ int storySegmentId,
+ CancellationToken cancellationToken = default)
+ {
+ return await _context.StoryProgress
+ .FirstOrDefaultAsync(
+ p => p.UserId == userId && p.StorySegmentId == storySegmentId,
+ cancellationToken);
+ }
+
+ public async Task GetHighestUnlockedOrderAsync(
+ int userId,
+ int levelId,
+ CancellationToken cancellationToken = default)
+ {
+ var highestOrder = await _context.StoryProgress
+ .Where(p => p.UserId == userId && p.LevelId == levelId)
+ .Include(p => p.StorySegment)
+ .Select(p => p.StorySegment != null ? p.StorySegment.Order : 0)
+ .MaxAsync(cancellationToken);
+
+ return highestOrder == null ? 0 : highestOrder;
+ }
+
+ public async Task IsSegmentUnlockedAsync(
+ int userId,
+ int storySegmentId,
+ CancellationToken cancellationToken = default)
+ {
+ return await _context.StoryProgress
+ .AnyAsync(
+ p => p.UserId == userId && p.StorySegmentId == storySegmentId,
+ cancellationToken);
+ }
+
+ public async Task IsSegmentCompletedAsync(
+ int userId,
+ int storySegmentId,
+ CancellationToken cancellationToken = default)
+ {
+ return await _context.StoryProgress
+ .AnyAsync(
+ p => p.UserId == userId
+ && p.StorySegmentId == storySegmentId
+ && p.IsCompleted,
+ cancellationToken);
+ }
+
+ public async Task AddAsync(StoryProgress progress, CancellationToken cancellationToken = default)
+ {
+ await _context.StoryProgress.AddAsync(progress, cancellationToken);
+ await _context.SaveChangesAsync(cancellationToken);
+ return progress;
+ }
+
+ public async Task UpdateAsync(StoryProgress progress, CancellationToken cancellationToken = default)
+ {
+ _context.StoryProgress.Update(progress);
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task GetTotalSegmentsForLevelAsync(int levelId, CancellationToken cancellationToken = default)
+ {
+ return await _context.StorySegments
+ .CountAsync(s => s.LevelId == levelId && s.IsActive, cancellationToken);
+ }
+
+ public async Task GetUnlockedCountAsync(
+ int userId,
+ int levelId,
+ CancellationToken cancellationToken = default)
+ {
+ return await _context.StoryProgress
+ .CountAsync(
+ p => p.UserId == userId && p.LevelId == levelId,
+ cancellationToken);
+ }
+
+ public async Task GetCompletedCountAsync(
+ int userId,
+ int levelId,
+ CancellationToken cancellationToken = default)
+ {
+ return await _context.StoryProgress
+ .CountAsync(
+ p => p.UserId == userId && p.LevelId == levelId && p.IsCompleted,
+ cancellationToken);
+ }
+}
diff --git a/GermanApp/Infrastructure/Data/Repositories/StoryRepository.cs b/GermanApp/Infrastructure/Data/Repositories/StoryRepository.cs
new file mode 100644
index 0000000..a48a531
--- /dev/null
+++ b/GermanApp/Infrastructure/Data/Repositories/StoryRepository.cs
@@ -0,0 +1,153 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using GermanApp.Domain.Entities;
+using GermanApp.Domain.Interfaces;
+using GermanApp.Infrastructure.Data.DbContext;
+using Microsoft.EntityFrameworkCore;
+
+namespace GermanApp.Infrastructure.Data.Repositories;
+
+///
+/// Entity Framework Core implementation of IStoryRepository.
+/// This is part of the Infrastructure layer.
+///
+public class StoryRepository : IStoryRepository
+{
+ private readonly AppDbContext _context;
+
+ public StoryRepository(AppDbContext context)
+ {
+ _context = context;
+ }
+
+ public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default)
+ {
+ return await _context.StorySegments
+ .Include(s => s.Level)
+ .Include(s => s.Lesson)
+ .FirstOrDefaultAsync(s => s.Id == id, cancellationToken);
+ }
+
+ public async Task> GetByLevelAsync(
+ int levelId,
+ bool includeInactive = false,
+ CancellationToken cancellationToken = default)
+ {
+ var query = _context.StorySegments
+ .Where(s => s.LevelId == levelId);
+
+ if (!includeInactive)
+ {
+ query = query.Where(s => s.IsActive);
+ }
+
+ return await query
+ .Include(s => s.Level)
+ .Include(s => s.Lesson)
+ .OrderBy(s => s.Order)
+ .AsNoTracking()
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task> GetByLessonAsync(
+ int lessonId,
+ CancellationToken cancellationToken = default)
+ {
+ return await _context.StorySegments
+ .Where(s => s.LessonId == lessonId)
+ .Include(s => s.Level)
+ .Include(s => s.Lesson)
+ .OrderBy(s => s.Order)
+ .AsNoTracking()
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task GetNextSegmentToUnlockAsync(
+ int levelId,
+ int completedLessonOrder,
+ CancellationToken cancellationToken = default)
+ {
+ // Get the next lesson order (the one after the completed lesson)
+ // Then find the story segment with matching Lesson.Order
+ var nextLessonOrder = completedLessonOrder + 1;
+
+ return await _context.StorySegments
+ .Where(s => s.LevelId == levelId && s.LessonId != null && s.IsActive)
+ .Where(s => s.Lesson != null && s.Lesson.Order == nextLessonOrder)
+ .Include(s => s.Lesson)
+ .OrderBy(s => s.Order)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ public async Task> GetByOrderRangeAsync(
+ int levelId,
+ int startOrder,
+ int endOrder,
+ CancellationToken cancellationToken = default)
+ {
+ return await _context.StorySegments
+ .Where(s => s.LevelId == levelId
+ && s.Order >= startOrder
+ && s.Order <= endOrder
+ && s.IsActive)
+ .Include(s => s.Level)
+ .Include(s => s.Lesson)
+ .OrderBy(s => s.Order)
+ .AsNoTracking()
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task AddAsync(StorySegment segment, CancellationToken cancellationToken = default)
+ {
+ await _context.StorySegments.AddAsync(segment, cancellationToken);
+ await _context.SaveChangesAsync(cancellationToken);
+ return segment;
+ }
+
+ public async Task UpdateAsync(StorySegment segment, CancellationToken cancellationToken = default)
+ {
+ _context.StorySegments.Update(segment);
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
+ {
+ var segment = await _context.StorySegments.FindAsync(new object[] { id }, cancellationToken);
+ if (segment != null)
+ {
+ _context.StorySegments.Remove(segment);
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+ }
+
+ public async Task GetMaxOrderAsync(int levelId, CancellationToken cancellationToken = default)
+ {
+ var maxOrder = await _context.StorySegments
+ .Where(s => s.LevelId == levelId)
+ .Select(s => s.Order)
+ .MaxAsync(cancellationToken);
+
+ return maxOrder == null ? 0 : maxOrder;
+ }
+
+ public async Task ExistsAsync(int id, CancellationToken cancellationToken = default)
+ {
+ return await _context.StorySegments
+ .AnyAsync(s => s.Id == id, cancellationToken);
+ }
+
+ public async Task> GetSegmentsNeedingAudioAsync(
+ CancellationToken cancellationToken = default)
+ {
+ return await _context.StorySegments
+ .Where(s => string.IsNullOrEmpty(s.AudioUrl) && s.IsActive)
+ .Include(s => s.Level)
+ .Include(s => s.Lesson)
+ .OrderBy(s => s.LevelId)
+ .ThenBy(s => s.Order)
+ .AsNoTracking()
+ .ToListAsync(cancellationToken);
+ }
+}
diff --git a/docs/features/story-integration.md b/docs/features/story-integration.md
index 02c5770..375b3eb 100644
--- a/docs/features/story-integration.md
+++ b/docs/features/story-integration.md
@@ -1,6 +1,7 @@
# Feature: Story Integration
-> **Status**: ⏳ Planned
+> **Status**: 🚀 In Progress
+> **📊 Current Progress**: Phase 1 (Database & Models) Started
> **Priority**: High
> **Complexity**: High
> **Estimate**: 8-12 hours
@@ -127,12 +128,17 @@ Order: 1
## 🚀 Implementation Plan
### Phase 1: Database & Models (2 hours)
-- [ ] Create StorySegment entity
-- [ ] Create StorySegmentDto
-- [ ] Create StoryGenerationRequest DTO
-- [ ] Create IStoryRepository interface
-- [ ] Create StoryRepository implementation
-- [ ] Create migration for StorySegments table
+- [x] Create StorySegment entity (Domain/Entities/StorySegment.cs)
+- [x] Create StoryProgress entity (Domain/Entities/StoryProgress.cs)
+- [x] Create StorySegmentDto (Application/DTOs/StorySegmentDto.cs)
+- [x] Create StoryGenerationRequestDto
+- [x] Create IStoryRepository interface (Domain/Interfaces/IStoryRepository.cs)
+- [x] Create IStoryProgressRepository interface (Domain/Interfaces/IStoryProgressRepository.cs)
+- [x] Create StoryRepository implementation (Infrastructure/Data/Repositories/StoryRepository.cs)
+- [x] Create StoryProgressRepository implementation (Infrastructure/Data/Repositories/StoryProgressRepository.cs)
+- [x] Add DbSets to AppDbContext (StorySegments, StoryProgress)
+- [x] Add entity configurations to AppDbContext
+- [ ] Create and apply migration for StorySegments and StoryProgress tables
- [ ] Add relationships to Level and Lesson entities
### Phase 2: Backend Services (2-3 hours)
@@ -188,22 +194,26 @@ Order: 1
## ✅ Tasks
### Backend
-- [ ] Create Domain/Entities/StorySegment.cs
-- [ ] Create Application/DTOs/StorySegmentDto.cs
-- [ ] Create Application/DTOs/StoryGenerationRequest.cs
-- [ ] Create Domain/Interfaces/IStoryRepository.cs
-- [ ] Create Infrastructure/Data/Repositories/StoryRepository.cs
+- [x] Create Domain/Entities/StorySegment.cs
+- [x] Create Domain/Entities/StoryProgress.cs
+- [x] Create Application/DTOs/StorySegmentDto.cs
+- [x] Create Application/DTOs/StoryGenerationRequestDto.cs
+- [x] Create Domain/Interfaces/IStoryRepository.cs
+- [x] Create Domain/Interfaces/IStoryProgressRepository.cs
+- [x] Create Infrastructure/Data/Repositories/StoryRepository.cs
+- [x] Create Infrastructure/Data/Repositories/StoryProgressRepository.cs
+- [x] Add DbSets and configurations to AppDbContext
- [ ] Create Application/Services/StoryService.cs
-- [ ] Create Application/Services/StoryGenerationService.cs
-- [ ] Create Application/Services/MistralClientService.cs
+- [ ] Create Application/Services/StoryGenerationService.cs (already exists from AI Services, needs adaptation)
- [ ] Create Presentation/Controllers/StoryController.cs
-- [ ] Update Level and Lesson entities with StorySegment relationships
+- [ ] Update Level and Lesson entities with StorySegment relationships (navigation properties)
- [ ] Register services in Program.cs
- [ ] Write unit tests
- [ ] Write integration tests
### Database
- [ ] Create migration for StorySegments table
+- [ ] Create migration for StoryProgress table
- [ ] Add foreign keys to Levels and Lessons
- [ ] Add indexes for LevelId, LessonId, Order
- [ ] Apply migration