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.
240 lines
12 KiB
C#
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";
|
|
}
|