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:
hz
2026-07-22 15:52:19 -05:00
parent 3a01e9156c
commit 7f76ca0fdc
13 changed files with 444 additions and 31 deletions
@@ -47,6 +47,8 @@ public sealed class FlashcardsController(
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("name required");
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true))
return NotFound();
var result = await decks.UpsertAsync(userId, request, deckId);
if (!result.Succeeded) return Conflict(result.Current);
return Ok(result.Deck);
@@ -57,6 +59,8 @@ public sealed class FlashcardsController(
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true))
return NotFound();
var result = await decks.ArchiveAsync(userId, deckId, true);
if (!result.Succeeded) return Conflict("cannot archive the default deck, or deck not found");
return Ok(result.Deck);
@@ -67,6 +71,8 @@ public sealed class FlashcardsController(
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true))
return NotFound();
var result = await decks.ArchiveAsync(userId, deckId, false);
if (!result.Succeeded) return NotFound();
return Ok(result.Deck);
@@ -77,7 +83,7 @@ public sealed class FlashcardsController(
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var (items, total) = await notes.SearchAsync(userId, query);
var (items, total) = await notes.SearchVisibleAsync(userId, query);
return Ok(new FlashcardLibraryResponse { Items = items, Total = total });
}
@@ -86,7 +92,7 @@ public sealed class FlashcardsController(
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var note = await notes.GetAsync(userId, noteId);
var note = await notes.GetVisibleAsync(userId, noteId);
if (note is null) return NotFound();
return Ok(note);
}
@@ -99,6 +105,8 @@ public sealed class FlashcardsController(
if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest("prompt required");
if (string.IsNullOrWhiteSpace(request.DeckId))
request.DeckId = (await decks.GetOrCreateDefaultDeckAsync(userId)).Id!;
else if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(request.DeckId))?.IsSystem == true))
return BadRequest("cannot add cards to a system deck");
try
{
@@ -118,6 +126,12 @@ public sealed class FlashcardsController(
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest("prompt required");
var isSuper = User.IsInRole("SUPER");
if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true))
return NotFound();
if (!string.IsNullOrWhiteSpace(request.DeckId)
&& !FlashcardOwnershipGuard.CanMutate(isSuper, (await decks.GetAnyAsync(request.DeckId))?.IsSystem == true))
return BadRequest("cannot move cards into a system deck");
try
{
@@ -137,6 +151,8 @@ public sealed class FlashcardsController(
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.TargetDeckId)) return BadRequest("targetDeckId required");
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(request.TargetDeckId))?.IsSystem == true))
return BadRequest("cannot move cards into a system deck");
var moved = await notes.MoveAsync(userId, request.NoteIds, request.TargetDeckId);
return Ok(new { moved });
}
@@ -146,9 +162,11 @@ public sealed class FlashcardsController(
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var isSuper = User.IsInRole("SUPER");
var results = new List<FlashcardNote>();
foreach (var noteId in request.NoteIds)
{
if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true)) continue;
var result = await notes.ArchiveAsync(userId, noteId, true);
if (result.Note is not null) results.Add(result.Note);
}
@@ -160,9 +178,11 @@ public sealed class FlashcardsController(
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var isSuper = User.IsInRole("SUPER");
var results = new List<FlashcardNote>();
foreach (var noteId in request.NoteIds)
{
if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true)) continue;
var result = await notes.ArchiveAsync(userId, noteId, false);
if (result.Note is not null) results.Add(result.Note);
}
@@ -10,6 +10,14 @@ public static class FlashcardTypes
public static readonly HashSet<string> All = [Basic, Cloze];
}
// A reserved, never-authenticatable owner id. Decks/notes owned by this id are shared,
// admin-curated content visible to every user (studied via their own independent schedule
// rows) rather than a real user's private content.
public static class FlashcardOwners
{
public const string System = "system";
}
public static class FlashcardRatings
{
public const string Again = "again";
@@ -43,6 +51,7 @@ public sealed class FlashcardDeck
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
public DateTime? ArchivedUtc { get; set; }
[BsonIgnore] public bool IsSystem => UserId == FlashcardOwners.System;
}
public sealed class FlashcardImageReference
@@ -69,6 +78,7 @@ public sealed class FlashcardNote
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
public DateTime? ArchivedUtc { get; set; }
[BsonIgnore] public bool IsSystem => UserId == FlashcardOwners.System;
}
public sealed class FlashcardSchedule
+10
View File
@@ -17,6 +17,16 @@ if (args.FirstOrDefault() == "promote-content")
return;
}
if (args.FirstOrDefault() == "seed-flashcards")
{
var seedConfiguration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
Environment.ExitCode = await SeedFlashcardsCommand.RunAsync(seedConfiguration, Console.Out);
return;
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
@@ -33,6 +33,21 @@ public sealed class FlashcardDeckStore
public async Task<FlashcardDeck?> GetAsync(string userId, string deckId) =>
await _decks.Find(v => v.UserId == userId && v.Id == deckId).FirstOrDefaultAsync();
// Read-only: the caller's own decks plus every system (shared, admin-curated) deck. Never
// used to back a write path - mutations must stay strictly own-user via ListAsync/GetAsync.
public Task<List<FlashcardDeck>> ListVisibleAsync(string userId) =>
_decks.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.ArchivedUtc == null)
.SortBy(v => v.Name).ToListAsync();
public async Task<FlashcardDeck?> GetVisibleAsync(string userId, string deckId) =>
await _decks.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.Id == deckId)
.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<FlashcardDeck?> GetAnyAsync(string deckId) =>
await _decks.Find(v => v.Id == deckId).FirstOrDefaultAsync();
public async Task<FlashcardDeck> GetOrCreateDefaultDeckAsync(string userId)
{
var existing = await _decks.Find(v => v.UserId == userId && v.IsDefault).FirstOrDefaultAsync();
@@ -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);
@@ -0,0 +1,8 @@
namespace QuestionsApi.Services.Flashcards;
// Pure authorization decision for mutating a flashcard deck/note, kept dependency-free (like
// FlashcardScheduling) so it's unit testable without a controller or Mongo.
public static class FlashcardOwnershipGuard
{
public static bool CanMutate(bool isSuper, bool targetIsSystem) => isSuper || !targetIsSystem;
}
@@ -81,6 +81,44 @@ public sealed class FlashcardScheduleStore
}
}
// 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();
@@ -51,7 +51,7 @@ public sealed class FlashcardStudyService(
};
if (card is null) return response;
var note = await notes.GetAsync(userId, card.NoteId);
var note = await notes.GetVisibleAsync(userId, card.NoteId);
if (note is null) return response;
response.Note = note;
@@ -89,11 +89,13 @@ public sealed class FlashcardStudyService(
public async Task<List<FlashcardDeckSummary>> GetDeckSummariesAsync(string userId, DateTime nowUtc, DateTime studyDayStart)
{
var deckList = await decks.ListAsync(userId);
var deckList = await decks.ListVisibleAsync(userId);
var result = new List<FlashcardDeckSummary>();
foreach (var deck in deckList)
{
var deckNoteIds = (await notes.ListByDeckAsync(userId, deck.Id!)).Select(v => v.Id!).ToList();
var deckNotes = await notes.ListByDeckVisibleAsync(userId, deck.Id!);
await EnsureSystemScheduleRowsAsync(userId, deckNotes);
var deckNoteIds = deckNotes.Select(v => v.Id!).ToList();
var due = checked((int)await schedules.CountDueAsync(userId, deckNoteIds, nowUtc, studyDayStart));
var fresh = checked((int)await schedules.CountNewAsync(userId, deckNoteIds, studyDayStart));
var learning = checked((int)await schedules.CountByStateAsync(userId, deckNoteIds, FlashcardScheduleStates.Learning))
@@ -118,11 +120,21 @@ public sealed class FlashcardStudyService(
private async Task<List<string>> ResolveNoteIdsAsync(string userId, string deckId)
{
var activeNotes = string.IsNullOrWhiteSpace(deckId)
? await notes.ListAllActiveAsync(userId)
: await notes.ListByDeckAsync(userId, deckId);
? await notes.ListAllVisibleActiveAsync(userId)
: await notes.ListByDeckVisibleAsync(userId, deckId);
await EnsureSystemScheduleRowsAsync(userId, activeNotes);
return activeNotes.Select(v => v.Id!).ToList();
}
// Lazily materializes this user's own FlashcardSchedule rows for any note they don't own
// (i.e. system notes) before it's counted/served. A no-op once a user has touched a given
// system note (SyncVariantsForStudyingUserAsync is duplicate-key-tolerant and additive-only).
private async Task EnsureSystemScheduleRowsAsync(string userId, List<FlashcardNote> activeNotes)
{
foreach (var note in activeNotes.Where(v => v.IsSystem))
await schedules.SyncVariantsForStudyingUserAsync(userId, note);
}
private async Task<(int NewBudget, int ReviewBudget)> ResolveBudgetsAsync(string userId, string deckId, DateTime studyDayStart)
{
var settings = await settingsStore.GetAsync(userId);
@@ -130,7 +142,7 @@ public sealed class FlashcardStudyService(
var reviewLimit = settings.ReviewsPerDay;
if (!string.IsNullOrWhiteSpace(deckId))
{
var deck = await decks.GetAsync(userId, deckId);
var deck = await decks.GetVisibleAsync(userId, deckId);
if (deck is not null)
{
newLimit = deck.NewCardsPerDay ?? newLimit;
@@ -0,0 +1,62 @@
using QuestionsApi.Models;
using QuestionsApi.Services.Flashcards;
namespace QuestionsApi.Services;
// Idempotent, single-target seed for testing "system" (shared, admin-curated) flashcards.
// Unlike ContentPromotionCommand (cross-environment copy, source+target connections), this only
// ever writes to the one database the app is already configured against, using the exact same
// store methods a real user's create/edit path uses - just under the reserved FlashcardOwners.System
// identity - so seeded content is created/updated through the same validation and revision
// handling as any other deck/note.
public static class SeedFlashcardsCommand
{
public static async Task<int> RunAsync(IConfiguration configuration, TextWriter output)
{
var mongo = new MongoDatabaseProvider(configuration);
var scheduleStore = new FlashcardScheduleStore(mongo, new FsrsFlashcardScheduler());
var deckStore = new FlashcardDeckStore(mongo);
var noteStore = new FlashcardNoteStore(mongo, scheduleStore);
var deck = await UpsertSystemDeckAsync(deckStore, "Step 3 Essentials", "Official curated review deck");
await UpsertSystemNoteAsync(noteStore, deck.Id!, FlashcardTypes.Basic,
"A 62-year-old woman presents with sudden, painless, monocular vision loss. What is the diagnosis?",
"Central retinal artery occlusion", "\"Cherry-red spot\" on fundoscopy is the classic finding.");
await UpsertSystemNoteAsync(noteStore, deck.Id!, FlashcardTypes.Basic,
"Thyroid storm: correct order of pharmacologic treatment?",
"Beta-blocker (propranolol) first -> thionamide (PTU/methimazole) -> iodine solution (started >=1hr after thionamide) -> glucocorticoids",
"Giving iodine before the thionamide can fuel new hormone synthesis (Wolff-Chaikoff escape risk).");
await UpsertSystemNoteAsync(noteStore, deck.Id!, FlashcardTypes.Cloze,
"First-line treatment for uncomplicated hypertension in a Black patient is {{c1::a thiazide diuretic or calcium channel blocker}}.",
"", "");
await output.WriteLineAsync($"Seeded system flashcards into deck '{deck.Name}' ({deck.Id}).");
return 0;
}
private static async Task<FlashcardDeck> UpsertSystemDeckAsync(FlashcardDeckStore deckStore, string name, string description)
{
var existing = await deckStore.GetByNameAsync(FlashcardOwners.System, FlashcardDeckStore.Normalize(name));
var request = new UpsertFlashcardDeckRequest { Name = name, Description = description };
var result = await deckStore.UpsertAsync(FlashcardOwners.System, request, existing?.Id);
return result.Deck ?? existing ?? throw new InvalidOperationException($"failed to seed deck '{name}'");
}
private static async Task UpsertSystemNoteAsync(
FlashcardNoteStore noteStore, string deckId, string type, string prompt, string answer, string extra)
{
var fingerprint = FlashcardNoteStore.Fingerprint(type, prompt, answer);
var existingNotes = await noteStore.ListByDeckAsync(FlashcardOwners.System, deckId);
var existing = existingNotes.FirstOrDefault(v => v.ContentFingerprint == fingerprint);
var request = new UpsertFlashcardNoteRequest
{
DeckId = deckId,
Type = type,
Prompt = prompt,
Answer = answer,
Extra = extra,
ExpectedRevision = existing?.Revision
};
await noteStore.UpsertAsync(FlashcardOwners.System, request, existing?.Id);
}
}