Files
step3/api/QuestionsApi/Services/Flashcards/FlashcardNoteStore.cs
T
hz 7f76ca0fdc Add system (curated) vs. user-uploaded flashcards
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.
2026-07-22 15:52:19 -05:00

240 lines
12 KiB
C#

using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed record FlashcardNoteUpdateResult(FlashcardNote? Note, FlashcardNote? Current)
{
public bool Succeeded => Note is not null;
}
public sealed class FlashcardNoteStore
{
private readonly IMongoCollection<FlashcardNote> _notes;
private readonly FlashcardScheduleStore _schedules;
public FlashcardNoteStore(MongoDatabaseProvider mongo, FlashcardScheduleStore schedules)
{
_notes = mongo.Database.GetCollection<FlashcardNote>("FlashcardNotes");
_schedules = schedules;
_notes.Indexes.CreateMany([
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.StableId),
new CreateIndexOptions { Unique = true, Name = "flashcard_note_unique_user_stable" }),
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.DeckId),
new CreateIndexOptions { Name = "flashcard_note_user_deck" }),
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.SourceQuestionExternalId),
new CreateIndexOptions { Name = "flashcard_note_user_source_question" }),
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.ContentFingerprint),
new CreateIndexOptions { Name = "flashcard_note_user_fingerprint" })
]);
}
public static string Fingerprint(string type, string prompt, string answer) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{type}{prompt.Trim()}{answer.Trim()}")));
public async Task<FlashcardNote?> GetAsync(string userId, string noteId) =>
await _notes.Find(v => v.UserId == userId && v.Id == noteId).FirstOrDefaultAsync();
public async Task<FlashcardNote?> GetByStableIdAsync(string userId, string stableId) =>
await _notes.Find(v => v.UserId == userId && v.StableId == stableId).FirstOrDefaultAsync();
public Task<List<FlashcardNote>> ListByDeckAsync(string userId, string deckId) =>
_notes.Find(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null).ToListAsync();
public Task<List<FlashcardNote>> ListAllActiveAsync(string userId) =>
_notes.Find(v => v.UserId == userId && v.ArchivedUtc == null).ToListAsync();
// Read-only: the caller's own notes plus every system (shared, admin-curated) note. Never
// used to back a write path - mutations must stay strictly own-user via the methods above.
public Task<List<FlashcardNote>> ListByDeckVisibleAsync(string userId, string deckId) =>
_notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System)
&& v.DeckId == deckId && v.ArchivedUtc == null).ToListAsync();
public Task<List<FlashcardNote>> ListAllVisibleActiveAsync(string userId) =>
_notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.ArchivedUtc == null)
.ToListAsync();
public async Task<FlashcardNote?> GetVisibleAsync(string userId, string noteId) =>
await _notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.Id == noteId)
.FirstOrDefaultAsync();
// No owner filter at all - used only by the controller's mutation guard to check IsSystem
// regardless of who's asking.
public async Task<FlashcardNote?> GetAnyAsync(string noteId) =>
await _notes.Find(v => v.Id == noteId).FirstOrDefaultAsync();
public async Task<(List<FlashcardNote> Items, int Total)> SearchVisibleAsync(string userId, FlashcardLibraryQuery query)
{
var filter = Builders<FlashcardNote>.Filter.Or(
Builders<FlashcardNote>.Filter.Eq(v => v.UserId, userId),
Builders<FlashcardNote>.Filter.Eq(v => v.UserId, FlashcardOwners.System));
if (query.State != FlashcardArchiveFilter.IncludeArchived)
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.ArchivedUtc, null);
if (!string.IsNullOrWhiteSpace(query.DeckId))
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.DeckId, query.DeckId);
if (!string.IsNullOrWhiteSpace(query.Type))
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.Type, query.Type);
if (!string.IsNullOrWhiteSpace(query.Tag))
filter &= Builders<FlashcardNote>.Filter.AnyEq(v => v.Tags, query.Tag);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var pattern = new MongoDB.Bson.BsonRegularExpression(Regex.Escape(query.Search), "i");
filter &= Builders<FlashcardNote>.Filter.Or(
Builders<FlashcardNote>.Filter.Regex(v => v.Prompt, pattern),
Builders<FlashcardNote>.Filter.Regex(v => v.Answer, pattern),
Builders<FlashcardNote>.Filter.Regex(v => v.Extra, pattern));
}
var total = checked((int)await _notes.CountDocumentsAsync(filter));
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 200);
var items = await _notes.Find(filter)
.SortByDescending(v => v.UpdatedUtc)
.Skip((page - 1) * pageSize)
.Limit(pageSize)
.ToListAsync();
return (items, total);
}
public async Task<(List<FlashcardNote> Items, int Total)> SearchAsync(string userId, FlashcardLibraryQuery query)
{
var filter = Builders<FlashcardNote>.Filter.Eq(v => v.UserId, userId);
if (query.State != FlashcardArchiveFilter.IncludeArchived)
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.ArchivedUtc, null);
if (!string.IsNullOrWhiteSpace(query.DeckId))
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.DeckId, query.DeckId);
if (!string.IsNullOrWhiteSpace(query.Type))
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.Type, query.Type);
if (!string.IsNullOrWhiteSpace(query.Tag))
filter &= Builders<FlashcardNote>.Filter.AnyEq(v => v.Tags, query.Tag);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var pattern = new MongoDB.Bson.BsonRegularExpression(Regex.Escape(query.Search), "i");
filter &= Builders<FlashcardNote>.Filter.Or(
Builders<FlashcardNote>.Filter.Regex(v => v.Prompt, pattern),
Builders<FlashcardNote>.Filter.Regex(v => v.Answer, pattern),
Builders<FlashcardNote>.Filter.Regex(v => v.Extra, pattern));
}
var total = checked((int)await _notes.CountDocumentsAsync(filter));
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 200);
var items = await _notes.Find(filter)
.SortByDescending(v => v.UpdatedUtc)
.Skip((page - 1) * pageSize)
.Limit(pageSize)
.ToListAsync();
return (items, total);
}
public async Task<FlashcardNoteUpdateResult> UpsertAsync(string userId, UpsertFlashcardNoteRequest request, string? noteId)
{
var type = FlashcardTypes.All.Contains(request.Type) ? request.Type : FlashcardTypes.Basic;
if (type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(request.Prompt))
throw new FlashcardValidationException("cloze note must contain at least one {{cN::answer}} marker");
var now = DateTime.UtcNow;
var fingerprint = Fingerprint(type, request.Prompt, request.Answer);
FlashcardNote? saved;
if (string.IsNullOrWhiteSpace(noteId))
{
var created = new FlashcardNote
{
UserId = userId,
StableId = string.IsNullOrWhiteSpace(request.StableId) ? Guid.NewGuid().ToString() : request.StableId,
DeckId = request.DeckId,
Type = type,
Prompt = request.Prompt,
Answer = request.Answer,
Extra = request.Extra,
Tags = NormalizeTags(request.Tags),
Images = request.Images,
SourceQuestionExternalId = request.SourceQuestionExternalId,
ContentFingerprint = fingerprint,
CreatedUtc = now,
UpdatedUtc = now
};
try
{
await _notes.InsertOneAsync(created);
saved = created;
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return new FlashcardNoteUpdateResult(null, await GetByStableIdAsync(userId, created.StableId));
}
}
else
{
var expectedRevision = request.ExpectedRevision ?? 0;
var updated = await _notes.FindOneAndUpdateAsync(
v => v.UserId == userId && v.Id == noteId && v.Revision == expectedRevision,
Builders<FlashcardNote>.Update
.Set(v => v.DeckId, request.DeckId)
.Set(v => v.Type, type)
.Set(v => v.Prompt, request.Prompt)
.Set(v => v.Answer, request.Answer)
.Set(v => v.Extra, request.Extra)
.Set(v => v.Tags, NormalizeTags(request.Tags))
.Set(v => v.Images, request.Images)
.Set(v => v.SourceQuestionExternalId, request.SourceQuestionExternalId)
.Set(v => v.ContentFingerprint, fingerprint)
.Set(v => v.UpdatedUtc, now)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardNote> { ReturnDocument = ReturnDocument.After });
if (updated is null)
return new FlashcardNoteUpdateResult(null, await GetAsync(userId, noteId));
saved = updated;
}
await _schedules.SyncVariantsAsync(saved);
return new FlashcardNoteUpdateResult(saved, null);
}
public async Task<FlashcardNoteUpdateResult> ArchiveAsync(string userId, string noteId, bool archived)
{
var updated = await _notes.FindOneAndUpdateAsync(
v => v.UserId == userId && v.Id == noteId,
Builders<FlashcardNote>.Update
.Set(v => v.ArchivedUtc, archived ? DateTime.UtcNow : null)
.Set(v => v.UpdatedUtc, DateTime.UtcNow)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardNote> { ReturnDocument = ReturnDocument.After });
return updated is not null
? new FlashcardNoteUpdateResult(updated, null)
: new FlashcardNoteUpdateResult(null, await GetAsync(userId, noteId));
}
public async Task<long> MoveAsync(string userId, IReadOnlyCollection<string> noteIds, string targetDeckId)
{
var result = await _notes.UpdateManyAsync(
v => v.UserId == userId && noteIds.Contains(v.Id!),
Builders<FlashcardNote>.Update.Set(v => v.DeckId, targetDeckId).Set(v => v.UpdatedUtc, DateTime.UtcNow).Inc(v => v.Revision, 1));
return result.ModifiedCount;
}
public Task<long> CountInDeckAsync(string userId, string deckId) =>
_notes.CountDocumentsAsync(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null);
private static List<string> NormalizeTags(List<string> tags) => tags
.Select(v => v.Trim())
.Where(v => v.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
public sealed class FlashcardValidationException(string message) : Exception(message);
public static class FlashcardArchiveFilter
{
public const string IncludeArchived = "archived";
}