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.
This commit is contained in:
@@ -51,6 +51,58 @@ public sealed class FlashcardNoteStore
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user