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 _decks; public FlashcardDeckStore(MongoDatabaseProvider mongo) { _decks = mongo.Database.GetCollection("FlashcardDecks"); _decks.Indexes.CreateMany([ new CreateIndexModel( Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.NormalizedName), new CreateIndexOptions { Unique = true, Name = "flashcard_deck_unique_user_name" }), new CreateIndexModel( Builders.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> ListAsync(string userId) => _decks.Find(v => v.UserId == userId && v.ArchivedUtc == null).SortBy(v => v.Name).ToListAsync(); public async Task GetAsync(string userId, string deckId) => await _decks.Find(v => v.UserId == userId && v.Id == deckId).FirstOrDefaultAsync(); public async Task 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 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.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 { ReturnDocument = ReturnDocument.After }); return updated is not null ? new FlashcardDeckUpdateResult(updated, null) : new FlashcardDeckUpdateResult(null, await GetAsync(userId, deckId)); } public async Task GetByNameAsync(string userId, string normalizedName) => await _decks.Find(v => v.UserId == userId && v.NormalizedName == normalizedName).FirstOrDefaultAsync(); public async Task 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.Update .Set(v => v.ArchivedUtc, archived ? DateTime.UtcNow : null) .Set(v => v.UpdatedUtc, DateTime.UtcNow) .Inc(v => v.Revision, 1), new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); return updated is not null ? new FlashcardDeckUpdateResult(updated, null) : new FlashcardDeckUpdateResult(null, deck); } }