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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user