using MongoDB.Driver; using QuestionsApi.Models; namespace QuestionsApi.Services.Flashcards; public enum FlashcardReviewOutcomeKind { Success, Duplicate, Conflict, NotFound } public sealed class FlashcardReviewOutcome { public FlashcardReviewOutcomeKind Kind { get; init; } public FlashcardSchedule? Schedule { get; init; } public static FlashcardReviewOutcome NotFound() => new() { Kind = FlashcardReviewOutcomeKind.NotFound }; public static FlashcardReviewOutcome Conflict(FlashcardSchedule? current) => new() { Kind = FlashcardReviewOutcomeKind.Conflict, Schedule = current }; public static FlashcardReviewOutcome Duplicate(FlashcardSchedule? current) => new() { Kind = FlashcardReviewOutcomeKind.Duplicate, Schedule = current }; public static FlashcardReviewOutcome Success(FlashcardSchedule schedule) => new() { Kind = FlashcardReviewOutcomeKind.Success, Schedule = schedule }; } public sealed class FlashcardScheduleStore { private readonly IMongoCollection _schedules; private readonly IMongoCollection _reviewLogs; private readonly IFlashcardScheduler _scheduler; public FlashcardScheduleStore(MongoDatabaseProvider mongo, IFlashcardScheduler scheduler) { _schedules = mongo.Database.GetCollection("FlashcardSchedules"); _reviewLogs = mongo.Database.GetCollection("FlashcardReviewLogs"); _scheduler = scheduler; _schedules.Indexes.CreateMany([ new CreateIndexModel( Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.NoteId).Ascending(v => v.Variant), new CreateIndexOptions { Unique = true, Name = "flashcard_schedule_unique_user_note_variant" }), new CreateIndexModel( Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.State).Ascending(v => v.DueUtc), new CreateIndexOptions { Name = "flashcard_schedule_user_state_due" }) ]); _reviewLogs.Indexes.CreateMany([ new CreateIndexModel( Builders.IndexKeys.Ascending(v => v.UserId).Descending(v => v.ReviewedUtc), new CreateIndexOptions { Name = "flashcard_review_log_user_reviewed" }), new CreateIndexModel( Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.CardId), new CreateIndexOptions { Name = "flashcard_review_log_user_card" }) ]); } // Basic notes always have exactly one card (variant 0). Cloze notes get one independently // scheduled card per distinct {{cN::...}} number found in the prompt. Existing variants are // never removed on re-sync (even if a cloze number disappears from edited text) so a note // edit never discards review history. public async Task SyncVariantsAsync(FlashcardNote note) { var desiredVariants = note.Type == FlashcardTypes.Cloze ? FlashcardClozeParser.DistinctClozeNumbers(note.Prompt) : [0]; if (desiredVariants.Count == 0) return; var existing = await GetByNoteIdAsync(note.UserId, note.Id!); var existingVariants = existing.Select(v => v.Variant).ToHashSet(); var now = DateTime.UtcNow; foreach (var variant in desiredVariants.Where(v => !existingVariants.Contains(v))) { var schedule = new FlashcardSchedule { UserId = note.UserId, NoteId = note.Id!, Variant = variant, State = FlashcardScheduleStates.New, DueUtc = now, CreatedUtc = now }; try { await _schedules.InsertOneAsync(schedule); } catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { // Concurrent save already created this variant. } } } public async Task GetAsync(string userId, string cardId) => await _schedules.Find(v => v.UserId == userId && v.Id == cardId).FirstOrDefaultAsync(); public Task> GetByNoteIdAsync(string userId, string noteId) => _schedules.Find(v => v.UserId == userId && v.NoteId == noteId).ToListAsync(); public async Task> GetDueAsync( string userId, IReadOnlyCollection noteIds, DateTime nowUtc, DateTime studyDayStartUtc, int limit) { if (noteIds.Count == 0 || limit <= 0) return []; return await _schedules.Find(v => v.UserId == userId && noteIds.Contains(v.NoteId) && v.State != FlashcardScheduleStates.New && !v.IsSuspended && (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc) && v.DueUtc <= nowUtc) .SortBy(v => v.DueUtc) .Limit(limit) .ToListAsync(); } public async Task> GetNewAsync( string userId, IReadOnlyCollection noteIds, DateTime studyDayStartUtc, int limit) { if (noteIds.Count == 0 || limit <= 0) return []; return await _schedules.Find(v => v.UserId == userId && noteIds.Contains(v.NoteId) && v.State == FlashcardScheduleStates.New && !v.IsSuspended && (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc)) .SortBy(v => v.CreatedUtc) .Limit(limit) .ToListAsync(); } public Task CountDueAsync(string userId, IReadOnlyCollection noteIds, DateTime nowUtc, DateTime studyDayStartUtc) => noteIds.Count == 0 ? Task.FromResult(0L) : _schedules.CountDocumentsAsync(v => v.UserId == userId && noteIds.Contains(v.NoteId) && v.State != FlashcardScheduleStates.New && !v.IsSuspended && (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc) && v.DueUtc <= nowUtc); public Task CountNewAsync(string userId, IReadOnlyCollection noteIds, DateTime studyDayStartUtc) => noteIds.Count == 0 ? Task.FromResult(0L) : _schedules.CountDocumentsAsync(v => v.UserId == userId && noteIds.Contains(v.NoteId) && v.State == FlashcardScheduleStates.New && !v.IsSuspended && (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc)); public Task CountReviewsSinceAsync(string userId, DateTime sinceUtc) => _reviewLogs.CountDocumentsAsync(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null); public Task CountByStateAsync(string userId, IReadOnlyCollection noteIds, string state) => noteIds.Count == 0 ? Task.FromResult(0L) : _schedules.CountDocumentsAsync(v => v.UserId == userId && noteIds.Contains(v.NoteId) && v.State == state && !v.IsSuspended); // A card counts toward "mastered" once it has graduated to long-term review and reached a // stability (FSRS's estimate of days until retention drops to ~90%) past this threshold - // the same rough "won't need attention again soon" cutoff several SRS apps use for a mastery // stat. Kept separate from the plain Review-state count, which includes recently-graduated // cards still building stability. public const double MasteryStabilityDaysThreshold = 21.0; public Task CountMasteredAsync(string userId, IReadOnlyCollection noteIds) => noteIds.Count == 0 ? Task.FromResult(0L) : _schedules.CountDocumentsAsync(v => v.UserId == userId && noteIds.Contains(v.NoteId) && v.State == FlashcardScheduleStates.Review && !v.IsSuspended && v.Stability >= MasteryStabilityDaysThreshold); // Bounded lookback (streaks longer than this are vanishingly rare and this keeps the query // cheap); returns raw timestamps so the caller can compute consecutive-day streaks in the // user's own calendar-day semantics. public Task> GetReviewTimestampsSinceAsync(string userId, DateTime sinceUtc) => _reviewLogs.Find(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null) .Project(v => v.ReviewedUtc) .ToListAsync(); public async Task CountNewIntroducedSinceAsync(string userId, DateTime sinceUtc) => checked((int)await _reviewLogs.CountDocumentsAsync(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null && v.Before.State == FlashcardScheduleStates.New)); public Task> GetReviewsSinceAsync(string userId, DateTime sinceUtc) => _reviewLogs.Find(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc).SortBy(v => v.ReviewedUtc).ToListAsync(); public Task> GetReviewLogsForCardAsync(string userId, string cardId) => _reviewLogs.Find(v => v.UserId == userId && v.CardId == cardId).SortBy(v => v.ReviewedUtc).ToListAsync(); // Used only by JSON import: restores a previously-exported card's FSRS state verbatim // (not a normal review) so importing preserves history instead of re-deriving it. public Task ImportScheduleStateAsync(string userId, string noteId, FlashcardSchedule imported) => _schedules.UpdateOneAsync( v => v.UserId == userId && v.NoteId == noteId && v.Variant == imported.Variant, Builders.Update .Set(v => v.State, imported.State) .Set(v => v.Step, imported.Step) .Set(v => v.Stability, imported.Stability) .Set(v => v.Difficulty, imported.Difficulty) .Set(v => v.DueUtc, imported.DueUtc) .Set(v => v.LastReviewUtc, imported.LastReviewUtc) .Set(v => v.IsSuspended, imported.IsSuspended) .Set(v => v.BuriedUntilUtc, imported.BuriedUntilUtc) .Inc(v => v.Revision, 1)); public async Task ImportReviewLogAsync(string userId, string cardId, FlashcardReviewLog imported) { var log = new FlashcardReviewLog { Id = string.IsNullOrWhiteSpace(imported.Id) ? Guid.NewGuid().ToString() : imported.Id, UserId = userId, CardId = cardId, Rating = imported.Rating, ReviewedUtc = imported.ReviewedUtc, DurationSeconds = imported.DurationSeconds, Before = imported.Before, After = imported.After, CreatedUtc = imported.CreatedUtc, UndoneUtc = imported.UndoneUtc }; try { await _reviewLogs.InsertOneAsync(log); } catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { // Already imported (re-running the same import commit). } } public List PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc) => _scheduler.PreviewRatingOptions(schedule, nowUtc); public async Task SubmitReviewAsync(string userId, FlashcardReviewRequest request) { var schedule = await GetAsync(userId, request.CardId); if (schedule is null) return FlashcardReviewOutcome.NotFound(); if (schedule.Revision != request.ExpectedRevision) { var priorLog = await _reviewLogs.Find(v => v.Id == request.ClientReviewId && v.UserId == userId).FirstOrDefaultAsync(); return priorLog is not null && priorLog.CardId == request.CardId ? FlashcardReviewOutcome.Duplicate(schedule) : FlashcardReviewOutcome.Conflict(schedule); } var before = Snapshot(schedule); var result = _scheduler.Review(schedule, request.Rating, request.ReviewedUtc, request.DurationSeconds); var updated = await _schedules.FindOneAndUpdateAsync( v => v.UserId == userId && v.Id == request.CardId && v.Revision == request.ExpectedRevision, Builders.Update .Set(v => v.State, result.State) .Set(v => v.Step, result.Step) .Set(v => v.Stability, result.Stability) .Set(v => v.Difficulty, result.Difficulty) .Set(v => v.DueUtc, result.DueUtc) .Set(v => v.LastReviewUtc, result.LastReviewUtc) .Set(v => v.AlgorithmVersion, FsrsFlashcardScheduler.AlgorithmVersion) .Inc(v => v.Revision, 1), new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); if (updated is null) { var raceLog = await _reviewLogs.Find(v => v.Id == request.ClientReviewId && v.UserId == userId).FirstOrDefaultAsync(); var current = await GetAsync(userId, request.CardId); return raceLog is not null && raceLog.CardId == request.CardId ? FlashcardReviewOutcome.Duplicate(current) : FlashcardReviewOutcome.Conflict(current); } var log = new FlashcardReviewLog { Id = request.ClientReviewId, UserId = userId, CardId = request.CardId, Rating = request.Rating, ReviewedUtc = request.ReviewedUtc, DurationSeconds = Math.Clamp(request.DurationSeconds, 0, 86_400), Before = before, After = Snapshot(updated), CreatedUtc = DateTime.UtcNow }; try { await _reviewLogs.InsertOneAsync(log); } catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { // A retried duplicate request already recorded this review; the schedule update // above only applies once because it matched the pre-review revision exactly. } // Bury cloze siblings (basic notes have exactly one variant, so this is a no-op for them). var buriedUntil = FlashcardScheduling.NextStudyDayStart(request.StudyDayStartUtc); await _schedules.UpdateManyAsync( v => v.UserId == userId && v.NoteId == updated.NoteId && v.Id != updated.Id, Builders.Update.Set(v => v.BuriedUntilUtc, buriedUntil)); return FlashcardReviewOutcome.Success(updated); } public async Task UndoAsync(string userId, string reviewLogId) { var log = await _reviewLogs.Find(v => v.Id == reviewLogId && v.UserId == userId && v.UndoneUtc == null).FirstOrDefaultAsync(); if (log is null) return FlashcardReviewOutcome.NotFound(); var current = await GetAsync(userId, log.CardId); if (current is null) return FlashcardReviewOutcome.NotFound(); if (current.Revision != log.After.Revision) return FlashcardReviewOutcome.Conflict(current); var restored = await _schedules.FindOneAndUpdateAsync( v => v.UserId == userId && v.Id == log.CardId && v.Revision == log.After.Revision, Builders.Update .Set(v => v.State, log.Before.State) .Set(v => v.Step, log.Before.Step) .Set(v => v.Stability, log.Before.Stability) .Set(v => v.Difficulty, log.Before.Difficulty) .Set(v => v.DueUtc, log.Before.DueUtc) .Set(v => v.LastReviewUtc, log.Before.LastReviewUtc) .Inc(v => v.Revision, 1), new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); if (restored is null) return FlashcardReviewOutcome.Conflict(await GetAsync(userId, log.CardId)); await _reviewLogs.UpdateOneAsync( v => v.Id == reviewLogId, Builders.Update.Set(v => v.UndoneUtc, DateTime.UtcNow)); return FlashcardReviewOutcome.Success(restored); } public async Task SetSuspendedAsync(string userId, IReadOnlyCollection cardIds, bool suspended) { if (cardIds.Count == 0) return 0; var result = await _schedules.UpdateManyAsync( v => v.UserId == userId && cardIds.Contains(v.Id!), Builders.Update.Set(v => v.IsSuspended, suspended).Inc(v => v.Revision, 1)); return result.ModifiedCount; } public async Task BuryAsync(string userId, IReadOnlyCollection cardIds, DateTime untilUtc) { if (cardIds.Count == 0) return 0; var result = await _schedules.UpdateManyAsync( v => v.UserId == userId && cardIds.Contains(v.Id!), Builders.Update.Set(v => v.BuriedUntilUtc, untilUtc).Inc(v => v.Revision, 1)); return result.ModifiedCount; } private static FlashcardScheduleSnapshot Snapshot(FlashcardSchedule schedule) => new() { State = schedule.State, Step = schedule.Step, Stability = schedule.Stability, Difficulty = schedule.Difficulty, DueUtc = schedule.DueUtc, LastReviewUtc = schedule.LastReviewUtc, Revision = schedule.Revision }; }