using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using MongoDB.Driver; using QuestionsApi.Models; namespace QuestionsApi.Services.Flashcards; public sealed record FlashcardNoteUpdateResult(FlashcardNote? Note, FlashcardNote? Current) { public bool Succeeded => Note is not null; } public sealed class FlashcardNoteStore { private readonly IMongoCollection _notes; private readonly FlashcardScheduleStore _schedules; public FlashcardNoteStore(MongoDatabaseProvider mongo, FlashcardScheduleStore schedules) { _notes = mongo.Database.GetCollection("FlashcardNotes"); _schedules = schedules; _notes.Indexes.CreateMany([ new CreateIndexModel( Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.StableId), new CreateIndexOptions { Unique = true, Name = "flashcard_note_unique_user_stable" }), new CreateIndexModel( Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.DeckId), new CreateIndexOptions { Name = "flashcard_note_user_deck" }), new CreateIndexModel( Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.SourceQuestionExternalId), new CreateIndexOptions { Name = "flashcard_note_user_source_question" }), new CreateIndexModel( Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.ContentFingerprint), new CreateIndexOptions { Name = "flashcard_note_user_fingerprint" }) ]); } public static string Fingerprint(string type, string prompt, string answer) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{type}{prompt.Trim()}{answer.Trim()}"))); public async Task GetAsync(string userId, string noteId) => await _notes.Find(v => v.UserId == userId && v.Id == noteId).FirstOrDefaultAsync(); public async Task GetByStableIdAsync(string userId, string stableId) => await _notes.Find(v => v.UserId == userId && v.StableId == stableId).FirstOrDefaultAsync(); public Task> ListByDeckAsync(string userId, string deckId) => _notes.Find(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null).ToListAsync(); public Task> 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> ListByDeckVisibleAsync(string userId, string deckId) => _notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.DeckId == deckId && v.ArchivedUtc == null).ToListAsync(); public Task> ListAllVisibleActiveAsync(string userId) => _notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.ArchivedUtc == null) .ToListAsync(); public async Task 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 GetAnyAsync(string noteId) => await _notes.Find(v => v.Id == noteId).FirstOrDefaultAsync(); public async Task<(List Items, int Total)> SearchVisibleAsync(string userId, FlashcardLibraryQuery query) { var filter = Builders.Filter.Or( Builders.Filter.Eq(v => v.UserId, userId), Builders.Filter.Eq(v => v.UserId, FlashcardOwners.System)); if (query.State != FlashcardArchiveFilter.IncludeArchived) filter &= Builders.Filter.Eq(v => v.ArchivedUtc, null); if (!string.IsNullOrWhiteSpace(query.DeckId)) filter &= Builders.Filter.Eq(v => v.DeckId, query.DeckId); if (!string.IsNullOrWhiteSpace(query.Type)) filter &= Builders.Filter.Eq(v => v.Type, query.Type); if (!string.IsNullOrWhiteSpace(query.Tag)) filter &= Builders.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.Filter.Or( Builders.Filter.Regex(v => v.Prompt, pattern), Builders.Filter.Regex(v => v.Answer, pattern), Builders.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 Items, int Total)> SearchAsync(string userId, FlashcardLibraryQuery query) { var filter = Builders.Filter.Eq(v => v.UserId, userId); if (query.State != FlashcardArchiveFilter.IncludeArchived) filter &= Builders.Filter.Eq(v => v.ArchivedUtc, null); if (!string.IsNullOrWhiteSpace(query.DeckId)) filter &= Builders.Filter.Eq(v => v.DeckId, query.DeckId); if (!string.IsNullOrWhiteSpace(query.Type)) filter &= Builders.Filter.Eq(v => v.Type, query.Type); if (!string.IsNullOrWhiteSpace(query.Tag)) filter &= Builders.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.Filter.Or( Builders.Filter.Regex(v => v.Prompt, pattern), Builders.Filter.Regex(v => v.Answer, pattern), Builders.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 UpsertAsync(string userId, UpsertFlashcardNoteRequest request, string? noteId) { var type = FlashcardTypes.All.Contains(request.Type) ? request.Type : FlashcardTypes.Basic; if (type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(request.Prompt)) throw new FlashcardValidationException("cloze note must contain at least one {{cN::answer}} marker"); var now = DateTime.UtcNow; var fingerprint = Fingerprint(type, request.Prompt, request.Answer); FlashcardNote? saved; if (string.IsNullOrWhiteSpace(noteId)) { var created = new FlashcardNote { UserId = userId, StableId = string.IsNullOrWhiteSpace(request.StableId) ? Guid.NewGuid().ToString() : request.StableId, DeckId = request.DeckId, Type = type, Prompt = request.Prompt, Answer = request.Answer, Extra = request.Extra, Tags = NormalizeTags(request.Tags), Images = request.Images, SourceQuestionExternalId = request.SourceQuestionExternalId, ContentFingerprint = fingerprint, CreatedUtc = now, UpdatedUtc = now }; try { await _notes.InsertOneAsync(created); saved = created; } catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { return new FlashcardNoteUpdateResult(null, await GetByStableIdAsync(userId, created.StableId)); } } else { var expectedRevision = request.ExpectedRevision ?? 0; var updated = await _notes.FindOneAndUpdateAsync( v => v.UserId == userId && v.Id == noteId && v.Revision == expectedRevision, Builders.Update .Set(v => v.DeckId, request.DeckId) .Set(v => v.Type, type) .Set(v => v.Prompt, request.Prompt) .Set(v => v.Answer, request.Answer) .Set(v => v.Extra, request.Extra) .Set(v => v.Tags, NormalizeTags(request.Tags)) .Set(v => v.Images, request.Images) .Set(v => v.SourceQuestionExternalId, request.SourceQuestionExternalId) .Set(v => v.ContentFingerprint, fingerprint) .Set(v => v.UpdatedUtc, now) .Inc(v => v.Revision, 1), new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); if (updated is null) return new FlashcardNoteUpdateResult(null, await GetAsync(userId, noteId)); saved = updated; } await _schedules.SyncVariantsAsync(saved); return new FlashcardNoteUpdateResult(saved, null); } public async Task ArchiveAsync(string userId, string noteId, bool archived) { var updated = await _notes.FindOneAndUpdateAsync( v => v.UserId == userId && v.Id == noteId, 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 FlashcardNoteUpdateResult(updated, null) : new FlashcardNoteUpdateResult(null, await GetAsync(userId, noteId)); } public async Task MoveAsync(string userId, IReadOnlyCollection noteIds, string targetDeckId) { var result = await _notes.UpdateManyAsync( v => v.UserId == userId && noteIds.Contains(v.Id!), Builders.Update.Set(v => v.DeckId, targetDeckId).Set(v => v.UpdatedUtc, DateTime.UtcNow).Inc(v => v.Revision, 1)); return result.ModifiedCount; } public Task CountInDeckAsync(string userId, string deckId) => _notes.CountDocumentsAsync(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null); private static List NormalizeTags(List tags) => tags .Select(v => v.Trim()) .Where(v => v.Length > 0) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } public sealed class FlashcardValidationException(string message) : Exception(message); public static class FlashcardArchiveFilter { public const string IncludeArchived = "archived"; }