feat(backend/story-integration): Phase 1 - Database & Models

Implement story integration foundation:
- Create Domain/Entities/StorySegment.cs with full CRUD methods
- Create Domain/Entities/StoryProgress.cs for user story progress tracking
- Create Application/DTOs/StorySegmentDto.cs with multiple DTO types
- Create Domain/Interfaces/IStoryRepository.cs
- Create Domain/Interfaces/IStoryProgressRepository.cs
- Create Infrastructure/Data/Repositories/StoryRepository.cs
- Create Infrastructure/Data/Repositories/StoryProgressRepository.cs
- Add DbSets and entity configurations to AppDbContext
- Add navigation properties to Level, Lesson, and User entities

Next: Phase 2 - Backend Services (StoryService, StoryGenerationService)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Lasse Rune Hansen 2026-06-13 13:23:12 +02:00
parent 70510d264b
commit 5a514c5a2d
12 changed files with 1029 additions and 15 deletions

View file

@ -0,0 +1,104 @@
namespace GermanApp.Application.DTOs;
/// <summary>
/// Data Transfer Object for StorySegment.
/// Used for API requests and responses.
/// </summary>
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)
{
/// <summary>
/// Creates a StorySegmentDto from a domain entity.
/// </summary>
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);
}
}
/// <summary>
/// DTO for creating a new story segment.
/// </summary>
public record CreateStorySegmentDto(
int LevelId,
int? LessonId,
string Content,
int Order,
string Title,
string Theme,
int EstimatedReadingMinutes = 2);
/// <summary>
/// DTO for updating an existing story segment.
/// </summary>
public record UpdateStorySegmentDto(
string? Content = null,
int? Order = null,
string? Title = null,
string? Theme = null,
int? EstimatedReadingMinutes = null,
int? LessonId = null,
bool? IsActive = null);
/// <summary>
/// DTO for generating a story for a level.
/// </summary>
public record StoryGenerationRequestDto(
int LevelId,
string Theme,
int SegmentCount,
string? CustomPrompt = null);
/// <summary>
/// Response DTO for story generation.
/// </summary>
public record StoryGenerationResponseDto(
int LevelId,
string Theme,
int SegmentCount,
string FullStoryText,
IReadOnlyList<StorySegmentDto> Segments);
/// <summary>
/// DTO for user's story progress.
/// </summary>
public record StoryProgressDto(
int LevelId,
string LevelName,
int TotalSegments,
int UnlockedSegments,
int CurrentSegmentOrder,
IReadOnlyList<StorySegmentProgressDto> Segments);
/// <summary>
/// DTO for individual segment progress.
/// </summary>
public record StorySegmentProgressDto(
int SegmentId,
int Order,
string Title,
bool IsUnlocked,
bool IsCompleted);

View file

@ -107,4 +107,7 @@ public class Lesson
IsActive = false;
UpdatedAt = DateTime.UtcNow;
}
// Navigation properties
public virtual ICollection<StorySegment> StorySegments { get; private set; } = new List<StorySegment>();
}

View file

@ -54,4 +54,8 @@ public class Level
{
Order = newOrder;
}
// Navigation properties
public virtual ICollection<Lesson> Lessons { get; private set; } = new List<Lesson>();
public virtual ICollection<StorySegment> StorySegments { get; private set; } = new List<StorySegment>();
}

View file

@ -0,0 +1,106 @@
namespace GermanApp.Domain.Entities;
/// <summary>
/// Tracks a user's progress through story segments.
/// A user unlocks story segments by completing lessons.
/// </summary>
public class StoryProgress
{
public int Id { get; private set; }
/// <summary>
/// The user ID who owns this progress.
/// </summary>
public int UserId { get; private set; }
/// <summary>
/// The level ID this progress is for.
/// </summary>
public int LevelId { get; private set; }
/// <summary>
/// The story segment ID that was unlocked.
/// </summary>
public int StorySegmentId { get; private set; }
/// <summary>
/// Whether the user has read/listened to this segment.
/// </summary>
public bool IsCompleted { get; private set; }
/// <summary>
/// When the segment was unlocked.
/// </summary>
public DateTime UnlockedAt { get; private set; }
/// <summary>
/// When the segment was completed (read/listened to).
/// </summary>
public DateTime? CompletedAt { get; private set; }
/// <summary>
/// Timestamp when the progress record was created.
/// </summary>
public DateTime CreatedAt { get; private set; }
/// <summary>
/// Timestamp when the progress record was last updated.
/// </summary>
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; }
/// <summary>
/// Constructor for EF Core deserialization.
/// </summary>
private StoryProgress() { }
/// <summary>
/// Factory method to create a new story progress record.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="levelId">The level ID</param>
/// <param name="storySegmentId">The story segment ID</param>
/// <returns>New StoryProgress instance</returns>
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
};
}
/// <summary>
/// Marks this story segment as completed (read/listened to).
/// </summary>
public void MarkAsCompleted()
{
if (!IsCompleted)
{
IsCompleted = true;
CompletedAt = DateTime.UtcNow;
UpdatedAt = DateTime.UtcNow;
}
}
/// <summary>
/// Marks this story segment as not completed.
/// </summary>
public void MarkAsIncomplete()
{
if (IsCompleted)
{
IsCompleted = false;
CompletedAt = null;
UpdatedAt = DateTime.UtcNow;
}
}
}

View file

@ -0,0 +1,200 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace GermanApp.Domain.Entities;
/// <summary>
/// Represents a segment of a continuous story for a specific level and lesson.
/// Story segments are unlocked sequentially as users complete lessons.
/// </summary>
public class StorySegment
{
public int Id { get; private set; }
/// <summary>
/// The CEFR level this story segment belongs to.
/// </summary>
public int LevelId { get; private set; }
/// <summary>
/// The lesson this story segment is associated with.
/// Can be null if the segment is an introduction or conclusion.
/// </summary>
public int? LessonId { get; private set; }
/// <summary>
/// The text content of the story segment.
/// </summary>
public string Content { get; private set; } = string.Empty;
/// <summary>
/// URL path to the audio file for this story segment.
/// </summary>
public string? AudioUrl { get; private set; }
/// <summary>
/// The order of this segment within the level's story.
/// Segments are displayed in ascending order.
/// </summary>
public int Order { get; private set; }
/// <summary>
/// Title or brief description of this story segment.
/// </summary>
public string Title { get; private set; } = string.Empty;
/// <summary>
/// Theme or topic of this story segment.
/// </summary>
public string Theme { get; private set; } = string.Empty;
/// <summary>
/// Estimated reading time in minutes.
/// </summary>
public int EstimatedReadingMinutes { get; private set; }
/// <summary>
/// Whether this segment is active and visible to users.
/// </summary>
public bool IsActive { get; private set; } = true;
/// <summary>
/// Timestamp when the segment was created.
/// </summary>
public DateTime CreatedAt { get; private set; }
/// <summary>
/// Timestamp when the segment was last updated.
/// </summary>
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; }
/// <summary>
/// Constructor for EF Core deserialization.
/// </summary>
private StorySegment() { }
/// <summary>
/// Factory method to create a new story segment.
/// </summary>
/// <param name="levelId">ID of the level this segment belongs to</param>
/// <param name="lessonId">Optional ID of the associated lesson</param>
/// <param name="content">The story text content</param>
/// <param name="order">The order within the level's story</param>
/// <param name="title">Title of the segment</param>
/// <param name="theme">Theme or topic of the segment</param>
/// <param name="estimatedReadingMinutes">Estimated reading time in minutes</param>
/// <returns>New StorySegment instance</returns>
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
};
}
/// <summary>
/// Updates the content of the story segment.
/// </summary>
/// <param name="newContent">New content text</param>
public void UpdateContent(string newContent)
{
Content = newContent;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the audio URL for this segment.
/// </summary>
/// <param name="audioUrl">URL path to the audio file</param>
public void UpdateAudioUrl(string audioUrl)
{
AudioUrl = audioUrl;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the title of the story segment.
/// </summary>
/// <param name="newTitle">New title</param>
public void UpdateTitle(string newTitle)
{
Title = newTitle;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the theme of the story segment.
/// </summary>
/// <param name="newTheme">New theme</param>
public void UpdateTheme(string newTheme)
{
Theme = newTheme;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the estimated reading time.
/// </summary>
/// <param name="minutes">Estimated reading time in minutes</param>
public void UpdateEstimatedReadingMinutes(int minutes)
{
EstimatedReadingMinutes = minutes;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the order of this segment within the level's story.
/// </summary>
/// <param name="newOrder">New order value</param>
public void UpdateOrder(int newOrder)
{
Order = newOrder;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Updates the associated lesson.
/// </summary>
/// <param name="newLessonId">New lesson ID (can be null)</param>
public void UpdateLesson(int? newLessonId)
{
LessonId = newLessonId;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Activates this story segment.
/// </summary>
public void Activate()
{
IsActive = true;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Deactivates this story segment.
/// </summary>
public void Deactivate()
{
IsActive = false;
UpdatedAt = DateTime.UtcNow;
}
}

View file

@ -75,4 +75,7 @@ public class User
{
PasswordHash = newPasswordHash;
}
// Navigation properties
public virtual ICollection<StoryProgress> StoryProgress { get; private set; } = new List<StoryProgress>();
}

View file

@ -0,0 +1,120 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GermanApp.Domain.Entities;
namespace GermanApp.Domain.Interfaces;
/// <summary>
/// Repository interface for managing StoryProgress entities.
/// This is part of the Domain layer.
/// </summary>
public interface IStoryProgressRepository
{
/// <summary>
/// Gets story progress for a specific user and level.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="levelId">The level ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of story progress records for the user and level</returns>
Task<IReadOnlyList<StoryProgress>> GetByUserAndLevelAsync(
int userId,
int levelId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets story progress for a specific user and segment.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="storySegmentId">The story segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The story progress record, or null if not found</returns>
Task<StoryProgress?> GetByUserAndSegmentAsync(
int userId,
int storySegmentId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the highest unlocked segment order for a user in a level.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="levelId">The level ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The highest order of unlocked segments, or 0 if none</returns>
Task<int> GetHighestUnlockedOrderAsync(
int userId,
int levelId,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a user has unlocked a specific story segment.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="storySegmentId">The story segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if the segment is unlocked</returns>
Task<bool> IsSegmentUnlockedAsync(
int userId,
int storySegmentId,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a user has completed (read/listened to) a specific story segment.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="storySegmentId">The story segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if the segment is completed</returns>
Task<bool> IsSegmentCompletedAsync(
int userId,
int storySegmentId,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new story progress record.
/// </summary>
/// <param name="progress">The story progress to add</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The added progress with generated ID</returns>
Task<StoryProgress> AddAsync(StoryProgress progress, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing story progress record.
/// </summary>
/// <param name="progress">The story progress to update</param>
/// <param name="cancellationToken">Cancellation token</param>
Task UpdateAsync(StoryProgress progress, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the total count of story segments for a level.
/// </summary>
/// <param name="levelId">The level ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The total number of segments in the level</returns>
Task<int> GetTotalSegmentsForLevelAsync(int levelId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the count of unlocked segments for a user in a level.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="levelId">The level ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The number of unlocked segments</returns>
Task<int> GetUnlockedCountAsync(
int userId,
int levelId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the count of completed segments for a user in a level.
/// </summary>
/// <param name="userId">The user ID</param>
/// <param name="levelId">The level ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The number of completed segments</returns>
Task<int> GetCompletedCountAsync(
int userId,
int levelId,
CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,115 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GermanApp.Domain.Entities;
namespace GermanApp.Domain.Interfaces;
/// <summary>
/// Repository interface for managing StorySegment entities.
/// This is part of the Domain layer.
/// </summary>
public interface IStoryRepository
{
/// <summary>
/// Gets a story segment by its ID.
/// </summary>
/// <param name="id">The segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The story segment or null if not found</returns>
Task<StorySegment?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all story segments for a specific level.
/// </summary>
/// <param name="levelId">The level ID</param>
/// <param name="includeInactive">Whether to include inactive segments</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of story segments ordered by Order</returns>
Task<IReadOnlyList<StorySegment>> GetByLevelAsync(
int levelId,
bool includeInactive = false,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all story segments for a specific lesson.
/// </summary>
/// <param name="lessonId">The lesson ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of story segments associated with the lesson</returns>
Task<IReadOnlyList<StorySegment>> GetByLessonAsync(
int lessonId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the next segment to unlock for a user after completing a lesson.
/// </summary>
/// <param name="levelId">The level ID</param>
/// <param name="completedLessonOrder">The order of the completed lesson</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The next story segment to unlock, or null if none</returns>
Task<StorySegment?> GetNextSegmentToUnlockAsync(
int levelId,
int completedLessonOrder,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets story segments by their order range.
/// </summary>
/// <param name="levelId">The level ID</param>
/// <param name="startOrder">Starting order (inclusive)</param>
/// <param name="endOrder">Ending order (inclusive)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of story segments in the specified order range</returns>
Task<IReadOnlyList<StorySegment>> GetByOrderRangeAsync(
int levelId,
int startOrder,
int endOrder,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new story segment.
/// </summary>
/// <param name="segment">The story segment to add</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The added segment with generated ID</returns>
Task<StorySegment> AddAsync(StorySegment segment, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing story segment.
/// </summary>
/// <param name="segment">The story segment to update</param>
/// <param name="cancellationToken">Cancellation token</param>
Task UpdateAsync(StorySegment segment, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a story segment by its ID.
/// </summary>
/// <param name="id">The segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the highest order value for segments in a level.
/// </summary>
/// <param name="levelId">The level ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The highest order value, or 0 if no segments exist</returns>
Task<int> GetMaxOrderAsync(int levelId, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a story segment exists for the given ID.
/// </summary>
/// <param name="id">The segment ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if the segment exists</returns>
Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all story segments that need audio generation.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of segments with null or empty AudioUrl</returns>
Task<IReadOnlyList<StorySegment>> GetSegmentsNeedingAudioAsync(
CancellationToken cancellationToken = default);
}

View file

@ -22,6 +22,8 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
public DbSet<Quiz> Quizzes { get; set; } = null!;
public DbSet<QuizQuestion> QuizQuestions { get; set; } = null!;
public DbSet<QuizOption> QuizOptions { get; set; } = null!;
public DbSet<StorySegment> StorySegments { get; set; } = null!;
public DbSet<StoryProgress> 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<StorySegment>(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<StoryProgress>(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<Lesson>().HasData(

View file

@ -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;
/// <summary>
/// Entity Framework Core implementation of IStoryProgressRepository.
/// This is part of the Infrastructure layer.
/// </summary>
public class StoryProgressRepository : IStoryProgressRepository
{
private readonly AppDbContext _context;
public StoryProgressRepository(AppDbContext context)
{
_context = context;
}
public async Task<IReadOnlyList<StoryProgress>> 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<StoryProgress?> GetByUserAndSegmentAsync(
int userId,
int storySegmentId,
CancellationToken cancellationToken = default)
{
return await _context.StoryProgress
.FirstOrDefaultAsync(
p => p.UserId == userId && p.StorySegmentId == storySegmentId,
cancellationToken);
}
public async Task<int> 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<bool> IsSegmentUnlockedAsync(
int userId,
int storySegmentId,
CancellationToken cancellationToken = default)
{
return await _context.StoryProgress
.AnyAsync(
p => p.UserId == userId && p.StorySegmentId == storySegmentId,
cancellationToken);
}
public async Task<bool> 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<StoryProgress> 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<int> GetTotalSegmentsForLevelAsync(int levelId, CancellationToken cancellationToken = default)
{
return await _context.StorySegments
.CountAsync(s => s.LevelId == levelId && s.IsActive, cancellationToken);
}
public async Task<int> GetUnlockedCountAsync(
int userId,
int levelId,
CancellationToken cancellationToken = default)
{
return await _context.StoryProgress
.CountAsync(
p => p.UserId == userId && p.LevelId == levelId,
cancellationToken);
}
public async Task<int> GetCompletedCountAsync(
int userId,
int levelId,
CancellationToken cancellationToken = default)
{
return await _context.StoryProgress
.CountAsync(
p => p.UserId == userId && p.LevelId == levelId && p.IsCompleted,
cancellationToken);
}
}

View file

@ -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;
/// <summary>
/// Entity Framework Core implementation of IStoryRepository.
/// This is part of the Infrastructure layer.
/// </summary>
public class StoryRepository : IStoryRepository
{
private readonly AppDbContext _context;
public StoryRepository(AppDbContext context)
{
_context = context;
}
public async Task<StorySegment?> 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<IReadOnlyList<StorySegment>> 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<IReadOnlyList<StorySegment>> 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<StorySegment?> 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<IReadOnlyList<StorySegment>> 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<StorySegment> 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<int> 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<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.StorySegments
.AnyAsync(s => s.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<StorySegment>> 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);
}
}

View file

@ -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