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:
parent
70510d264b
commit
5a514c5a2d
12 changed files with 1029 additions and 15 deletions
104
GermanApp/Application/DTOs/StorySegmentDto.cs
Normal file
104
GermanApp/Application/DTOs/StorySegmentDto.cs
Normal 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);
|
||||
|
|
@ -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>();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
}
|
||||
|
|
|
|||
106
GermanApp/Domain/Entities/StoryProgress.cs
Normal file
106
GermanApp/Domain/Entities/StoryProgress.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
200
GermanApp/Domain/Entities/StorySegment.cs
Normal file
200
GermanApp/Domain/Entities/StorySegment.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -75,4 +75,7 @@ public class User
|
|||
{
|
||||
PasswordHash = newPasswordHash;
|
||||
}
|
||||
|
||||
// Navigation properties
|
||||
public virtual ICollection<StoryProgress> StoryProgress { get; private set; } = new List<StoryProgress>();
|
||||
}
|
||||
|
|
|
|||
120
GermanApp/Domain/Interfaces/IStoryProgressRepository.cs
Normal file
120
GermanApp/Domain/Interfaces/IStoryProgressRepository.cs
Normal 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);
|
||||
}
|
||||
115
GermanApp/Domain/Interfaces/IStoryRepository.cs
Normal file
115
GermanApp/Domain/Interfaces/IStoryRepository.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
153
GermanApp/Infrastructure/Data/Repositories/StoryRepository.cs
Normal file
153
GermanApp/Infrastructure/Data/Repositories/StoryRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue