diff --git a/backend/DramaLing.Api/Controllers/FlashcardsController.cs b/backend/DramaLing.Api/Controllers/FlashcardsController.cs index 84045f1..b11ff54 100644 --- a/backend/DramaLing.Api/Controllers/FlashcardsController.cs +++ b/backend/DramaLing.Api/Controllers/FlashcardsController.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Mvc; using DramaLing.Api.Models.Entities; +using DramaLing.Api.Models.DTOs; using DramaLing.Api.Repositories; +using DramaLing.Api.Services.Review; using Microsoft.AspNetCore.Authorization; using DramaLing.Api.Utils; @@ -11,12 +13,15 @@ namespace DramaLing.Api.Controllers; public class FlashcardsController : BaseController { private readonly IFlashcardRepository _flashcardRepository; + private readonly IReviewService _reviewService; public FlashcardsController( IFlashcardRepository flashcardRepository, + IReviewService reviewService, ILogger logger) : base(logger) { _flashcardRepository = flashcardRepository; + _reviewService = reviewService; } [HttpGet] @@ -283,6 +288,64 @@ public class FlashcardsController : BaseController return ErrorResponse("INTERNAL_ERROR", "切換收藏狀態失敗"); } } + + [HttpGet("due")] + public async Task GetDueFlashcards( + [FromQuery] int limit = 10, + [FromQuery] bool includeToday = true, + [FromQuery] bool includeOverdue = true, + [FromQuery] bool favoritesOnly = false) + { + try + { + var userId = await GetCurrentUserIdAsync(); + + var query = new DueFlashcardsQuery + { + Limit = limit, + IncludeToday = includeToday, + IncludeOverdue = includeOverdue, + FavoritesOnly = favoritesOnly + }; + + var response = await _reviewService.GetDueFlashcardsAsync(userId, query); + return Ok(response); + } + catch (UnauthorizedAccessException) + { + return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting due flashcards"); + return ErrorResponse("INTERNAL_ERROR", "載入待複習詞卡失敗"); + } + } + + [HttpPost("{id}/review")] + public async Task SubmitReview(Guid id, [FromBody] ReviewRequest request) + { + try + { + if (!ModelState.IsValid) + { + return HandleModelStateErrors(); + } + + var userId = await GetCurrentUserIdAsync(); + var response = await _reviewService.SubmitReviewAsync(userId, id, request); + return Ok(response); + } + catch (UnauthorizedAccessException) + { + return ErrorResponse("UNAUTHORIZED", "認證失敗", null, 401); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error submitting review for flashcard {FlashcardId}", id); + return ErrorResponse("INTERNAL_ERROR", "提交複習結果失敗"); + } + } } // DTO 類別 diff --git a/backend/DramaLing.Api/Data/DramaLingDbContext.cs b/backend/DramaLing.Api/Data/DramaLingDbContext.cs index 18775f0..a9c7eab 100644 --- a/backend/DramaLing.Api/Data/DramaLingDbContext.cs +++ b/backend/DramaLing.Api/Data/DramaLingDbContext.cs @@ -50,6 +50,7 @@ public class DramaLingDbContext : DbContext modelBuilder.Entity().ToTable("flashcard_example_images"); modelBuilder.Entity().ToTable("image_generation_requests"); modelBuilder.Entity().ToTable("options_vocabularies"); + modelBuilder.Entity().ToTable("flashcard_reviews"); modelBuilder.Entity().ToTable("sentence_analysis_cache"); modelBuilder.Entity().ToTable("word_query_usage_stats"); @@ -66,6 +67,7 @@ public class DramaLingDbContext : DbContext ConfigureAudioEntities(modelBuilder); ConfigureImageGenerationEntities(modelBuilder); ConfigureOptionsVocabularyEntity(modelBuilder); + ConfigureFlashcardReviewEntity(modelBuilder); // 複合主鍵 modelBuilder.Entity() @@ -546,4 +548,30 @@ public class DramaLingDbContext : DbContext optionsVocabEntity.Property(ov => ov.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP"); optionsVocabEntity.Property(ov => ov.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP"); } + + private void ConfigureFlashcardReviewEntity(ModelBuilder modelBuilder) + { + var reviewEntity = modelBuilder.Entity(); + + // Configure column names (snake_case) + reviewEntity.Property(fr => fr.Id).HasColumnName("id"); + reviewEntity.Property(fr => fr.FlashcardId).HasColumnName("flashcard_id"); + reviewEntity.Property(fr => fr.UserId).HasColumnName("user_id"); + reviewEntity.Property(fr => fr.SuccessCount).HasColumnName("success_count"); + reviewEntity.Property(fr => fr.NextReviewDate).HasColumnName("next_review_date"); + reviewEntity.Property(fr => fr.LastReviewDate).HasColumnName("last_review_date"); + reviewEntity.Property(fr => fr.LastSuccessDate).HasColumnName("last_success_date"); + reviewEntity.Property(fr => fr.TotalSkipCount).HasColumnName("total_skip_count"); + reviewEntity.Property(fr => fr.TotalWrongCount).HasColumnName("total_wrong_count"); + reviewEntity.Property(fr => fr.TotalCorrectCount).HasColumnName("total_correct_count"); + reviewEntity.Property(fr => fr.CreatedAt).HasColumnName("created_at"); + reviewEntity.Property(fr => fr.UpdatedAt).HasColumnName("updated_at"); + + // Configure indexes for performance + reviewEntity.HasIndex(fr => fr.NextReviewDate) + .HasDatabaseName("IX_FlashcardReviews_NextReviewDate"); + + reviewEntity.HasIndex(fr => new { fr.UserId, fr.NextReviewDate }) + .HasDatabaseName("IX_FlashcardReviews_UserId_NextReviewDate"); + } } \ No newline at end of file diff --git a/backend/DramaLing.Api/Extensions/ServiceCollectionExtensions.cs b/backend/DramaLing.Api/Extensions/ServiceCollectionExtensions.cs index 98f4bce..d8cce98 100644 --- a/backend/DramaLing.Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/DramaLing.Api/Extensions/ServiceCollectionExtensions.cs @@ -53,6 +53,7 @@ public static class ServiceCollectionExtensions services.AddScoped(typeof(IRepository<>), typeof(BaseRepository<>)); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } @@ -152,6 +153,9 @@ public static class ServiceCollectionExtensions // 分析服務 services.AddScoped(); + // 複習服務 + services.AddScoped(); + return services; } diff --git a/backend/DramaLing.Api/Migrations/20251006122004_AddFlashcardReviewTable.Designer.cs b/backend/DramaLing.Api/Migrations/20251006122004_AddFlashcardReviewTable.Designer.cs new file mode 100644 index 0000000..babbed7 --- /dev/null +++ b/backend/DramaLing.Api/Migrations/20251006122004_AddFlashcardReviewTable.Designer.cs @@ -0,0 +1,1341 @@ +// +using System; +using DramaLing.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DramaLing.Api.Migrations +{ + [DbContext(typeof(DramaLingDbContext))] + [Migration("20251006122004_AddFlashcardReviewTable")] + partial class AddFlashcardReviewTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.AudioCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Accent") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("TEXT") + .HasColumnName("accent"); + + b.Property("AccessCount") + .HasColumnType("INTEGER") + .HasColumnName("access_count"); + + b.Property("AudioUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("audio_url"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DurationMs") + .HasColumnType("INTEGER") + .HasColumnName("duration_ms"); + + b.Property("FileSize") + .HasColumnType("INTEGER") + .HasColumnName("file_size"); + + b.Property("LastAccessed") + .HasColumnType("TEXT") + .HasColumnName("last_accessed"); + + b.Property("TextContent") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text_content"); + + b.Property("TextHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("text_hash"); + + b.Property("VoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("voice_id"); + + b.HasKey("Id"); + + b.HasIndex("LastAccessed") + .HasDatabaseName("IX_AudioCache_LastAccessed"); + + b.HasIndex("TextHash") + .IsUnique() + .HasDatabaseName("IX_AudioCache_TextHash"); + + b.ToTable("audio_cache", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.DailyStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AiApiCalls") + .HasColumnType("INTEGER") + .HasColumnName("ai_api_calls"); + + b.Property("CardsGenerated") + .HasColumnType("INTEGER") + .HasColumnName("cards_generated"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("SessionCount") + .HasColumnType("INTEGER") + .HasColumnName("session_count"); + + b.Property("StudyTimeSeconds") + .HasColumnType("INTEGER") + .HasColumnName("study_time_seconds"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("WordsCorrect") + .HasColumnType("INTEGER") + .HasColumnName("words_correct"); + + b.Property("WordsStudied") + .HasColumnType("INTEGER") + .HasColumnName("words_studied"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Date") + .IsUnique(); + + b.ToTable("daily_stats", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ErrorReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AdminNotes") + .HasColumnType("TEXT") + .HasColumnName("admin_notes"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("ReportType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("report_type"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT") + .HasColumnName("resolved_at"); + + b.Property("ResolvedBy") + .HasColumnType("TEXT") + .HasColumnName("resolved_by"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("StudyMode") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("study_mode"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("ResolvedBy"); + + b.HasIndex("UserId"); + + b.ToTable("error_reports", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ExampleImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessCount") + .HasColumnType("INTEGER") + .HasColumnName("access_count"); + + b.Property("AltText") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("alt_text"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("content_hash"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileSize") + .HasColumnType("INTEGER") + .HasColumnName("file_size"); + + b.Property("GeminiCost") + .HasColumnType("TEXT") + .HasColumnName("gemini_cost"); + + b.Property("GeminiDescription") + .HasColumnType("TEXT") + .HasColumnName("gemini_description"); + + b.Property("GeminiPrompt") + .HasColumnType("TEXT") + .HasColumnName("gemini_prompt"); + + b.Property("ImageHeight") + .HasColumnType("INTEGER") + .HasColumnName("image_height"); + + b.Property("ImageWidth") + .HasColumnType("INTEGER") + .HasColumnName("image_width"); + + b.Property("ModerationNotes") + .HasColumnType("TEXT") + .HasColumnName("moderation_notes"); + + b.Property("ModerationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("moderation_status"); + + b.Property("QualityScore") + .HasColumnType("TEXT") + .HasColumnName("quality_score"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("relative_path"); + + b.Property("ReplicateCost") + .HasColumnType("TEXT") + .HasColumnName("replicate_cost"); + + b.Property("ReplicateModel") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("replicate_model"); + + b.Property("ReplicatePrompt") + .HasColumnType("TEXT") + .HasColumnName("replicate_prompt"); + + b.Property("ReplicateVersion") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("replicate_version"); + + b.Property("TotalGenerationCost") + .HasColumnType("TEXT") + .HasColumnName("total_generation_cost"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("AccessCount"); + + b.HasIndex("ContentHash") + .IsUnique(); + + b.ToTable("example_images", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Definition") + .HasColumnType("TEXT") + .HasColumnName("definition"); + + b.Property("DifficultyLevelNumeric") + .HasColumnType("INTEGER") + .HasColumnName("difficulty_level_numeric"); + + b.Property("Example") + .HasColumnType("TEXT") + .HasColumnName("example"); + + b.Property("ExampleTranslation") + .HasColumnType("TEXT") + .HasColumnName("example_translation"); + + b.Property("IsArchived") + .HasColumnType("INTEGER") + .HasColumnName("is_archived"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER") + .HasColumnName("is_favorite"); + + b.Property("PartOfSpeech") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("part_of_speech"); + + b.Property("Pronunciation") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("pronunciation"); + + b.Property("Translation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("translation"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("word"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("flashcards", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardExampleImage", b => + { + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("ExampleImageId") + .HasColumnType("TEXT") + .HasColumnName("example_image_id"); + + b.Property("ContextRelevance") + .HasColumnType("TEXT") + .HasColumnName("context_relevance"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DisplayOrder") + .HasColumnType("INTEGER") + .HasColumnName("display_order"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER") + .HasColumnName("is_primary"); + + b.HasKey("FlashcardId", "ExampleImageId"); + + b.HasIndex("ExampleImageId"); + + b.ToTable("flashcard_example_images", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("LastReviewDate") + .HasColumnType("TEXT") + .HasColumnName("last_review_date"); + + b.Property("LastSuccessDate") + .HasColumnType("TEXT") + .HasColumnName("last_success_date"); + + b.Property("NextReviewDate") + .HasColumnType("TEXT") + .HasColumnName("next_review_date"); + + b.Property("SuccessCount") + .HasColumnType("INTEGER") + .HasColumnName("success_count"); + + b.Property("TotalCorrectCount") + .HasColumnType("INTEGER") + .HasColumnName("total_correct_count"); + + b.Property("TotalSkipCount") + .HasColumnType("INTEGER") + .HasColumnName("total_skip_count"); + + b.Property("TotalWrongCount") + .HasColumnType("INTEGER") + .HasColumnName("total_wrong_count"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("NextReviewDate") + .HasDatabaseName("IX_FlashcardReviews_NextReviewDate"); + + b.HasIndex("FlashcardId", "UserId") + .IsUnique(); + + b.HasIndex("UserId", "NextReviewDate") + .HasDatabaseName("IX_FlashcardReviews_UserId_NextReviewDate"); + + b.ToTable("flashcard_reviews", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b => + { + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("TagId") + .HasColumnType("TEXT") + .HasColumnName("tag_id"); + + b.HasKey("FlashcardId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("flashcard_tags", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ImageGenerationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FinalReplicatePrompt") + .HasColumnType("TEXT") + .HasColumnName("final_replicate_prompt"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("GeminiCompletedAt") + .HasColumnType("TEXT") + .HasColumnName("gemini_completed_at"); + + b.Property("GeminiCost") + .HasColumnType("TEXT") + .HasColumnName("gemini_cost"); + + b.Property("GeminiErrorMessage") + .HasColumnType("TEXT") + .HasColumnName("gemini_error_message"); + + b.Property("GeminiProcessingTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("gemini_processing_time_ms"); + + b.Property("GeminiPrompt") + .HasColumnType("TEXT") + .HasColumnName("gemini_prompt"); + + b.Property("GeminiStartedAt") + .HasColumnType("TEXT") + .HasColumnName("gemini_started_at"); + + b.Property("GeminiStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("gemini_status"); + + b.Property("GeneratedDescription") + .HasColumnType("TEXT") + .HasColumnName("generated_description"); + + b.Property("GeneratedImageId") + .HasColumnType("TEXT") + .HasColumnName("generated_image_id"); + + b.Property("OriginalRequest") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("original_request"); + + b.Property("OverallStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("overall_status"); + + b.Property("ReplicateCompletedAt") + .HasColumnType("TEXT") + .HasColumnName("replicate_completed_at"); + + b.Property("ReplicateCost") + .HasColumnType("TEXT") + .HasColumnName("replicate_cost"); + + b.Property("ReplicateErrorMessage") + .HasColumnType("TEXT") + .HasColumnName("replicate_error_message"); + + b.Property("ReplicateProcessingTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("replicate_processing_time_ms"); + + b.Property("ReplicateStartedAt") + .HasColumnType("TEXT") + .HasColumnName("replicate_started_at"); + + b.Property("ReplicateStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("replicate_status"); + + b.Property("TotalCost") + .HasColumnType("TEXT") + .HasColumnName("total_cost"); + + b.Property("TotalProcessingTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("total_processing_time_ms"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("GeneratedImageId"); + + b.HasIndex("UserId"); + + b.ToTable("image_generation_requests", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.OptionsVocabulary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CEFRLevel") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("TEXT") + .HasColumnName("cefr_level"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("PartOfSpeech") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("part_of_speech"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("word"); + + b.Property("WordLength") + .HasColumnType("INTEGER") + .HasColumnName("word_length"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "IsActive" }, "IX_OptionsVocabulary_Active"); + + b.HasIndex(new[] { "CEFRLevel" }, "IX_OptionsVocabulary_CEFR"); + + b.HasIndex(new[] { "CEFRLevel", "PartOfSpeech", "WordLength" }, "IX_OptionsVocabulary_Core_Matching"); + + b.HasIndex(new[] { "PartOfSpeech" }, "IX_OptionsVocabulary_PartOfSpeech"); + + b.HasIndex(new[] { "Word" }, "IX_OptionsVocabulary_Word") + .IsUnique(); + + b.HasIndex(new[] { "WordLength" }, "IX_OptionsVocabulary_WordLength"); + + b.ToTable("options_vocabularies", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.PronunciationAssessment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccuracyScore") + .HasColumnType("TEXT") + .HasColumnName("accuracy_score"); + + b.Property("AudioUrl") + .HasColumnType("TEXT") + .HasColumnName("audio_url"); + + b.Property("CompletenessScore") + .HasColumnType("TEXT") + .HasColumnName("completeness_score"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("FluencyScore") + .HasColumnType("TEXT") + .HasColumnName("fluency_score"); + + b.Property("OverallScore") + .HasColumnType("INTEGER") + .HasColumnName("overall_score"); + + b.Property("PhonemeScores") + .HasColumnType("TEXT") + .HasColumnName("phoneme_scores"); + + b.Property("PracticeMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("practice_mode"); + + b.Property("ProsodyScore") + .HasColumnType("TEXT") + .HasColumnName("prosody_score"); + + b.Property("Suggestions") + .HasColumnType("TEXT") + .HasColumnName("suggestions"); + + b.Property("TargetText") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_text"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("UserId", "FlashcardId") + .HasDatabaseName("IX_PronunciationAssessment_UserFlashcard"); + + b.ToTable("pronunciation_assessments", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.SentenceAnalysisCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessCount") + .HasColumnType("INTEGER") + .HasColumnName("access_count"); + + b.Property("AnalysisResult") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("analysis_result"); + + b.Property("CorrectedText") + .HasMaxLength(1000) + .HasColumnType("TEXT") + .HasColumnName("corrected_text"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT") + .HasColumnName("expires_at"); + + b.Property("GrammarCorrections") + .HasColumnType("TEXT") + .HasColumnName("grammar_corrections"); + + b.Property("HasGrammarErrors") + .HasColumnType("INTEGER") + .HasColumnName("has_grammar_errors"); + + b.Property("HighValueWords") + .HasColumnType("TEXT") + .HasColumnName("high_value_words"); + + b.Property("IdiomsDetected") + .HasColumnType("TEXT") + .HasColumnName("idioms_detected"); + + b.Property("InputText") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT") + .HasColumnName("input_text"); + + b.Property("InputTextHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("input_text_hash"); + + b.Property("LastAccessedAt") + .HasColumnType("TEXT") + .HasColumnName("last_accessed_at"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_SentenceAnalysisCache_Expires"); + + b.HasIndex("InputTextHash") + .HasDatabaseName("IX_SentenceAnalysisCache_Hash"); + + b.HasIndex("InputTextHash", "ExpiresAt") + .HasDatabaseName("IX_SentenceAnalysisCache_Hash_Expires"); + + b.ToTable("sentence_analysis_cache", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("color"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("UsageCount") + .HasColumnType("INTEGER") + .HasColumnName("usage_count"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("email"); + + b.Property("EnglishLevel") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT") + .HasColumnName("english_level"); + + b.Property("EnglishLevelNumeric") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(2) + .HasColumnName("english_level_numeric"); + + b.Property("IsLevelVerified") + .HasColumnType("INTEGER") + .HasColumnName("is_level_verified"); + + b.Property("LevelNotes") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("level_notes"); + + b.Property("LevelUpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("level_updated_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password_hash"); + + b.Property("Preferences") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("preferences"); + + b.Property("SubscriptionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("subscription_type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("user_profiles", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserAudioPreferences", b => + { + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("AutoPlayEnabled") + .HasColumnType("INTEGER") + .HasColumnName("auto_play_enabled"); + + b.Property("DefaultSpeed") + .HasColumnType("TEXT") + .HasColumnName("default_speed"); + + b.Property("EnableDetailedFeedback") + .HasColumnType("INTEGER") + .HasColumnName("enable_detailed_feedback"); + + b.Property("PreferredAccent") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("TEXT") + .HasColumnName("preferred_accent"); + + b.Property("PreferredVoiceFemale") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("preferred_voice_female"); + + b.Property("PreferredVoiceMale") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("preferred_voice_male"); + + b.Property("PronunciationDifficulty") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("pronunciation_difficulty"); + + b.Property("TargetScoreThreshold") + .HasColumnType("INTEGER") + .HasColumnName("target_score_threshold"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("UserId"); + + b.ToTable("user_audio_preferences", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AutoPlayAudio") + .HasColumnType("INTEGER") + .HasColumnName("auto_play_audio"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DailyGoal") + .HasColumnType("INTEGER") + .HasColumnName("daily_goal"); + + b.Property("DifficultyPreference") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("difficulty_preference"); + + b.Property("ReminderEnabled") + .HasColumnType("INTEGER") + .HasColumnName("reminder_enabled"); + + b.Property("ReminderTime") + .HasColumnType("TEXT") + .HasColumnName("reminder_time"); + + b.Property("ShowPronunciation") + .HasColumnType("INTEGER") + .HasColumnName("show_pronunciation"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_settings", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.WordQueryUsageStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("HighValueWordClicks") + .HasColumnType("INTEGER") + .HasColumnName("high_value_word_clicks"); + + b.Property("LowValueWordClicks") + .HasColumnType("INTEGER") + .HasColumnName("low_value_word_clicks"); + + b.Property("SentenceAnalysisCount") + .HasColumnType("INTEGER") + .HasColumnName("sentence_analysis_count"); + + b.Property("TotalApiCalls") + .HasColumnType("INTEGER") + .HasColumnName("total_api_calls"); + + b.Property("UniqueWordsQueried") + .HasColumnType("INTEGER") + .HasColumnName("unique_words_queried"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_WordQueryUsageStats_CreatedAt"); + + b.HasIndex("UserId", "Date") + .IsUnique() + .HasDatabaseName("IX_WordQueryUsageStats_UserDate"); + + b.ToTable("word_query_usage_stats", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.DailyStats", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("DailyStats") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ErrorReport", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("ErrorReports") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.User", "ResolvedByUser") + .WithMany() + .HasForeignKey("ResolvedBy") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("ErrorReports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("ResolvedByUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("Flashcards") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardExampleImage", b => + { + b.HasOne("DramaLing.Api.Models.Entities.ExampleImage", "ExampleImage") + .WithMany("FlashcardExampleImages") + .HasForeignKey("ExampleImageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("FlashcardExampleImages") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ExampleImage"); + + b.Navigation("Flashcard"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardReview", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany() + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("FlashcardTags") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.Tag", "Tag") + .WithMany("FlashcardTags") + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ImageGenerationRequest", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany() + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.ExampleImage", "GeneratedImage") + .WithMany() + .HasForeignKey("GeneratedImageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("GeneratedImage"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.PronunciationAssessment", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany() + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserAudioPreferences", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithOne() + .HasForeignKey("DramaLing.Api.Models.Entities.UserAudioPreferences", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserSettings", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithOne("Settings") + .HasForeignKey("DramaLing.Api.Models.Entities.UserSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.WordQueryUsageStats", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ExampleImage", b => + { + b.Navigation("FlashcardExampleImages"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b => + { + b.Navigation("ErrorReports"); + + b.Navigation("FlashcardExampleImages"); + + b.Navigation("FlashcardTags"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.Navigation("FlashcardTags"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b => + { + b.Navigation("DailyStats"); + + b.Navigation("ErrorReports"); + + b.Navigation("Flashcards"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/DramaLing.Api/Migrations/20251006122004_AddFlashcardReviewTable.cs b/backend/DramaLing.Api/Migrations/20251006122004_AddFlashcardReviewTable.cs new file mode 100644 index 0000000..74bfaaf --- /dev/null +++ b/backend/DramaLing.Api/Migrations/20251006122004_AddFlashcardReviewTable.cs @@ -0,0 +1,72 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DramaLing.Api.Migrations +{ + /// + public partial class AddFlashcardReviewTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "flashcard_reviews", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + flashcard_id = table.Column(type: "TEXT", nullable: false), + user_id = table.Column(type: "TEXT", nullable: false), + success_count = table.Column(type: "INTEGER", nullable: false), + next_review_date = table.Column(type: "TEXT", nullable: false), + last_review_date = table.Column(type: "TEXT", nullable: true), + last_success_date = table.Column(type: "TEXT", nullable: true), + total_skip_count = table.Column(type: "INTEGER", nullable: false), + total_wrong_count = table.Column(type: "INTEGER", nullable: false), + total_correct_count = table.Column(type: "INTEGER", nullable: false), + created_at = table.Column(type: "TEXT", nullable: false), + updated_at = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_flashcard_reviews", x => x.id); + table.ForeignKey( + name: "FK_flashcard_reviews_flashcards_flashcard_id", + column: x => x.flashcard_id, + principalTable: "flashcards", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_flashcard_reviews_user_profiles_user_id", + column: x => x.user_id, + principalTable: "user_profiles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_flashcard_reviews_flashcard_id_user_id", + table: "flashcard_reviews", + columns: new[] { "flashcard_id", "user_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_FlashcardReviews_NextReviewDate", + table: "flashcard_reviews", + column: "next_review_date"); + + migrationBuilder.CreateIndex( + name: "IX_FlashcardReviews_UserId_NextReviewDate", + table: "flashcard_reviews", + columns: new[] { "user_id", "next_review_date" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "flashcard_reviews"); + } + } +} diff --git a/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs b/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs index 50a696a..34e212f 100644 --- a/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs +++ b/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs @@ -407,6 +407,71 @@ namespace DramaLing.Api.Migrations b.ToTable("flashcard_example_images", (string)null); }); + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("LastReviewDate") + .HasColumnType("TEXT") + .HasColumnName("last_review_date"); + + b.Property("LastSuccessDate") + .HasColumnType("TEXT") + .HasColumnName("last_success_date"); + + b.Property("NextReviewDate") + .HasColumnType("TEXT") + .HasColumnName("next_review_date"); + + b.Property("SuccessCount") + .HasColumnType("INTEGER") + .HasColumnName("success_count"); + + b.Property("TotalCorrectCount") + .HasColumnType("INTEGER") + .HasColumnName("total_correct_count"); + + b.Property("TotalSkipCount") + .HasColumnType("INTEGER") + .HasColumnName("total_skip_count"); + + b.Property("TotalWrongCount") + .HasColumnType("INTEGER") + .HasColumnName("total_wrong_count"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("NextReviewDate") + .HasDatabaseName("IX_FlashcardReviews_NextReviewDate"); + + b.HasIndex("FlashcardId", "UserId") + .IsUnique(); + + b.HasIndex("UserId", "NextReviewDate") + .HasDatabaseName("IX_FlashcardReviews_UserId_NextReviewDate"); + + b.ToTable("flashcard_reviews", (string)null); + }); + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b => { b.Property("FlashcardId") @@ -1112,6 +1177,25 @@ namespace DramaLing.Api.Migrations b.Navigation("Flashcard"); }); + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardReview", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany() + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("User"); + }); + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b => { b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") diff --git a/backend/DramaLing.Api/Repositories/FlashcardReviewRepository.cs b/backend/DramaLing.Api/Repositories/FlashcardReviewRepository.cs new file mode 100644 index 0000000..1e306cd --- /dev/null +++ b/backend/DramaLing.Api/Repositories/FlashcardReviewRepository.cs @@ -0,0 +1,150 @@ +using Microsoft.EntityFrameworkCore; +using DramaLing.Api.Data; +using DramaLing.Api.Models.Entities; +using DramaLing.Api.Models.DTOs; + +namespace DramaLing.Api.Repositories; + +public class FlashcardReviewRepository : BaseRepository, IFlashcardReviewRepository +{ + public FlashcardReviewRepository( + DramaLingDbContext context, + ILogger> logger) : base(context, logger) + { + } + + public async Task> GetDueFlashcardsAsync( + Guid userId, + DueFlashcardsQuery query) + { + var now = DateTime.UtcNow; + var cutoffDate = now; + + if (query.IncludeToday) + { + cutoffDate = cutoffDate.AddDays(1); // 包含今天到期的 + } + + // 簡化查詢:分別獲取詞卡和複習記錄,避免複雜的 GroupJoin + + // 首先獲取用戶的詞卡 + var flashcardsQuery = _context.Flashcards + .Where(f => f.UserId == userId && !f.IsArchived); + + // 如果只要收藏的卡片 + if (query.FavoritesOnly) + { + flashcardsQuery = flashcardsQuery.Where(f => f.IsFavorite); + } + + var allFlashcards = await flashcardsQuery.ToListAsync(); + + // 獲取用戶的所有複習記錄 + var reviewsDict = await _context.FlashcardReviews + .Where(fr => fr.UserId == userId) + .ToDictionaryAsync(fr => fr.FlashcardId, fr => fr); + + // 在記憶體中進行篩選和排序 + var candidateItems = allFlashcards.Select(flashcard => + { + reviewsDict.TryGetValue(flashcard.Id, out var review); + return new { Flashcard = flashcard, Review = review }; + }) + .Where(x => + // 沒有複習記錄的新卡片 + x.Review == null || + // 或者到期需要複習的卡片 + (x.Review.NextReviewDate <= cutoffDate)) + .Where(x => + // 如果不包含過期,過濾掉過期的卡片 + query.IncludeOverdue || x.Review == null || x.Review.NextReviewDate >= now.Date) + .OrderBy(x => x.Review?.NextReviewDate ?? DateTime.MinValue) + .ThenBy(x => x.Flashcard.CreatedAt) + .Take(query.Limit); + + var results = candidateItems.ToList(); + + return results.Select(x => (x.Flashcard, x.Review)); + } + + public async Task GetOrCreateReviewAsync(Guid userId, Guid flashcardId) + { + var existingReview = await _context.FlashcardReviews + .FirstOrDefaultAsync(fr => fr.UserId == userId && fr.FlashcardId == flashcardId); + + if (existingReview != null) + { + return existingReview; + } + + // 創建新的複習記錄 + var newReview = new FlashcardReview + { + Id = Guid.NewGuid(), + FlashcardId = flashcardId, + UserId = userId, + SuccessCount = 0, + NextReviewDate = DateTime.UtcNow.AddDays(1), // 新卡片明天複習 + TotalSkipCount = 0, + TotalWrongCount = 0, + TotalCorrectCount = 0, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + await _context.FlashcardReviews.AddAsync(newReview); + return newReview; + } + + public async Task GetByUserAndFlashcardAsync(Guid userId, Guid flashcardId) + { + return await _context.FlashcardReviews + .FirstOrDefaultAsync(fr => fr.UserId == userId && fr.FlashcardId == flashcardId); + } + + public async Task<(int TodayDue, int Overdue, int TotalReviews)> GetReviewStatsAsync(Guid userId) + { + var now = DateTime.UtcNow; + var today = now.Date; + var tomorrow = today.AddDays(1); + + var userReviews = _context.FlashcardReviews.Where(fr => fr.UserId == userId); + + var todayDue = await userReviews + .CountAsync(fr => fr.NextReviewDate >= today && fr.NextReviewDate < tomorrow); + + var overdue = await userReviews + .CountAsync(fr => fr.NextReviewDate < today); + + var totalReviews = await userReviews + .SumAsync(fr => fr.TotalCorrectCount + fr.TotalWrongCount + fr.TotalSkipCount); + + return (todayDue, overdue, totalReviews); + } + + public async Task GetTodayDueCountAsync(Guid userId) + { + var today = DateTime.UtcNow.Date; + var tomorrow = today.AddDays(1); + + return await _context.FlashcardReviews + .Where(fr => fr.UserId == userId) + .CountAsync(fr => fr.NextReviewDate >= today && fr.NextReviewDate < tomorrow); + } + + public async Task GetOverdueCountAsync(Guid userId) + { + var today = DateTime.UtcNow.Date; + + return await _context.FlashcardReviews + .Where(fr => fr.UserId == userId) + .CountAsync(fr => fr.NextReviewDate < today); + } + + public async Task UpdateReviewAsync(FlashcardReview review) + { + review.UpdatedAt = DateTime.UtcNow; + _context.FlashcardReviews.Update(review); + await _context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api/Repositories/IFlashcardReviewRepository.cs b/backend/DramaLing.Api/Repositories/IFlashcardReviewRepository.cs new file mode 100644 index 0000000..fe91cbd --- /dev/null +++ b/backend/DramaLing.Api/Repositories/IFlashcardReviewRepository.cs @@ -0,0 +1,44 @@ +using DramaLing.Api.Models.Entities; +using DramaLing.Api.Models.DTOs; + +namespace DramaLing.Api.Repositories; + +public interface IFlashcardReviewRepository : IRepository +{ + /// + /// 獲取待複習的詞卡(包含複習記錄) + /// + Task> GetDueFlashcardsAsync( + Guid userId, + DueFlashcardsQuery query); + + /// + /// 獲取或創建詞卡的複習記錄 + /// + Task GetOrCreateReviewAsync(Guid userId, Guid flashcardId); + + /// + /// 根據用戶ID和詞卡ID獲取複習記錄 + /// + Task GetByUserAndFlashcardAsync(Guid userId, Guid flashcardId); + + /// + /// 獲取用戶的複習統計 + /// + Task<(int TodayDue, int Overdue, int TotalReviews)> GetReviewStatsAsync(Guid userId); + + /// + /// 獲取今天到期的詞卡數量 + /// + Task GetTodayDueCountAsync(Guid userId); + + /// + /// 獲取過期的詞卡數量 + /// + Task GetOverdueCountAsync(Guid userId); + + /// + /// 更新複習記錄 + /// + Task UpdateReviewAsync(FlashcardReview review); +} \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/Review/IReviewService.cs b/backend/DramaLing.Api/Services/Review/IReviewService.cs new file mode 100644 index 0000000..57d06b2 --- /dev/null +++ b/backend/DramaLing.Api/Services/Review/IReviewService.cs @@ -0,0 +1,22 @@ +using DramaLing.Api.Models.DTOs; +using DramaLing.Api.Controllers; + +namespace DramaLing.Api.Services.Review; + +public interface IReviewService +{ + /// + /// 獲取待複習的詞卡列表 + /// + Task> GetDueFlashcardsAsync(Guid userId, DueFlashcardsQuery query); + + /// + /// 提交複習結果 + /// + Task> SubmitReviewAsync(Guid userId, Guid flashcardId, ReviewRequest request); + + /// + /// 獲取複習統計 + /// + Task> GetReviewStatsAsync(Guid userId, string period = "today"); +} \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/Review/ReviewService.cs b/backend/DramaLing.Api/Services/Review/ReviewService.cs new file mode 100644 index 0000000..6c37143 --- /dev/null +++ b/backend/DramaLing.Api/Services/Review/ReviewService.cs @@ -0,0 +1,217 @@ +using DramaLing.Api.Models.DTOs; +using DramaLing.Api.Models.Entities; +using DramaLing.Api.Repositories; +using DramaLing.Api.Controllers; +using DramaLing.Api.Utils; + +namespace DramaLing.Api.Services.Review; + +public class ReviewService : IReviewService +{ + private readonly IFlashcardReviewRepository _reviewRepository; + private readonly ILogger _logger; + + public ReviewService( + IFlashcardReviewRepository reviewRepository, + ILogger logger) + { + _reviewRepository = reviewRepository; + _logger = logger; + } + + public async Task> GetDueFlashcardsAsync(Guid userId, DueFlashcardsQuery query) + { + try + { + var dueFlashcards = await _reviewRepository.GetDueFlashcardsAsync(userId, query); + var (todayDue, overdue, totalReviews) = await _reviewRepository.GetReviewStatsAsync(userId); + + // 轉換為符合前端期望的格式 + var flashcardData = dueFlashcards.Select(item => new + { + // 基本詞卡信息 (匹配 api_seeds.json 格式) + id = item.Flashcard.Id.ToString(), + word = item.Flashcard.Word, + translation = item.Flashcard.Translation, + definition = item.Flashcard.Definition ?? "", + partOfSpeech = item.Flashcard.PartOfSpeech ?? "", + pronunciation = item.Flashcard.Pronunciation ?? "", + example = item.Flashcard.Example ?? "", + exampleTranslation = item.Flashcard.ExampleTranslation ?? "", + isFavorite = item.Flashcard.IsFavorite, + difficultyLevelNumeric = item.Flashcard.DifficultyLevelNumeric, + cefr = CEFRHelper.ToString(item.Flashcard.DifficultyLevelNumeric), + createdAt = item.Flashcard.CreatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"), + updatedAt = item.Flashcard.UpdatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"), + + // 圖片相關 (暫時設為預設值,因為需要額外查詢) + hasExampleImage = false, + primaryImageUrl = (string?)null, + + // 同義詞(暫時空陣列,未來可擴展) + synonyms = new string[] { }, + + // 複習相關信息 (新增) + reviewInfo = item.Review != null ? new + { + successCount = item.Review.SuccessCount, + nextReviewDate = item.Review.NextReviewDate.ToString("yyyy-MM-ddTHH:mm:ssZ"), + lastReviewDate = item.Review.LastReviewDate?.ToString("yyyy-MM-ddTHH:mm:ssZ"), + totalCorrectCount = item.Review.TotalCorrectCount, + totalWrongCount = item.Review.TotalWrongCount, + totalSkipCount = item.Review.TotalSkipCount, + isOverdue = item.Review.NextReviewDate < DateTime.UtcNow.Date, + daysSinceLastReview = item.Review.LastReviewDate.HasValue + ? (int)(DateTime.UtcNow - item.Review.LastReviewDate.Value).TotalDays + : 0 + } : new + { + successCount = 0, + nextReviewDate = DateTime.UtcNow.AddDays(1).ToString("yyyy-MM-ddTHH:mm:ssZ"), + lastReviewDate = (string?)null, + totalCorrectCount = 0, + totalWrongCount = 0, + totalSkipCount = 0, + isOverdue = false, + daysSinceLastReview = 0 + } + }).ToList(); + + var response = new + { + flashcards = flashcardData, + count = flashcardData.Count, + metadata = new + { + todayDue = todayDue, + overdue = overdue, + totalReviews = totalReviews, + studyStreak = 0 // 暫時設為0,未來可實作 + } + }; + + return new ApiResponse + { + Success = true, + Data = response, + Message = null, + Timestamp = DateTime.UtcNow + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting due flashcards for user {UserId}", userId); + throw; + } + } + + public async Task> SubmitReviewAsync(Guid userId, Guid flashcardId, ReviewRequest request) + { + try + { + // 獲取或創建複習記錄 + var review = await _reviewRepository.GetOrCreateReviewAsync(userId, flashcardId); + + // 處理複習結果 + var result = ProcessReview(review, request); + + // 更新記錄 + await _reviewRepository.UpdateReviewAsync(review); + + return new ApiResponse + { + Success = true, + Data = result, + Timestamp = DateTime.UtcNow + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error submitting review for flashcard {FlashcardId}", flashcardId); + throw; + } + } + + public async Task> GetReviewStatsAsync(Guid userId, string period = "today") + { + try + { + var (todayDue, overdue, totalReviews) = await _reviewRepository.GetReviewStatsAsync(userId); + + var stats = new ReviewStats + { + TodayReviewed = 0, // TODO: 實作當日複習統計 + TodayDue = todayDue, + Overdue = overdue, + TotalReviews = totalReviews, + AverageAccuracy = 0.0, // TODO: 實作正確率統計 + StudyStreak = 0 // TODO: 實作連續學習天數 + }; + + return new ApiResponse + { + Success = true, + Data = stats, + Timestamp = DateTime.UtcNow + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting review stats for user {UserId}", userId); + throw; + } + } + + /// + /// 處理複習結果的核心算法 + /// + private ReviewResult ProcessReview(FlashcardReview review, ReviewRequest request) + { + if (request.WasSkipped) + { + // 跳過: 不改變成功次數,明天再複習 + review.TotalSkipCount++; + review.NextReviewDate = DateTime.UtcNow.AddDays(1); + } + else + { + // 根據信心度判斷是否答對 (0=不熟悉答錯, 1-2=答對) + var isCorrect = request.Confidence >= 1; + + if (isCorrect) + { + // 答對: 增加成功次數,計算新間隔 + review.SuccessCount++; + review.TotalCorrectCount++; + review.LastSuccessDate = DateTime.UtcNow; + + // 核心公式: 間隔 = 2^成功次數 天 + var intervalDays = (int)Math.Pow(2, review.SuccessCount); + var maxInterval = 180; // 最大半年 + var finalInterval = Math.Min(intervalDays, maxInterval); + + review.NextReviewDate = DateTime.UtcNow.AddDays(finalInterval); + } + else + { + // 答錯: 重置成功次數,明天再複習 + review.SuccessCount = 0; + review.TotalWrongCount++; + review.NextReviewDate = DateTime.UtcNow.AddDays(1); + } + } + + review.LastReviewDate = DateTime.UtcNow; + review.UpdatedAt = DateTime.UtcNow; + + return new ReviewResult + { + FlashcardId = review.FlashcardId, + NewSuccessCount = review.SuccessCount, + NextReviewDate = review.NextReviewDate, + IntervalDays = (int)(review.NextReviewDate - DateTime.UtcNow).TotalDays, + MasteryLevelChange = 0.0, // 暫時設為0 + IsNewRecord = review.CreatedAt == review.UpdatedAt + }; + } +} \ No newline at end of file diff --git a/frontend/lib/data/api_seeds.json b/frontend/lib/data/api_seeds.json index cd95ffe..201741b 100644 --- a/frontend/lib/data/api_seeds.json +++ b/frontend/lib/data/api_seeds.json @@ -18,8 +18,8 @@ "updatedAt": "2025-10-01T13:37:22.91802", "hasExampleImage": false, "primaryImageUrl": null, - "synonyms":["proof", "testimony", "documentation"] - }, + "synonyms":["proof", "testimony", "documentation"], + "quizOptions": ["excuse", "opinion", "prediction"] }, { "id": "5b854991-c64b-464f-b69b-f8946a165257", "word": "warrants", @@ -36,7 +36,8 @@ "updatedAt": "2025-10-01T12:48:10.161318", "hasExampleImage": false, "primaryImageUrl": null, - "synonyms":["proof", "testimony", "documentation"] + "synonyms":["proof", "testimony", "documentation"], + "quizOptions": ["laws", "weapons", "licenses"] }, { "id": "d6f4227f-bdc9-4f13-a532-aa47f802cf8d", @@ -54,7 +55,8 @@ "updatedAt": "2025-10-01T12:48:07.640111", "hasExampleImage": true, "primaryImageUrl": "/images/examples/d6f4227f-bdc9-4f13-a532-aa47f802cf8d_078eabb9-3630-4461-b9ea-98a677625d22.png", - "synonyms": ["acquired", "gained", "secured"] + "synonyms": ["acquired", "gained", "secured"], + "quizOptions": ["refused", "forgot", "broke"] }, { "id": "26e2e99c-124f-4bfe-859e-8819c68e72b8", @@ -72,7 +74,7 @@ "updatedAt": "2025-10-01T15:49:08.525139", "hasExampleImage": true, "primaryImageUrl": "/images/examples/26e2e99c-124f-4bfe-859e-8819c68e72b8_a7923c26-fefd-4705-9921-dc81f44e47c0.png", - "synonyms": ["rank", "organize", "arrange"] + "quizOptions": ["delay", "ignore", "mix up"] } ], "count": 4