using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Infrastructure; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Update.Internal; using QuotifyBE.Data; using QuotifyBE.DTOs; using QuotifyBE.Entities; using QuotifyBE.Mapping; using System.Reflection.Metadata.Ecma335; using System.Security.Claims; namespace QuotifyBE.Controllers; [ApiController] [Route("api/v1/quotes")] [Produces("application/json")] public class QuotesController : ControllerBase { private readonly ApplicationDbContext _db; private readonly GeneralUseHelpers guhf; public QuotesController(ApplicationDbContext db, GeneralUseHelpers GUHF) { _db = db; guhf = GUHF; } // GET /api/v1/quotes /// /// Get a page of quotes /// /// /// A page of quotes consists of 10 quotes or less. /// If a page does not contain any quotes, 404 is returned. /// Important! Has CORS set, unlike e.g. GET /api/v1/quote/{id} or GET /api/v1/quote/random. /// /// The page number /// A page (10 quotes) /// Returned on valid request /// Returned when requested page is invalid [HttpGet("page/{page_no}")] [EnableCors] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(ErrorDTO), 404)] public async Task GetQuotePage(int page_no) { var totalQuotes = await _db.Quotes.CountAsync(); const int PageSize = 10; if (page_no <= 0) { return NotFound(new ErrorDTO { Status = "error", Error_msg = "Numer strony musi być większy niż 0" }); } var quotes = await _db.Quotes .Include(q => q.QuoteCategories) .ThenInclude(qc => qc.Category) .Include(q => q.User) .Include(q => q.Image) .OrderBy(q => q.Id) .Skip((page_no - 1) * PageSize) .Take(PageSize) .ToListAsync(); var result = quotes .Select(q => q.ToQuoteShortDTO()) .ToList(); return Ok(result); } // GET /api/v1/quotes/{id} /// /// [AUTHED] Get specified quote summary /// /// The quote id in question /// A quote: id, quote content and author, imageUrl and categories if successful, otherwise: error message /// Returned on valid request /// Returned when quote id is invalid or simply doesn't exist [HttpGet("{id}")] [Authorize] [ProducesResponseType(typeof(QuoteShortDTO), 200)] [ProducesResponseType(typeof(ErrorDTO), 404)] public async Task GetQuoteById(int id) { var quote = await _db.Quotes .Include(q => q.QuoteCategories!) .ThenInclude(qc => qc.Category) .Include(q => q.User) .Include(q => q.Image) .FirstOrDefaultAsync(q => q.Id == id); if (quote == null) return NotFound(new { status = "error", error_msg = "Quote not found" }); return Ok(quote.ToQuoteShortDTO()); } // POST /api/v1/quotes/new /// /// [AUTHED] Add a new quote /// /// Newly created quote's id /// Form data containing required quote information /// Returned on valid request /// Returned when any of the categories does not exist /// Returned when user's id does not match the creator's id [HttpPost("new")] [Authorize] [EnableCors] [ProducesResponseType(201)] [ProducesResponseType(typeof(ErrorDTO), 400)] [ProducesResponseType(typeof(ErrorDTO), 403)] public async Task CreateQuote([FromBody] CreateQuoteDTO request) { // Get user ID from claims var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (userIdClaim == null || !int.TryParse(userIdClaim, out int userId)) // https://stackoverflow.com/a/47708867 return StatusCode(403, new ErrorDTO { Status = "error", Error_msg = "Invalid user ID" }); // Find or create image Image? image = null; if (!string.IsNullOrEmpty(request.ImageUrl)) { image = await _db.Images.FirstOrDefaultAsync(i => i.Url == request.ImageUrl); if (image == null) { image = new Image { Url = request.ImageUrl }; _db.Images.Add(image); await _db.SaveChangesAsync(); } } // Create quote var quote = new Quote { Text = request.Text, Author = request.Author, CreatedAt = DateTime.UtcNow, LastUpdatedAt = DateTime.UtcNow, ImageId = image?.Id ?? null, UserId = userId, QuoteCategories = new List() }; // Attach categories foreach (var categoryId in request.CategoryIds ?? []) { var categoryExists = await _db.Categories.AnyAsync(c => c.Id == categoryId); if (!categoryExists) return BadRequest(new ErrorDTO { Status = "error", Error_msg = $"Category ID {categoryId} not found" }); quote.QuoteCategories.Add(new QuoteCategory { CategoryId = categoryId, Quote = quote }); } _db.Quotes.Add(quote); await _db.SaveChangesAsync(); return CreatedAtAction(nameof(GetQuoteById), new { id = quote.Id }, quote); } // GET /api/v1/quotes/random /// /// Get a random quote summary /// /// A quote: id, quote content and author, imageUrl and categories if successful, otherwise: error message /// Returned on valid request /// Returned when no quotes exist [HttpGet("random")] [AllowAnonymous] [ProducesResponseType(typeof(QuoteShortDTO), 200)] [ProducesResponseType(typeof(ErrorDTO), 404)] public async Task GetRandomQuote() { var totalQuotes = await _db.Quotes.CountAsync(); if (totalQuotes == 0) return NotFound(new ErrorDTO { Status = "error", Error_msg = "No quotes to choose from" }); var random = new Random(); var skip = random.Next(0, totalQuotes); var quote = await _db.Quotes .Include(q => q.QuoteCategories!) .ThenInclude(qc => qc.Category) .Skip(skip) .Take(1) .FirstOrDefaultAsync(); if (quote == null) return NotFound(new ErrorDTO { Status = "error", Error_msg = "Unknown error - couldn't get quote" }); Image? image = null; if (quote.ImageId != 0) { image = await _db.Images.FirstOrDefaultAsync(i => i.Id == quote.ImageId); } var dto = new QuoteShortDTO { Id = quote.Id, Text = quote.Text, Author = quote.Author, ImageUrl = image?.Url, Categories = quote.QuoteCategories? .Select(qc => qc.Category?.Name ?? "") .Where(name => !string.IsNullOrEmpty(name)) .ToList() ?? new List() }; return Ok(dto); } // DELETE /api/v1/quotes/{id} /// /// [AUTHED] Delete a quote /// /// /// Deletes a quote, granted it exists.
///
/// /// Is this the best practice? Marking the quote as hidden is also an option. /// ~eee4 ///
/// Json with status /// Quote id which will be deleted /// Returned on valid request /// Returned when no such quote exists [HttpDelete("{id}")] [Authorize] [EnableCors] [ProducesResponseType(204)] [ProducesResponseType(typeof(ErrorDTO), 404)] public async Task DeleteQuote(int id) { // (Attempt to) find the quote Quote? quote = await _db.Quotes .FirstOrDefaultAsync(q => q.Id == id); // Failed? if (quote == null) return NotFound(new { status = "error", error_msg = "Quote not found" }); // If succeded, remove the quote _db.Quotes.Remove(quote); await _db.SaveChangesAsync(); // ====================================================================== // // Important! // // Is this the best we can do? Won't marking the quote as "hidden" // // be better than explicitly deleting it? ~eee4 // // ====================================================================== // // Return ok return Ok(new { Status = "ok" }); } // PATCH /api/v1/quotes/{id} /// /// [AUTHED] Modify an existing quote /// /// /// Modifies an existing quote. ///

/// Warning! /// We don't check the user id which created the quote. /// In case of single-user instances, this should not be a problem. /// This might become one, if we want users with non-admin roles; /// that would need some proper ACL checks here (with the help of GUHF). ///

/// Important! /// Image handling works the same as with creating new quote. /// This means that images not present in the DB will be added automatically. ///

/// Important! /// "categories = null" is not the same as "categories = []"! /// While "categories = null" will not alter the quote's categories, /// "categories = []" will (and in turn, empty each and every present category)!
/// Be careful when handling user-provided categories! ///
/// Newly modified quote as a DTO /// Quote to be modified /// Updated quote form data /// Returned on valid request /// Returned when request text or author is empty (or whitespace) /// Returned when no such quote exists [HttpPatch("{id}")] [Authorize] [EnableCors] [ProducesResponseType(typeof(QuoteShortDTO), 200)] [ProducesResponseType(typeof(ErrorDTO), 400)] [ProducesResponseType(typeof(ErrorDTO), 404)] public async Task EditQuote(int id, [FromBody] QuoteShortDTO updatedQuote) { // Try to find the quote in question Quote? quote = await _db.Quotes .Include(q => q.QuoteCategories) .FirstOrDefaultAsync(q => q.Id == id); // Failed? if (quote == null) return NotFound(new { status = "error", error_msg = "Quote not found" }); // Is quote contents or author empty? if (string.IsNullOrWhiteSpace(updatedQuote.Text) || string.IsNullOrWhiteSpace(updatedQuote.Author)) return BadRequest(new ErrorDTO { Status = "error", Error_msg = "Text and author are required." }); // Alter the quote's content quote.Text = updatedQuote.Text; quote.Author = updatedQuote.Author; quote.LastUpdatedAt = DateTime.UtcNow; // Try to find the image inside the DB Image? image = null; if (!string.IsNullOrEmpty(updatedQuote.ImageUrl)) { image = await _db.Images.FirstOrDefaultAsync(i => i.Url == updatedQuote.ImageUrl); // Failed? Just insert it yourself if (image == null) { image = new Image { Url = updatedQuote.ImageUrl }; _db.Images.Add(image); await _db.SaveChangesAsync(); } } quote.Image = image; // Don't touch categories if they are explicitly null if (updatedQuote.Categories == null) { } // If they aren't else if (updatedQuote.Categories.Any()) { // Get all the categories associated with a quote from DB List categoriesFromDb = await _db.Categories .Where(c => updatedQuote.Categories.Contains(c.Name)) .ToListAsync(); // Determine which ones are already present, and which to add IEnumerable existingNames = categoriesFromDb .Select(c => c.Name); List newNames = updatedQuote.Categories .Except(existingNames) .ToList(); // For all the categories not present foreach (var name in newNames) { // Add them to the DB var newCat = new Category { Name = name, Description = string.Empty, CreatedAt = DateTime.UtcNow }; _db.Categories.Add(newCat); categoriesFromDb.Add(newCat); } // If any categories were added, save changes if (newNames.Any()) await _db.SaveChangesAsync(); // Assign all the new categories to the quote quote.QuoteCategories = categoriesFromDb .Select(cat => new QuoteCategory { CategoryId = cat.Id, QuoteId = quote.Id }) .ToList(); } else { // No categories (empty list) inside DTO? // Clear them all! quote.QuoteCategories.Clear(); } // Save changes, return new quote as a DTO await _db.SaveChangesAsync(); return Ok(quote.ToQuoteShortDTO()); } }