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