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.
416 lines
17 KiB
C#
416 lines
17 KiB
C#
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<IActionResult> 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<IActionResult> 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<IActionResult> UpdateDeck(string deckId, [FromBody] UpsertFlashcardDeckRequest request)
|
|
{
|
|
var userId = UserId();
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("name required");
|
|
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true))
|
|
return NotFound();
|
|
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<IActionResult> ArchiveDeck(string deckId)
|
|
{
|
|
var userId = UserId();
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true))
|
|
return NotFound();
|
|
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<IActionResult> RestoreDeck(string deckId)
|
|
{
|
|
var userId = UserId();
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true))
|
|
return NotFound();
|
|
var result = await decks.ArchiveAsync(userId, deckId, false);
|
|
if (!result.Succeeded) return NotFound();
|
|
return Ok(result.Deck);
|
|
}
|
|
|
|
[HttpPost("notes/search")]
|
|
public async Task<IActionResult> SearchNotes([FromBody] FlashcardLibraryQuery query)
|
|
{
|
|
var userId = UserId();
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
var (items, total) = await notes.SearchVisibleAsync(userId, query);
|
|
return Ok(new FlashcardLibraryResponse { Items = items, Total = total });
|
|
}
|
|
|
|
[HttpGet("notes/{noteId}")]
|
|
public async Task<IActionResult> GetNote(string noteId)
|
|
{
|
|
var userId = UserId();
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
var note = await notes.GetVisibleAsync(userId, noteId);
|
|
if (note is null) return NotFound();
|
|
return Ok(note);
|
|
}
|
|
|
|
[HttpPost("notes")]
|
|
public async Task<IActionResult> 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!;
|
|
else if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(request.DeckId))?.IsSystem == true))
|
|
return BadRequest("cannot add cards to a system deck");
|
|
|
|
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<IActionResult> UpdateNote(string noteId, [FromBody] UpsertFlashcardNoteRequest request)
|
|
{
|
|
var userId = UserId();
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest("prompt required");
|
|
var isSuper = User.IsInRole("SUPER");
|
|
if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true))
|
|
return NotFound();
|
|
if (!string.IsNullOrWhiteSpace(request.DeckId)
|
|
&& !FlashcardOwnershipGuard.CanMutate(isSuper, (await decks.GetAnyAsync(request.DeckId))?.IsSystem == true))
|
|
return BadRequest("cannot move cards into a system deck");
|
|
|
|
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<IActionResult> MoveNotes([FromBody] FlashcardMoveRequest request)
|
|
{
|
|
var userId = UserId();
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
if (string.IsNullOrWhiteSpace(request.TargetDeckId)) return BadRequest("targetDeckId required");
|
|
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(request.TargetDeckId))?.IsSystem == true))
|
|
return BadRequest("cannot move cards into a system deck");
|
|
var moved = await notes.MoveAsync(userId, request.NoteIds, request.TargetDeckId);
|
|
return Ok(new { moved });
|
|
}
|
|
|
|
[HttpPost("notes/archive")]
|
|
public async Task<IActionResult> ArchiveNotes([FromBody] FlashcardBulkNoteIdsRequest request)
|
|
{
|
|
var userId = UserId();
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
var isSuper = User.IsInRole("SUPER");
|
|
var results = new List<FlashcardNote>();
|
|
foreach (var noteId in request.NoteIds)
|
|
{
|
|
if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true)) continue;
|
|
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<IActionResult> RestoreNotes([FromBody] FlashcardBulkNoteIdsRequest request)
|
|
{
|
|
var userId = UserId();
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
var isSuper = User.IsInRole("SUPER");
|
|
var results = new List<FlashcardNote>();
|
|
foreach (var noteId in request.NoteIds)
|
|
{
|
|
if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true)) continue;
|
|
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> GetSettings()
|
|
{
|
|
var userId = UserId();
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
return Ok(await settings.GetAsync(userId));
|
|
}
|
|
|
|
[HttpPut("settings")]
|
|
public async Task<IActionResult> 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<IActionResult> SubmitReviews([FromBody] FlashcardBatchReviewRequest request)
|
|
{
|
|
var userId = UserId();
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
|
|
var results = new List<FlashcardReviewResponse>();
|
|
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<IActionResult> 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<IActionResult> 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<string> { "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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<string> CardIds { get; set; } = [];
|
|
public bool Suspended { get; set; } = true;
|
|
}
|
|
|
|
public sealed class FlashcardBuryRequest
|
|
{
|
|
public List<string> CardIds { get; set; } = [];
|
|
public DateTime UntilUtc { get; set; }
|
|
}
|