System decks/notes are owned by a reserved sentinel user id, reusing all existing per-user store/index plumbing unchanged. Regular users see them under a separate "Official Decks" section, can study them with fully independent per-user FSRS progress (lazily materialized on first visit), but can never edit or archive them - enforced both in the client (no Edit/Archive controls) and server-side (ownership guard on every mutating endpoint). Includes a small idempotent seed command (`dotnet run -- seed-flashcards`) for testing, since there's no in-app authoring UI yet.
393 lines
19 KiB
C#
393 lines
19 KiB
C#
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<FlashcardSchedule> _schedules;
|
|
private readonly IMongoCollection<FlashcardReviewLog> _reviewLogs;
|
|
private readonly IFlashcardScheduler _scheduler;
|
|
|
|
public FlashcardScheduleStore(MongoDatabaseProvider mongo, IFlashcardScheduler scheduler)
|
|
{
|
|
_schedules = mongo.Database.GetCollection<FlashcardSchedule>("FlashcardSchedules");
|
|
_reviewLogs = mongo.Database.GetCollection<FlashcardReviewLog>("FlashcardReviewLogs");
|
|
_scheduler = scheduler;
|
|
|
|
_schedules.Indexes.CreateMany([
|
|
new CreateIndexModel<FlashcardSchedule>(
|
|
Builders<FlashcardSchedule>.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<FlashcardSchedule>(
|
|
Builders<FlashcardSchedule>.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<FlashcardReviewLog>(
|
|
Builders<FlashcardReviewLog>.IndexKeys.Ascending(v => v.UserId).Descending(v => v.ReviewedUtc),
|
|
new CreateIndexOptions { Name = "flashcard_review_log_user_reviewed" }),
|
|
new CreateIndexModel<FlashcardReviewLog>(
|
|
Builders<FlashcardReviewLog>.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.
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sibling of SyncVariantsAsync: materializes this note's variant schedule rows under a
|
|
// DIFFERENT user's ownership (the studying user, not the note's own owner). Used only for
|
|
// system notes, so a regular user studying shared content gets independent, per-user FSRS
|
|
// state. Deliberately duplicated rather than sharing a parameterized private helper with
|
|
// SyncVariantsAsync, which is called eagerly on every regular note save and must stay
|
|
// untouched/low-risk.
|
|
public async Task SyncVariantsForStudyingUserAsync(string studyingUserId, FlashcardNote note)
|
|
{
|
|
var desiredVariants = note.Type == FlashcardTypes.Cloze
|
|
? FlashcardClozeParser.DistinctClozeNumbers(note.Prompt)
|
|
: [0];
|
|
if (desiredVariants.Count == 0) return;
|
|
|
|
var existing = await GetByNoteIdAsync(studyingUserId, 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 = studyingUserId,
|
|
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<FlashcardSchedule?> GetAsync(string userId, string cardId) =>
|
|
await _schedules.Find(v => v.UserId == userId && v.Id == cardId).FirstOrDefaultAsync();
|
|
|
|
public Task<List<FlashcardSchedule>> GetByNoteIdAsync(string userId, string noteId) =>
|
|
_schedules.Find(v => v.UserId == userId && v.NoteId == noteId).ToListAsync();
|
|
|
|
public async Task<List<FlashcardSchedule>> GetDueAsync(
|
|
string userId, IReadOnlyCollection<string> 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<List<FlashcardSchedule>> GetNewAsync(
|
|
string userId, IReadOnlyCollection<string> 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<long> CountDueAsync(string userId, IReadOnlyCollection<string> 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<long> CountNewAsync(string userId, IReadOnlyCollection<string> 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<long> CountReviewsSinceAsync(string userId, DateTime sinceUtc) =>
|
|
_reviewLogs.CountDocumentsAsync(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null);
|
|
|
|
public Task<long> CountByStateAsync(string userId, IReadOnlyCollection<string> 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<long> CountMasteredAsync(string userId, IReadOnlyCollection<string> 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<List<DateTime>> 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<int> 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<List<FlashcardReviewLog>> GetReviewsSinceAsync(string userId, DateTime sinceUtc) =>
|
|
_reviewLogs.Find(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc).SortBy(v => v.ReviewedUtc).ToListAsync();
|
|
|
|
public Task<List<FlashcardReviewLog>> 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<FlashcardSchedule>.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<FlashcardRatingOption> PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc) =>
|
|
_scheduler.PreviewRatingOptions(schedule, nowUtc);
|
|
|
|
public async Task<FlashcardReviewOutcome> 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<FlashcardSchedule>.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<FlashcardSchedule> { 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<FlashcardSchedule>.Update.Set(v => v.BuriedUntilUtc, buriedUntil));
|
|
|
|
return FlashcardReviewOutcome.Success(updated);
|
|
}
|
|
|
|
public async Task<FlashcardReviewOutcome> 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<FlashcardSchedule>.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<FlashcardSchedule> { ReturnDocument = ReturnDocument.After });
|
|
|
|
if (restored is null) return FlashcardReviewOutcome.Conflict(await GetAsync(userId, log.CardId));
|
|
|
|
await _reviewLogs.UpdateOneAsync(
|
|
v => v.Id == reviewLogId,
|
|
Builders<FlashcardReviewLog>.Update.Set(v => v.UndoneUtc, DateTime.UtcNow));
|
|
return FlashcardReviewOutcome.Success(restored);
|
|
}
|
|
|
|
public async Task<long> SetSuspendedAsync(string userId, IReadOnlyCollection<string> cardIds, bool suspended)
|
|
{
|
|
if (cardIds.Count == 0) return 0;
|
|
var result = await _schedules.UpdateManyAsync(
|
|
v => v.UserId == userId && cardIds.Contains(v.Id!),
|
|
Builders<FlashcardSchedule>.Update.Set(v => v.IsSuspended, suspended).Inc(v => v.Revision, 1));
|
|
return result.ModifiedCount;
|
|
}
|
|
|
|
public async Task<long> BuryAsync(string userId, IReadOnlyCollection<string> cardIds, DateTime untilUtc)
|
|
{
|
|
if (cardIds.Count == 0) return 0;
|
|
var result = await _schedules.UpdateManyAsync(
|
|
v => v.UserId == userId && cardIds.Contains(v.Id!),
|
|
Builders<FlashcardSchedule>.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
|
|
};
|
|
}
|