using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json.Linq; using QuotifyBE.Data; using QuotifyBE.DTOs; using QuotifyBE.Entities; using QuotifyBE.Mapping; 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 /// How to sort the results (desc/asc) /// (Optional) Standalone category id or comma separated ids (e.g. "1" or "1,2,3") /// 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 = 1, string? sort = "desc", [FromQuery] string? category_id = null) { var totalQuotes = await _db.Quotes.CountAsync(); const int PageSize = 10; List? categories; try { categories = category_id? .Split(",") .Select(Int32.Parse) .ToList(); } catch { // Try to catch badly formatted requests return BadRequest(new ErrorDTO { Status = "error", Error_msg = "Category_id can be either an integer, or comma separated integers" }); } 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); // Sort the results in ascending/descending order by id IOrderedQueryable? orderedQuery; if (sort != null && sort.Equals("asc")) orderedQuery = baseQuery.OrderBy(q => q.Id); else // Sort in descending order by default orderedQuery = baseQuery.OrderByDescending(q => q.Id); // Botched solution List pageQuotes; // Filtrowanie przed pobraniem strony if (categories != null) { pageQuotes = await orderedQuery .Where(q => q.QuoteCategories! .Any(qc => categories.Contains(qc.CategoryId)) //.Any(qc => qc.CategoryId == category_id.Value) ) .Skip((page_no - 1) * PageSize) .Take(PageSize) .ToListAsync(); } else { pageQuotes = await orderedQuery .Skip((page_no - 1) * PageSize) .Take(PageSize) .ToListAsync(); } 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 /// /// Note: /// User-provided image URLs are validated by checking /// if they start with "https://", "http://" or "/". /// This is rather a naive solution. /// /// 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 /// Returned when image url is invalid (does not start with "https://", "http://", or "/") [HttpPost("new")] [Authorize] [EnableCors] [ProducesResponseType(201)] [ProducesResponseType(typeof(ErrorDTO), 400)] [ProducesResponseType(typeof(ErrorDTO), 403)] [ProducesResponseType(typeof(ErrorDTO), 406)] 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" }); // Try to find the image inside the DB Image? image = null; if (!string.IsNullOrEmpty(request.ImageUrl)) { image = await _db.Images.FirstOrDefaultAsync(i => i.Url == request.ImageUrl); // Failed? Just insert it yourself if (image == null) { // Simple (naive) sanity check for image URLs if ( !request.ImageUrl.StartsWith("http://") && !request.ImageUrl.StartsWith("https://") && !request.ImageUrl.StartsWith("/")) return StatusCode(406, new ErrorDTO { Status = "error", Error_msg = "Image URLs should point to http/https url or a local resource" }); 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 /// /// Draw a random quote /// /// A quote: id, quote content and author, imageUrl and categories if successful, otherwise: error message /// (Optional) category id to draw from /// Returned on valid request /// Returned when no quotes exist matching provided criteria /// Returned when no quotes exist (in the DB) [HttpGet("random")] [AllowAnonymous] [ProducesResponseType(typeof(QuoteShortDTO), 200)] [ProducesResponseType(204)] [ProducesResponseType(typeof(ErrorDTO), 404)] public async Task GetRandomQuote([FromQuery] int? category_id = null) { IQueryable query = _db.Quotes .Include(q => q.QuoteCategories!) .ThenInclude(qc => qc.Category) .Include(q => q.Image); if (category_id.HasValue) { query = query .Where(q => q.QuoteCategories! .Any(qc => qc.CategoryId == category_id.Value) ); } var totalQuotes = await query.CountAsync(); if (totalQuotes == 0) { if (category_id.HasValue) return NoContent(); // Brak cytatów w wybranej kategorii else 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 query .Skip(skip) .Take(1) .FirstOrDefaultAsync(); if (quote == null) return NotFound(new ErrorDTO { Status = "error", Error_msg = "Unknown error - couldn't get quote" }); // After getting and checking the quote, update the number of draws Statistic s = await _db.Statistics .FirstAsync(s => s.Label == "number_of_draws"); s.IValue += 1; 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! ///

/// Note: /// User-provided image URLs are validated by checking /// if they start with "https://", "http://" or "/". /// This is rather a naive solution. ///
/// Newly modified quote as a DTO /// Quote to be modified /// Updated quote form data. Id is ignored. /// Returned on valid request /// Returned when request text or author is empty (or whitespace) /// Returned when no such quote exists /// Returned when image url is invalid (does not start with "https://", "http://", or "/") [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) { // Simple (naive) sanity check for image URLs if ( !updatedQuote.ImageUrl.StartsWith("http://") && !updatedQuote.ImageUrl.StartsWith("https://") && !updatedQuote.ImageUrl.StartsWith("/")) return StatusCode(406, new ErrorDTO { Status = "error", Error_msg = "Image URLs should point to http/https url or a local resource" }); 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()); } // POST /api/v1/quotes/ai /// /// [AUTHED] Request a LLM-generated quote /// /// Generated quote's text /// /// Notes:
/// ///
    /// If customPrompt is passed: ///
  • The default prompt is overriden by whatever has been passed by the user.
  • ///

/// ///
    /// If model is passed: ///
  • The default large language model is overriden by whatever has been passed by the user.
  • ///

/// ///
    /// If temperature is passed: ///
  • The default temperature (= 0.8) is overriden by whatever has been passed by the user.
  • ///

/// ///
    /// If categoryId is passed: ///
  • The prompt is appended with an instruction in Polish to generate quotes based on the provided category /// (both name and description get passed to the model).
  • ///
  • Heads up! The text is appended even if customPrompt has been provided.
  • ///

/// ///
    /// If useSampleQuote is passed: ///
  • The prompt will be appended with a randomly chosen quote from the categoryId (if any exist), /// thus passing categoryId becomes a prerequisite.
  • ///
  • Heads up! The request will fail returning status code 400 if categoryId isn't provided!
  • ///
///
/// Form data containing required quote information /// Returned on valid request /// Returned when generation failed due to remote server error (likely because of a bad request) /// Returned when response has been generated, but couldn't be parsed (likely because of incompatible server or bad URL) [HttpPost("ai")] [Authorize] [EnableCors] [ProducesResponseType(200)] [ProducesResponseType(typeof(ErrorDTO), 400)] [ProducesResponseType(typeof(ErrorDTO), 500)] public async Task CreateLLMQuote([FromBody] AskLLMInDTO request) { JObject? generatedResponse = await guhf.GenerateLLMResponse( request.CustomPrompt, request.Model, request.Temperature, request.CategoryId, request.UseSampleQuote ); // Check if any errors occurred if (generatedResponse == null) { return StatusCode(400, new ErrorDTO { Status = "error", Error_msg = "Generation failed most likely due to bad request" }); } // Parse JSON to get the bot reply string? llmResponse = generatedResponse["choices"]?[0]?["message"]?["content"]?.ToString().Trim('"'); // If response string is not where we expect it, return 500 if (llmResponse == null) return StatusCode(500, new ErrorDTO { Status = "error", Error_msg = "Unexpected API response" }); // Otherwise, return the response return Ok(new { Status = "ok", BotResponse = llmResponse }); } }