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.
144 lines
6.4 KiB
C#
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);
|
|
}
|
|
}
|