Files
step3/api/QuestionsApi/Services/Flashcards/FlashcardDeckStore.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

144 lines
6.4 KiB
C#

using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed record FlashcardDeckUpdateResult(FlashcardDeck? Deck, FlashcardDeck? Current)
{
public bool Succeeded => Deck is not null;
}
public sealed class FlashcardDeckStore
{
private readonly IMongoCollection<FlashcardDeck> _decks;
public FlashcardDeckStore(MongoDatabaseProvider mongo)
{
_decks = mongo.Database.GetCollection<FlashcardDeck>("FlashcardDecks");
_decks.Indexes.CreateMany([
new CreateIndexModel<FlashcardDeck>(
Builders<FlashcardDeck>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.NormalizedName),
new CreateIndexOptions { Unique = true, Name = "flashcard_deck_unique_user_name" }),
new CreateIndexModel<FlashcardDeck>(
Builders<FlashcardDeck>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.StableId),
new CreateIndexOptions { Unique = true, Name = "flashcard_deck_unique_user_stable" })
]);
}
public static string Normalize(string name) => name.Trim().ToLowerInvariant();
public Task<List<FlashcardDeck>> ListAsync(string userId) =>
_decks.Find(v => v.UserId == userId && v.ArchivedUtc == null).SortBy(v => v.Name).ToListAsync();
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();
if (existing is not null) return existing;
var deck = new FlashcardDeck
{
UserId = userId,
Name = "Default",
NormalizedName = Normalize("Default"),
IsDefault = true
};
try
{
await _decks.InsertOneAsync(deck);
return deck;
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return await _decks.Find(v => v.UserId == userId && v.IsDefault).FirstOrDefaultAsync()
?? throw new InvalidOperationException("default deck race with no winner");
}
}
public async Task<FlashcardDeckUpdateResult> UpsertAsync(string userId, UpsertFlashcardDeckRequest request, string? deckId)
{
var now = DateTime.UtcNow;
var normalized = Normalize(request.Name);
if (string.IsNullOrWhiteSpace(deckId))
{
var created = new FlashcardDeck
{
UserId = userId,
Name = request.Name.Trim(),
NormalizedName = normalized,
Description = request.Description.Trim(),
NewCardsPerDay = request.NewCardsPerDay,
ReviewsPerDay = request.ReviewsPerDay,
DesiredRetention = request.DesiredRetention,
CreatedUtc = now,
UpdatedUtc = now
};
try
{
await _decks.InsertOneAsync(created);
return new FlashcardDeckUpdateResult(created, null);
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return new FlashcardDeckUpdateResult(null, await GetByNameAsync(userId, normalized));
}
}
var expectedRevision = request.ExpectedRevision ?? 0;
var updated = await _decks.FindOneAndUpdateAsync(
v => v.UserId == userId && v.Id == deckId && v.Revision == expectedRevision,
Builders<FlashcardDeck>.Update
.Set(v => v.Name, request.Name.Trim())
.Set(v => v.NormalizedName, normalized)
.Set(v => v.Description, request.Description.Trim())
.Set(v => v.NewCardsPerDay, request.NewCardsPerDay)
.Set(v => v.ReviewsPerDay, request.ReviewsPerDay)
.Set(v => v.DesiredRetention, request.DesiredRetention)
.Set(v => v.UpdatedUtc, now)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardDeck> { ReturnDocument = ReturnDocument.After });
return updated is not null
? new FlashcardDeckUpdateResult(updated, null)
: new FlashcardDeckUpdateResult(null, await GetAsync(userId, deckId));
}
public async Task<FlashcardDeck?> GetByNameAsync(string userId, string normalizedName) =>
await _decks.Find(v => v.UserId == userId && v.NormalizedName == normalizedName).FirstOrDefaultAsync();
public async Task<FlashcardDeckUpdateResult> ArchiveAsync(string userId, string deckId, bool archived)
{
var deck = await GetAsync(userId, deckId);
if (deck is not null && deck.IsDefault) return new FlashcardDeckUpdateResult(null, deck);
var updated = await _decks.FindOneAndUpdateAsync(
v => v.UserId == userId && v.Id == deckId && !v.IsDefault,
Builders<FlashcardDeck>.Update
.Set(v => v.ArchivedUtc, archived ? DateTime.UtcNow : null)
.Set(v => v.UpdatedUtc, DateTime.UtcNow)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardDeck> { ReturnDocument = ReturnDocument.After });
return updated is not null
? new FlashcardDeckUpdateResult(updated, null)
: new FlashcardDeckUpdateResult(null, deck);
}
}