using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using QuestionsApi.Models; using QuestionsApi.Services.Flashcards; using System.Security.Claims; using System.Text; namespace QuestionsApi.Controllers; [ApiController] [Route("api/flashcards")] [Authorize(Roles = "USER,SUPER")] public sealed class FlashcardsController( FlashcardDeckStore decks, FlashcardNoteStore notes, FlashcardScheduleStore schedules, FlashcardSettingsStore settings, FlashcardMediaStore media, FlashcardStudyService study, FlashcardImportExportService importExport) : ControllerBase { [HttpGet("decks")] public async Task ListDecks() { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); await decks.GetOrCreateDefaultDeckAsync(userId); var now = DateTime.UtcNow; var summaries = await study.GetDeckSummariesAsync(userId, now, now.Date); return Ok(summaries); } [HttpPost("decks")] public async Task CreateDeck([FromBody] UpsertFlashcardDeckRequest request) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("name required"); var result = await decks.UpsertAsync(userId, request, null); if (!result.Succeeded) return Conflict(result.Current); return Ok(result.Deck); } [HttpPut("decks/{deckId}")] public async Task UpdateDeck(string deckId, [FromBody] UpsertFlashcardDeckRequest request) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("name required"); var result = await decks.UpsertAsync(userId, request, deckId); if (!result.Succeeded) return Conflict(result.Current); return Ok(result.Deck); } [HttpPost("decks/{deckId}/archive")] public async Task ArchiveDeck(string deckId) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); 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); } [HttpPost("decks/{deckId}/restore")] public async Task RestoreDeck(string deckId) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var result = await decks.ArchiveAsync(userId, deckId, false); if (!result.Succeeded) return NotFound(); return Ok(result.Deck); } [HttpPost("notes/search")] public async Task SearchNotes([FromBody] FlashcardLibraryQuery query) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var (items, total) = await notes.SearchAsync(userId, query); return Ok(new FlashcardLibraryResponse { Items = items, Total = total }); } [HttpGet("notes/{noteId}")] public async Task GetNote(string noteId) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var note = await notes.GetAsync(userId, noteId); if (note is null) return NotFound(); return Ok(note); } [HttpPost("notes")] public async Task CreateNote([FromBody] UpsertFlashcardNoteRequest request) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest("prompt required"); if (string.IsNullOrWhiteSpace(request.DeckId)) request.DeckId = (await decks.GetOrCreateDefaultDeckAsync(userId)).Id!; try { var result = await notes.UpsertAsync(userId, request, null); if (!result.Succeeded) return Conflict(result.Current); return Ok(result.Note); } catch (FlashcardValidationException exception) { return BadRequest(exception.Message); } } [HttpPut("notes/{noteId}")] public async Task UpdateNote(string noteId, [FromBody] UpsertFlashcardNoteRequest request) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest("prompt required"); try { var result = await notes.UpsertAsync(userId, request, noteId); if (!result.Succeeded) return Conflict(result.Current); return Ok(result.Note); } catch (FlashcardValidationException exception) { return BadRequest(exception.Message); } } [HttpPost("notes/move")] public async Task MoveNotes([FromBody] FlashcardMoveRequest request) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); if (string.IsNullOrWhiteSpace(request.TargetDeckId)) return BadRequest("targetDeckId required"); var moved = await notes.MoveAsync(userId, request.NoteIds, request.TargetDeckId); return Ok(new { moved }); } [HttpPost("notes/archive")] public async Task ArchiveNotes([FromBody] FlashcardBulkNoteIdsRequest request) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var results = new List(); foreach (var noteId in request.NoteIds) { var result = await notes.ArchiveAsync(userId, noteId, true); if (result.Note is not null) results.Add(result.Note); } return Ok(results); } [HttpPost("notes/restore")] public async Task RestoreNotes([FromBody] FlashcardBulkNoteIdsRequest request) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var results = new List(); foreach (var noteId in request.NoteIds) { var result = await notes.ArchiveAsync(userId, noteId, false); if (result.Note is not null) results.Add(result.Note); } return Ok(results); } [HttpPost("cards/suspend")] public async Task SuspendCards([FromBody] FlashcardSuspendRequest request) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var changed = await schedules.SetSuspendedAsync(userId, request.CardIds, request.Suspended); return Ok(new { changed }); } [HttpPost("cards/bury")] public async Task BuryCards([FromBody] FlashcardBuryRequest request) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var until = request.UntilUtc == default ? DateTime.UtcNow.Date.AddDays(1) : request.UntilUtc; var changed = await schedules.BuryAsync(userId, request.CardIds, until); return Ok(new { changed }); } [HttpPost("study/next")] public async Task NextCard([FromBody] FlashcardStudyRequest request) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var response = await study.GetNextCardAsync(userId, request); return Ok(response); } [HttpGet("counts")] public async Task GetCounts() { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var now = DateTime.UtcNow; var counts = await study.GetDashboardCountsAsync(userId, now, now.Date.AddDays(-6)); return Ok(counts); } [HttpGet("settings")] public async Task GetSettings() { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); return Ok(await settings.GetAsync(userId)); } [HttpPut("settings")] public async Task UpdateSettings([FromBody] UpdateFlashcardSettingsRequest request) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); return Ok(await settings.UpdateAsync(userId, request)); } [HttpPost("reviews")] public async Task SubmitReviews([FromBody] FlashcardBatchReviewRequest request) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var results = new List(); foreach (var review in request.Reviews) { if (!Guid.TryParse(review.ClientReviewId, out _)) return BadRequest("clientReviewId must be a UUID"); if (!FlashcardRatings.All.Contains(review.Rating)) return BadRequest("rating must be again/hard/good/easy"); var outcome = await schedules.SubmitReviewAsync(userId, review); switch (outcome.Kind) { case FlashcardReviewOutcomeKind.NotFound: return NotFound($"card {review.CardId} not found"); case FlashcardReviewOutcomeKind.Conflict: return Conflict(new { cardId = review.CardId, current = outcome.Schedule }); case FlashcardReviewOutcomeKind.Duplicate: results.Add(new FlashcardReviewResponse { Card = outcome.Schedule!, Duplicate = true }); break; default: results.Add(new FlashcardReviewResponse { Card = outcome.Schedule!, RatingOptions = schedules.PreviewRatingOptions(outcome.Schedule!, DateTime.UtcNow) }); break; } } return Ok(new FlashcardBatchReviewResponse { Results = results }); } [HttpPost("reviews/undo")] public async Task UndoReview([FromBody] FlashcardUndoRequest request) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var outcome = await schedules.UndoAsync(userId, request.ReviewLogId); return outcome.Kind switch { FlashcardReviewOutcomeKind.NotFound => NotFound(), FlashcardReviewOutcomeKind.Conflict => Conflict(outcome.Schedule), _ => Ok(outcome.Schedule) }; } [HttpPost("media")] [RequestSizeLimit(5_000_000)] public async Task UploadMedia(IFormFile file) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); if (file is null || file.Length == 0) return BadRequest("file required"); if (file.Length > 5_000_000) return BadRequest("file must be 5 MB or smaller"); var contentType = file.ContentType.Trim().ToLowerInvariant(); var allowedTypes = new HashSet { "image/png", "image/jpeg", "image/webp" }; if (!allowedTypes.Contains(contentType)) return BadRequest("file must be a png, jpeg, or webp image"); await using var stream = file.OpenReadStream(); using var memory = new MemoryStream(); await stream.CopyToAsync(memory); var asset = await media.CreateAsync(userId, file.FileName, contentType, memory.ToArray()); return Ok(new { id = asset.Id ?? "", url = $"/api/flashcards/media/{asset.Id}" }); } [HttpGet("media/{id}")] public async Task GetMedia(string id) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var asset = await media.GetAsync(userId, id.Trim()); if (asset is null) return NotFound(); return File(asset.Bytes, asset.ContentType); } [HttpGet("export/csv")] public async Task ExportCsv() { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var (csv, omissions) = await importExport.ExportCsvAsync(userId); Response.Headers.Append("X-Flashcard-Export-Omissions", string.Join(';', omissions.Select(v => v.Kind))); return File(Encoding.UTF8.GetBytes(csv), "text/csv", "flashcards.csv"); } [HttpGet("export/json")] public async Task ExportJson() { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var export = await importExport.ExportJsonAsync(userId); return Ok(export); } [HttpPost("import/csv/preview")] [RequestSizeLimit(25 * 1024 * 1024)] public async Task PreviewCsvImport(IFormFile file) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); if (file is null || file.Length == 0) return BadRequest("file required"); try { FlashcardImportExportService.ValidateSize(file.Length); using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8); var text = await reader.ReadToEndAsync(); return Ok(await importExport.PreviewCsvAsync(userId, text)); } catch (FlashcardValidationException exception) { return BadRequest(exception.Message); } } [HttpPost("import/json/preview")] [RequestSizeLimit(25 * 1024 * 1024)] public async Task PreviewJsonImport(IFormFile file) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); if (file is null || file.Length == 0) return BadRequest("file required"); try { FlashcardImportExportService.ValidateSize(file.Length); using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8); var text = await reader.ReadToEndAsync(); return Ok(await importExport.PreviewJsonAsync(userId, text)); } catch (FlashcardValidationException exception) { return BadRequest(exception.Message); } } [HttpPost("import/commit")] public async Task CommitImport([FromBody] FlashcardImportCommitRequest request) { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); try { return Ok(await importExport.CommitAsync(userId, request.ImportToken)); } catch (FlashcardValidationException exception) { return BadRequest(exception.Message); } } private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? ""; } public sealed class FlashcardSuspendRequest { public List CardIds { get; set; } = []; public bool Suspended { get; set; } = true; } public sealed class FlashcardBuryRequest { public List CardIds { get; set; } = []; public DateTime UntilUtc { get; set; } }