diff --git a/Controllers/QuoteController.cs b/Controllers/QuoteController.cs index 9f1a4cd..35ec1f4 100644 --- a/Controllers/QuoteController.cs +++ b/Controllers/QuoteController.cs @@ -1,192 +1,201 @@ -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, an empty list 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 (page_no <= 0) - [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 - /// - /// - /// As per project's guidelines, requires a JWT. - /// - /// 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 ?? []) - { - Category? category = await _db.Categories.FirstOrDefaultAsync(c => c.Id == categoryId); - if (category == null) - return BadRequest(new ErrorDTO { Status = "error", Error_msg = $"Category ID {categoryId} not found" }); - - quote.QuoteCategories.Add(new QuoteCategory - { - Category = category, - Quote = quote - }); - } - - _db.Quotes.Add(quote); - await _db.SaveChangesAsync(); - - return CreatedAtAction(nameof(GetQuoteById), new { id = quote.Id }, quote.ToQuoteShortDTO()); - } - - // 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)] +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, an empty list 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 (page_no <= 0) + [HttpGet("page/{page_no}")] + [EnableCors] + [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(ErrorDTO), 404)] - [ProducesResponseType(204)] + public async Task GetQuotePage(int page_no, [FromQuery] int? category_id = null) + { + const int PageSize = 10; + + if (page_no <= 0) + { + return NotFound(new ErrorDTO { Status = "error", Error_msg = "Numer strony musi być większy niż 0" }); + } + + // Paginacja bez filtra + var baseQuery = _db.Quotes + .Include(q => q.QuoteCategories!) + .ThenInclude(qc => qc.Category) + .Include(q => q.User) + .Include(q => q.Image) + .OrderBy(q => q.Id); + + var pageQuotes = await baseQuery + .Skip((page_no - 1) * PageSize) + .Take(PageSize) + .ToListAsync(); + + // Filtrowanie dopiero po pobraniu strony + if (category_id.HasValue) + { + pageQuotes = pageQuotes + .Where(q => q.QuoteCategories!.Any(qc => qc.CategoryId == category_id.Value)) + .ToList(); + } + + var result = pageQuotes + .Select(q => q.ToQuoteShortDTO()) + .ToList(); + + return Ok(result); + } + // GET /api/v1/quotes/{id} + /// + /// [AUTHED] Get specified quote summary + /// + /// + /// As per project's guidelines, requires a JWT. + /// + /// 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 ?? []) + { + Category? category = await _db.Categories.FirstOrDefaultAsync(c => c.Id == categoryId); + if (category == null) + return BadRequest(new ErrorDTO { Status = "error", Error_msg = $"Category ID {categoryId} not found" }); + + quote.QuoteCategories.Add(new QuoteCategory + { + Category = category, + Quote = quote + }); + } + + _db.Quotes.Add(quote); + await _db.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetQuoteById), new { id = quote.Id }, quote.ToQuoteShortDTO()); + } + + // 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)] + [ProducesResponseType(204)] public async Task GetRandomQuote([FromQuery] int? category_id = null) { IQueryable query = _db.Quotes.Include(q => q.QuoteCategories!).ThenInclude(qc => qc.Category); @@ -239,173 +248,173 @@ public class QuotesController : ControllerBase 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(200)] - [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()); - } - -} + } + + // 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(200)] + [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()); + } + +}