From a558b3c1eccb33fe113aded930cf050c6007b5bf Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 7 Oct 2025 00:33:12 +1100 Subject: [PATCH 01/63] refactor: replace manual getters/setters with Lombok annotations --- .../smart_notes/entity/DocumentEntity.java | 69 +++---------------- .../be08/smart_notes/entity/NoteEntity.java | 34 +++------ .../be08/smart_notes/entity/UserEntity.java | 22 +++--- target/classes/META-INF/MANIFEST.MF | 2 +- 4 files changed, 28 insertions(+), 99 deletions(-) diff --git a/src/main/java/com/be08/smart_notes/entity/DocumentEntity.java b/src/main/java/com/be08/smart_notes/entity/DocumentEntity.java index 70c801f..26715d4 100644 --- a/src/main/java/com/be08/smart_notes/entity/DocumentEntity.java +++ b/src/main/java/com/be08/smart_notes/entity/DocumentEntity.java @@ -8,7 +8,15 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; - +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder @Entity @Table(name = "documents") public class DocumentEntity { @@ -30,63 +38,4 @@ public class DocumentEntity { @Column(nullable = true, name = "updated_at") private LocalDateTime updatedAt; - - public DocumentEntity() {} - - public DocumentEntity(Long id, Long userId, String title, String type, LocalDateTime createdAt) { - super(); - this.id = id; - this.userId = userId; - this.title = title; - this.type = type; - this.createdAt = createdAt; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Long getUserId() { - return userId; - } - - public void setUserId(Long userId) { - this.userId = userId; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getType() { - return type; - } - - public void setType(String type) { - this.type = type; - } - - public LocalDateTime getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(LocalDateTime createdAt) { - this.createdAt = createdAt; - } - - public LocalDateTime getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(LocalDateTime updatedAt) { - this.updatedAt = updatedAt; - } } diff --git a/src/main/java/com/be08/smart_notes/entity/NoteEntity.java b/src/main/java/com/be08/smart_notes/entity/NoteEntity.java index db3fd51..703dc62 100644 --- a/src/main/java/com/be08/smart_notes/entity/NoteEntity.java +++ b/src/main/java/com/be08/smart_notes/entity/NoteEntity.java @@ -4,7 +4,15 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; - +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder @Entity @Table(name = "notes") public class NoteEntity { @@ -13,28 +21,4 @@ public class NoteEntity { @Column(nullable = false) private String content; - - public NoteEntity() {} - - public NoteEntity(Long id, String content) { - super(); - this.id = id; - this.content = content; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getContent() { - return content; - } - - public void setContent(String content) { - this.content = content; - } } diff --git a/src/main/java/com/be08/smart_notes/entity/UserEntity.java b/src/main/java/com/be08/smart_notes/entity/UserEntity.java index 1dd7870..ad39ad8 100644 --- a/src/main/java/com/be08/smart_notes/entity/UserEntity.java +++ b/src/main/java/com/be08/smart_notes/entity/UserEntity.java @@ -8,7 +8,15 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; - +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder @Entity @Table(name = "users") public class UserEntity { @@ -33,16 +41,4 @@ public class UserEntity { @Column(nullable = true, name = "updated_at") private LocalDateTime updatedAt; - - public UserEntity() { - } - - public UserEntity(Long id, String email, String password, String name, LocalDateTime createdAt) { - super(); - this.id = id; - this.email = email; - this.password = password; - this.name = name; - this.createdAt = createdAt; - } } diff --git a/target/classes/META-INF/MANIFEST.MF b/target/classes/META-INF/MANIFEST.MF index 4184143..e1086ce 100644 --- a/target/classes/META-INF/MANIFEST.MF +++ b/target/classes/META-INF/MANIFEST.MF @@ -1,5 +1,5 @@ Manifest-Version: 1.0 -Build-Jdk-Spec: 23 +Build-Jdk-Spec: 21 Implementation-Title: smart-notes Implementation-Version: 0.0.1-SNAPSHOT Created-By: Maven Integration for Eclipse From 05e5772a57e7c083469c095e6cbfe6752670e758 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 14 Oct 2025 20:43:40 +1100 Subject: [PATCH 02/63] refactor(db): change id type to int, change table names from plural to singular --- README.md | 4 +- pom.xml | 8 + .../smart_notes/controller/AIController.java | 2 +- .../controller/NoteController.java | 11 +- .../smart_notes/entity/DocumentEntity.java | 6 +- .../be08/smart_notes/entity/NoteEntity.java | 4 +- .../be08/smart_notes/entity/UserEntity.java | 4 +- .../repository/DocumentRepository.java | 2 +- .../repository/NoteRepository.java | 2 +- .../repository/UserRepository.java | 2 +- .../be08/smart_notes/service/NoteService.java | 10 +- .../service/ai/QuizGenerationService.java | 2 +- .../resources/db/{dump.sql => dump-v1.sql} | 0 src/main/resources/db/dump-v2.sql | 366 ++++++++++++++++++ 14 files changed, 399 insertions(+), 24 deletions(-) rename src/main/resources/db/{dump.sql => dump-v1.sql} (100%) create mode 100644 src/main/resources/db/dump-v2.sql diff --git a/README.md b/README.md index 5e0f32b..52f80f6 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ Personal project for smart note taking application | Module | Method | API | Description | | -------- | -------- | -------- | ------- | | Document | GET | `/api/document/all` | List all available documents | -| Note | GET | `/api/note/{id}` | Get note content using its id | -| | POST | `/api/note/create` | Create new note | +| Note | GET | `/api/document/note/{id}` | Get note content using its id | +| | POST | `/api/document/note/create` | Create new note | | AI | GET | `/api/ai/generateQuiz/sample` | Get a sample quiz from sample response, this API does not require API TOKEN | | | GET | `/api/ai/generateQuiz/{noteId}` | Generate relevant quizzes based on 1 note, this feature **requires HuggingFace API TOKEN** to run | diff --git a/pom.xml b/pom.xml index d080d09..0804f45 100644 --- a/pom.xml +++ b/pom.xml @@ -71,6 +71,14 @@ lombok 1.18.30 + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.5 + diff --git a/src/main/java/com/be08/smart_notes/controller/AIController.java b/src/main/java/com/be08/smart_notes/controller/AIController.java index ec080e1..fa5c2f4 100644 --- a/src/main/java/com/be08/smart_notes/controller/AIController.java +++ b/src/main/java/com/be08/smart_notes/controller/AIController.java @@ -24,7 +24,7 @@ public ResponseEntity generateSampleQuiz() { } @GetMapping("/generateQuiz/{noteId}") - public ResponseEntity generateQuiz(@PathVariable Long noteId) { + public ResponseEntity generateQuiz(@PathVariable int noteId) { QuizResponse quizList = quizGenerationService.generateQuizFromNote(noteId); return ResponseEntity.status(HttpStatus.OK).body(quizList); } diff --git a/src/main/java/com/be08/smart_notes/controller/NoteController.java b/src/main/java/com/be08/smart_notes/controller/NoteController.java index 01d836a..1965990 100644 --- a/src/main/java/com/be08/smart_notes/controller/NoteController.java +++ b/src/main/java/com/be08/smart_notes/controller/NoteController.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -14,20 +15,20 @@ import com.be08.smart_notes.service.NoteService; @RestController -@RequestMapping("/api/note") +@RequestMapping("/api/document/note") public class NoteController { @Autowired private NoteService noteService; @GetMapping("/{id}") - public ResponseEntity getNote(@PathVariable Long id) { + public ResponseEntity getNote(@PathVariable int id) { NoteEntity note = noteService.getNote(id); return ResponseEntity.status(HttpStatus.OK).body(note); } - @PostMapping("/create") + @PutMapping("/update") public ResponseEntity createNote(@RequestBody NoteEntity note) { - NoteEntity createdNote = noteService.createNewNote(note); - return ResponseEntity.status(HttpStatus.CREATED).body(createdNote); + NoteEntity createdNote = noteService.createOrUpdateNote(note); + return ResponseEntity.status(HttpStatus.OK).body(createdNote); } } diff --git a/src/main/java/com/be08/smart_notes/entity/DocumentEntity.java b/src/main/java/com/be08/smart_notes/entity/DocumentEntity.java index 26715d4..b70bace 100644 --- a/src/main/java/com/be08/smart_notes/entity/DocumentEntity.java +++ b/src/main/java/com/be08/smart_notes/entity/DocumentEntity.java @@ -18,14 +18,14 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "documents") +@Table(name = "document") public class DocumentEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + private int id; @Column(nullable = false, name = "user_id") - private Long userId; + private int userId; @Column(nullable = false) private String title; diff --git a/src/main/java/com/be08/smart_notes/entity/NoteEntity.java b/src/main/java/com/be08/smart_notes/entity/NoteEntity.java index 703dc62..e3bd26f 100644 --- a/src/main/java/com/be08/smart_notes/entity/NoteEntity.java +++ b/src/main/java/com/be08/smart_notes/entity/NoteEntity.java @@ -14,10 +14,10 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "notes") +@Table(name = "note") public class NoteEntity { @Id - private Long id; + private int id; @Column(nullable = false) private String content; diff --git a/src/main/java/com/be08/smart_notes/entity/UserEntity.java b/src/main/java/com/be08/smart_notes/entity/UserEntity.java index ad39ad8..ab8fb7e 100644 --- a/src/main/java/com/be08/smart_notes/entity/UserEntity.java +++ b/src/main/java/com/be08/smart_notes/entity/UserEntity.java @@ -18,11 +18,11 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "users") +@Table(name = "user") public class UserEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + private int id; @Column(nullable = false, unique = true) private String email; diff --git a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java index 53c7849..568b8d7 100644 --- a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java @@ -4,6 +4,6 @@ import com.be08.smart_notes.entity.DocumentEntity; -public interface DocumentRepository extends JpaRepository{ +public interface DocumentRepository extends JpaRepository{ } diff --git a/src/main/java/com/be08/smart_notes/repository/NoteRepository.java b/src/main/java/com/be08/smart_notes/repository/NoteRepository.java index bc7343c..5a7eb2f 100644 --- a/src/main/java/com/be08/smart_notes/repository/NoteRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/NoteRepository.java @@ -6,6 +6,6 @@ import com.be08.smart_notes.entity.NoteEntity; @Repository -public interface NoteRepository extends JpaRepository{ +public interface NoteRepository extends JpaRepository{ } diff --git a/src/main/java/com/be08/smart_notes/repository/UserRepository.java b/src/main/java/com/be08/smart_notes/repository/UserRepository.java index 584ffdd..a01dbbf 100644 --- a/src/main/java/com/be08/smart_notes/repository/UserRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/UserRepository.java @@ -6,6 +6,6 @@ import com.be08.smart_notes.entity.UserEntity; @Repository -public interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository { } diff --git a/src/main/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index a508769..c097017 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -11,13 +11,13 @@ public class NoteService { @Autowired private NoteRepository noteRepository; - public NoteEntity getNote(long id) { - NoteEntity note = noteRepository.findById(id).orElse(null);; + public NoteEntity getNote(int id) { + NoteEntity note = noteRepository.findById(id).orElse(null); return note; } - public NoteEntity createNewNote(NoteEntity newNote) { - NoteEntity note = noteRepository.save(newNote); - return note; + public NoteEntity createOrUpdateNote(NoteEntity newNote) { + NoteEntity createdNote = noteRepository.save(newNote); + return createdNote; } } diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index 91de423..1f6580e 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -45,7 +45,7 @@ public QuizResponse generateSampleQuiz() { return quizResponse; } - public QuizResponse generateQuizFromNote(long noteId) { + public QuizResponse generateQuizFromNote(int noteId) { checkPermission(); NoteEntity selectedNote = noteService.getNote(noteId); diff --git a/src/main/resources/db/dump.sql b/src/main/resources/db/dump-v1.sql similarity index 100% rename from src/main/resources/db/dump.sql rename to src/main/resources/db/dump-v1.sql diff --git a/src/main/resources/db/dump-v2.sql b/src/main/resources/db/dump-v2.sql new file mode 100644 index 0000000..84a268a --- /dev/null +++ b/src/main/resources/db/dump-v2.sql @@ -0,0 +1,366 @@ +CREATE DATABASE IF NOT EXISTS `smartnotes` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci */ /*!80016 DEFAULT ENCRYPTION='N' */; +USE `smartnotes`; +-- MySQL dump 10.13 Distrib 8.0.43, for Win64 (x86_64) +-- +-- Host: localhost Database: smartnotes +-- ------------------------------------------------------ +-- Server version 8.0.43 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `attempt` +-- + +DROP TABLE IF EXISTS `attempt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `attempt` ( + `id` int NOT NULL AUTO_INCREMENT, + `quiz_id` int NOT NULL, + `attempt_at` datetime NOT NULL, + `total_question` int NOT NULL, + `score` int NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_attempts_quiz_id` (`quiz_id`), + CONSTRAINT `fk_attempts_quiz_id` FOREIGN KEY (`quiz_id`) REFERENCES `quiz` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `attempt` +-- + +LOCK TABLES `attempt` WRITE; +/*!40000 ALTER TABLE `attempt` DISABLE KEYS */; +/*!40000 ALTER TABLE `attempt` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `attempt_detail` +-- + +DROP TABLE IF EXISTS `attempt_detail`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `attempt_detail` ( + `id` int NOT NULL AUTO_INCREMENT, + `attempt_id` int NOT NULL, + `user_answer` char(1) NOT NULL, + `is_correct` tinyint(1) NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_attempt_details_attempt_id` (`attempt_id`), + CONSTRAINT `fk_attempt_details_attempt_id` FOREIGN KEY (`attempt_id`) REFERENCES `attempt` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `attempt_detail` +-- + +LOCK TABLES `attempt_detail` WRITE; +/*!40000 ALTER TABLE `attempt_detail` DISABLE KEYS */; +/*!40000 ALTER TABLE `attempt_detail` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `document` +-- + +DROP TABLE IF EXISTS `document`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `document` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `title` varchar(128) NOT NULL, + `type` enum('note','pdf') NOT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_documents_user_id` (`user_id`), + CONSTRAINT `fk_documents_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `document` +-- + +LOCK TABLES `document` WRITE; +/*!40000 ALTER TABLE `document` DISABLE KEYS */; +INSERT INTO `document` VALUES (1,1,'Time and Space Complexity','note','2025-10-13 18:00:00',NULL),(2,2,'Object-Oriented Programming Concepts','note','2025-10-14 22:12:25',NULL); +/*!40000 ALTER TABLE `document` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `document_tag` +-- + +DROP TABLE IF EXISTS `document_tag`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `document_tag` ( + `id` int NOT NULL AUTO_INCREMENT, + `document_id` int NOT NULL, + `tag_id` int DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_document_tags_document_id` (`document_id`), + KEY `fk_document_tags_tag_id` (`tag_id`), + CONSTRAINT `fk_document_tags_document_id` FOREIGN KEY (`document_id`) REFERENCES `document` (`id`), + CONSTRAINT `fk_document_tags_tag_id` FOREIGN KEY (`tag_id`) REFERENCES `tag` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `document_tag` +-- + +LOCK TABLES `document_tag` WRITE; +/*!40000 ALTER TABLE `document_tag` DISABLE KEYS */; +/*!40000 ALTER TABLE `document_tag` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `flashcard` +-- + +DROP TABLE IF EXISTS `flashcard`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flashcard` ( + `id` int NOT NULL AUTO_INCREMENT, + `flashcard_set_id` int NOT NULL, + `front_content` text NOT NULL, + `back_content` text NOT NULL, + `source_document_id` int NOT NULL, + `created_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_flashcards_flashcard_set_id` (`flashcard_set_id`), + KEY `fk_flashcards_source_document_id` (`source_document_id`), + CONSTRAINT `fk_flashcards_flashcard_set_id` FOREIGN KEY (`flashcard_set_id`) REFERENCES `flashcard_set` (`id`), + CONSTRAINT `fk_flashcards_source_document_id` FOREIGN KEY (`source_document_id`) REFERENCES `document` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `flashcard` +-- + +LOCK TABLES `flashcard` WRITE; +/*!40000 ALTER TABLE `flashcard` DISABLE KEYS */; +/*!40000 ALTER TABLE `flashcard` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `flashcard_set` +-- + +DROP TABLE IF EXISTS `flashcard_set`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flashcard_set` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `title` varchar(128) NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_flashcard_sets_user_id` (`user_id`), + CONSTRAINT `fk_flashcard_sets_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `flashcard_set` +-- + +LOCK TABLES `flashcard_set` WRITE; +/*!40000 ALTER TABLE `flashcard_set` DISABLE KEYS */; +/*!40000 ALTER TABLE `flashcard_set` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `note` +-- + +DROP TABLE IF EXISTS `note`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `note` ( + `id` int NOT NULL, + `content` text NOT NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_notes_id` FOREIGN KEY (`id`) REFERENCES `document` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `note` +-- + +LOCK TABLES `note` WRITE; +/*!40000 ALTER TABLE `note` DISABLE KEYS */; +INSERT INTO `note` VALUES (1,'# Time and Space Complexity - Sample study note generated by AI ## What is Time Complexity? Time complexity measures how the runtime of an algorithm grows as the input size increases. We use Big O notation to describe the worst-case scenario. It helps us compare algorithms and predict performance with large datasets. ## What is Space Complexity? Space complexity measures how much memory an algorithm uses relative to input size. This includes both the space for input data and any extra space the algorithm needs to work. ## Big O Notation Basics We focus on the dominant term and ignore constants. If an algorithm takes 5n² + 3n + 7 steps, we say it\'s O(n²) because n² grows much faster than n or constant terms when n gets large. ## Common Time Complexities **O(1) - Constant Time** Takes same time regardless of input size. Examples: accessing array element by index, basic math operations, hash table lookup in best case. **O(log n) - Logarithmic Time** Very efficient, grows slowly. Examples: binary search, finding element in balanced binary search tree. If you double input size, you only add one more step. **O(n) - Linear Time** Time grows directly with input size. Examples: linear search through array, printing all elements, finding maximum value in unsorted array. **O(n log n) - Linearithmic Time** Common in good sorting algorithms. Examples: merge sort, heap sort, quick sort average case. Much better than O(n²) but slower than O(n). **O(n²) - Quadratic Time** Time grows with square of input size. Examples: bubble sort, selection sort, nested loops checking all pairs. Gets slow quickly with large inputs. **O(2ⁿ) - Exponential Time** Extremely slow for large inputs. Examples: recursive fibonacci without memoization, trying all subsets of a set. Avoid if possible. ## Space Complexity Examples **O(1) Space** - Algorithm uses same amount of extra memory regardless of input size. Examples: swapping two variables, iterative algorithms that only use a few variables. **O(n) Space** - Memory usage grows with input size. Examples: creating copy of array, recursive algorithms (call stack), merge sort temporary arrays. **O(log n) Space** - Usually from recursion depth. Examples: binary search recursive implementation, balanced tree operations. ## Analyzing Algorithms For loops: if loop runs n times, that\'s O(n). Nested loops multiply complexities together. Recursion: look at how many times function calls itself and how much work each call does. Tree-like recursion can be exponential. ## Trade-offs Sometimes we can trade time for space or vice versa. Dynamic programming uses more memory to avoid recalculating same values. Hash tables use extra space to get faster lookups. ## Practical Tips - O(1) and O(log n) are excellent for any input size - O(n) and O(n log n) are good for most practical purposes - O(n²) starts getting slow around 10,000 elements - O(2ⁿ) is only practical for very small inputs (maybe 20-30 elements) ## Appendix **Common Mistakes:** Confusing best case with worst case. Hash tables are O(1) average but O(n) worst case. Always consider worst case for Big O analysis. **Important Questions:** How does choice of data structure affect complexity? When might we prefer a slower algorithm? What\'s the relationship between recursion depth and space complexity? **Examples to Remember:** Binary search: O(log n) time, O(1) space iterative or O(log n) space recursive Merge sort: O(n log n) time, O(n) space Bubble sort: O(n²) time, O(1) space Fibonacci recursive: O(2ⁿ) time, O(n) space'),(2,'# Object-Oriented Programming Concepts - Sample study note generated by AI ## What is Object-Oriented Programming? OOP is a programming approach that organizes code around objects rather than functions. An object contains both data (attributes) and methods (functions) that work with that data. Think of it like a blueprint for creating things. ## Four Main Principles - **Encapsulation**: Bundling data and methods together in a class. We hide internal details and only expose what\'s necessary. Like a car - you use the steering wheel and pedals, but don\'t need to know how the engine works internally. - **Inheritance**: Creating new classes based on existing ones. The new class gets all features of the parent class and can add its own. Example: Animal class has eat() method, Dog class inherits from Animal and adds bark() method. - **Polymorphism**: Same method name can behave differently in different classes. A draw() method works differently for Circle, Rectangle, and Triangle classes, but they all draw shapes. - **Abstraction**: Focusing on essential features while hiding complex implementation details. We know what a method does without caring about how it does it. ## Classes vs Objects Class is like a blueprint or template. Object is an actual instance created from that class. One Person class can create many person objects like john, mary, alex. ## Benefits of OOP - Code reusability through inheritance - Easier to maintain and modify - Better organization of complex programs - Matches real-world thinking patterns - Team development becomes easier ## Common Mistakes to Avoid - Making everything public instead of using proper access modifiers - Creating classes that try to do too many things - Not using inheritance when it would be helpful - Overcomplicating simple problems with unnecessary objects ## Key Terms - **Constructor** - special method that runs when object is created - **Method overriding** - child class changes parent\'s method behavior - **Interface** - contract that classes must follow - **Static methods** - belong to class, not individual objects ## Example Structure ```java class Student { private String name; private int age; public Student(String name, int age) { this.name = name; this.age = age; } public void study() { System.out.println(name + \" is studying\"); } } ```'); +/*!40000 ALTER TABLE `note` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `pdf` +-- + +DROP TABLE IF EXISTS `pdf`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `pdf` ( + `id` int NOT NULL, + `file_url` tinytext NOT NULL, + `size` int NOT NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_pdfs_id` FOREIGN KEY (`id`) REFERENCES `document` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `pdf` +-- + +LOCK TABLES `pdf` WRITE; +/*!40000 ALTER TABLE `pdf` DISABLE KEYS */; +/*!40000 ALTER TABLE `pdf` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `question` +-- + +DROP TABLE IF EXISTS `question`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `question` ( + `id` int NOT NULL AUTO_INCREMENT, + `quiz_id` int NOT NULL, + `question_text` text NOT NULL, + `option_a` text NOT NULL, + `option_b` text NOT NULL, + `option_c` text NOT NULL, + `option_d` text NOT NULL, + `correct_answer` char(1) NOT NULL, + `source_document_id` int NOT NULL, + `created_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_questions_quiz_id` (`quiz_id`), + KEY `fk_questions_source_document_id` (`source_document_id`), + CONSTRAINT `fk_questions_quiz_id` FOREIGN KEY (`quiz_id`) REFERENCES `quiz` (`id`), + CONSTRAINT `fk_questions_source_document_id` FOREIGN KEY (`source_document_id`) REFERENCES `document` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `question` +-- + +LOCK TABLES `question` WRITE; +/*!40000 ALTER TABLE `question` DISABLE KEYS */; +/*!40000 ALTER TABLE `question` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `quiz` +-- + +DROP TABLE IF EXISTS `quiz`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `quiz` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `title` varchar(128) NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_quizzes_user_id` (`user_id`), + CONSTRAINT `fk_quizzes_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `quiz` +-- + +LOCK TABLES `quiz` WRITE; +/*!40000 ALTER TABLE `quiz` DISABLE KEYS */; +/*!40000 ALTER TABLE `quiz` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `tag` +-- + +DROP TABLE IF EXISTS `tag`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `tag` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `name` varchar(50) NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_tags_user_id` (`user_id`), + CONSTRAINT `fk_tags_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `tag` +-- + +LOCK TABLES `tag` WRITE; +/*!40000 ALTER TABLE `tag` DISABLE KEYS */; +/*!40000 ALTER TABLE `tag` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `user` +-- + +DROP TABLE IF EXISTS `user`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user` ( + `id` int NOT NULL AUTO_INCREMENT, + `email` varchar(50) NOT NULL, + `password` varchar(50) NOT NULL, + `name` varchar(50) NOT NULL, + `avatar_url` tinytext, + `created_at` datetime NOT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`) +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `user` +-- + +LOCK TABLES `user` WRITE; +/*!40000 ALTER TABLE `user` DISABLE KEYS */; +INSERT INTO `user` VALUES (1,'john@gmail.com','john','John',NULL,'2025-10-13 18:00:00',NULL),(2,'mary@gmail.com','mary','Mary',NULL,'2025-10-22 18:00:00',NULL); +/*!40000 ALTER TABLE `user` ENABLE KEYS */; +UNLOCK TABLES; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2025-10-14 20:25:03 From 7eedaa265229b30ac35ffb3b9d5830d9cf4425cc Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Fri, 17 Oct 2025 20:16:18 +1100 Subject: [PATCH 03/63] refactor: standardize model filename format --- .../controller/DocumentController.java | 4 +- .../controller/NoteController.java | 8 +-- .../dto/{Quiz.java => QuizQuestion.java} | 6 +-- .../be08/smart_notes/dto/ai/QuizResponse.java | 4 +- .../Document.java} | 4 +- .../NoteEntity.java => model/Note.java} | 4 +- .../com/be08/smart_notes/model/Question.java | 53 +++++++++++++++++++ .../java/com/be08/smart_notes/model/Quiz.java | 30 +++++++++++ .../UserEntity.java => model/User.java} | 4 +- .../repository/DocumentRepository.java | 4 +- .../repository/NoteRepository.java | 4 +- .../repository/QuizRepository.java | 8 +++ .../repository/UserRepository.java | 4 +- .../smart_notes/service/DocumentService.java | 21 ++++++-- .../be08/smart_notes/service/NoteService.java | 10 ++-- .../service/ai/QuizGenerationService.java | 4 +- 16 files changed, 139 insertions(+), 33 deletions(-) rename src/main/java/com/be08/smart_notes/dto/{Quiz.java => QuizQuestion.java} (69%) rename src/main/java/com/be08/smart_notes/{entity/DocumentEntity.java => model/Document.java} (92%) rename src/main/java/com/be08/smart_notes/{entity/NoteEntity.java => model/Note.java} (86%) create mode 100644 src/main/java/com/be08/smart_notes/model/Question.java create mode 100644 src/main/java/com/be08/smart_notes/model/Quiz.java rename src/main/java/com/be08/smart_notes/{entity/UserEntity.java => model/User.java} (93%) create mode 100644 src/main/java/com/be08/smart_notes/repository/QuizRepository.java diff --git a/src/main/java/com/be08/smart_notes/controller/DocumentController.java b/src/main/java/com/be08/smart_notes/controller/DocumentController.java index af0e004..3bc20b4 100644 --- a/src/main/java/com/be08/smart_notes/controller/DocumentController.java +++ b/src/main/java/com/be08/smart_notes/controller/DocumentController.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.be08.smart_notes.entity.DocumentEntity; +import com.be08.smart_notes.model.Document; import com.be08.smart_notes.service.DocumentService; @RestController @@ -20,7 +20,7 @@ public class DocumentController { @GetMapping("/all") public ResponseEntity getAllDocuments() { - List documentList = documentService.getAllDocuments(); + List documentList = documentService.getAllDocuments(); return ResponseEntity.status(HttpStatus.OK).body(documentList); } } diff --git a/src/main/java/com/be08/smart_notes/controller/NoteController.java b/src/main/java/com/be08/smart_notes/controller/NoteController.java index 1965990..1e882d5 100644 --- a/src/main/java/com/be08/smart_notes/controller/NoteController.java +++ b/src/main/java/com/be08/smart_notes/controller/NoteController.java @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.be08.smart_notes.entity.NoteEntity; +import com.be08.smart_notes.model.Note; import com.be08.smart_notes.service.NoteService; @RestController @@ -22,13 +22,13 @@ public class NoteController { @GetMapping("/{id}") public ResponseEntity getNote(@PathVariable int id) { - NoteEntity note = noteService.getNote(id); + Note note = noteService.getNote(id); return ResponseEntity.status(HttpStatus.OK).body(note); } @PutMapping("/update") - public ResponseEntity createNote(@RequestBody NoteEntity note) { - NoteEntity createdNote = noteService.createOrUpdateNote(note); + public ResponseEntity createNote(@RequestBody Note note) { + Note createdNote = noteService.createOrUpdateNote(note); return ResponseEntity.status(HttpStatus.OK).body(createdNote); } } diff --git a/src/main/java/com/be08/smart_notes/dto/Quiz.java b/src/main/java/com/be08/smart_notes/dto/QuizQuestion.java similarity index 69% rename from src/main/java/com/be08/smart_notes/dto/Quiz.java rename to src/main/java/com/be08/smart_notes/dto/QuizQuestion.java index fd8afc5..675ee51 100644 --- a/src/main/java/com/be08/smart_notes/dto/Quiz.java +++ b/src/main/java/com/be08/smart_notes/dto/QuizQuestion.java @@ -1,17 +1,17 @@ package com.be08.smart_notes.dto; -public class Quiz { +public class QuizQuestion { public String question; public String[] options; public int correctIndex; - public Quiz() { + public QuizQuestion() { this.question = ""; this.options = new String[4]; this.correctIndex = -1; } - public Quiz(String question, String[] options, int correctIndex) { + public QuizQuestion(String question, String[] options, int correctIndex) { this.question = question; this.options = options; this.correctIndex = correctIndex; diff --git a/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java b/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java index c1ee9c5..5b8b427 100644 --- a/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java @@ -2,9 +2,9 @@ import java.util.ArrayList; -import com.be08.smart_notes.dto.Quiz; +import com.be08.smart_notes.dto.QuizQuestion; public class QuizResponse { public String topic; - public ArrayList quizzes; + public ArrayList quizzes; } diff --git a/src/main/java/com/be08/smart_notes/entity/DocumentEntity.java b/src/main/java/com/be08/smart_notes/model/Document.java similarity index 92% rename from src/main/java/com/be08/smart_notes/entity/DocumentEntity.java rename to src/main/java/com/be08/smart_notes/model/Document.java index b70bace..a521549 100644 --- a/src/main/java/com/be08/smart_notes/entity/DocumentEntity.java +++ b/src/main/java/com/be08/smart_notes/model/Document.java @@ -1,4 +1,4 @@ -package com.be08.smart_notes.entity; +package com.be08.smart_notes.model; import java.time.LocalDateTime; @@ -19,7 +19,7 @@ @Builder @Entity @Table(name = "document") -public class DocumentEntity { +public class Document { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private int id; diff --git a/src/main/java/com/be08/smart_notes/entity/NoteEntity.java b/src/main/java/com/be08/smart_notes/model/Note.java similarity index 86% rename from src/main/java/com/be08/smart_notes/entity/NoteEntity.java rename to src/main/java/com/be08/smart_notes/model/Note.java index e3bd26f..415eefc 100644 --- a/src/main/java/com/be08/smart_notes/entity/NoteEntity.java +++ b/src/main/java/com/be08/smart_notes/model/Note.java @@ -1,4 +1,4 @@ -package com.be08.smart_notes.entity; +package com.be08.smart_notes.model; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -15,7 +15,7 @@ @Builder @Entity @Table(name = "note") -public class NoteEntity { +public class Note { @Id private int id; diff --git a/src/main/java/com/be08/smart_notes/model/Question.java b/src/main/java/com/be08/smart_notes/model/Question.java new file mode 100644 index 0000000..d331e67 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/model/Question.java @@ -0,0 +1,53 @@ +package com.be08.smart_notes.model; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table(name = "question") +public class Question { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @Column(nullable = false, name = "quiz_id") + private int quizId; + + @Column(nullable = false, name = "question_text") + private String questionText; + + @Column(nullable = false, name = "option_a") + private String optionA; + + @Column(nullable = false, name = "option_b") + private String optionB; + + @Column(nullable = false, name = "option_c") + private String optionC; + + @Column(nullable = false, name = "option_d") + private String optionD; + + @Column(nullable = false, name = "correct_answer") + private char correctAnswer; + + @Column(nullable = false, name = "source_document_id") + private int sourceDocumentId; + + @Column(nullable = false, name = "created_at") + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/be08/smart_notes/model/Quiz.java b/src/main/java/com/be08/smart_notes/model/Quiz.java new file mode 100644 index 0000000..bd4d7a4 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/model/Quiz.java @@ -0,0 +1,30 @@ +package com.be08.smart_notes.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table(name = "quiz") +public class Quiz { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @Column(nullable = false, name = "user_id") + private int userId; + + @Column(nullable = false) + private String title; +} diff --git a/src/main/java/com/be08/smart_notes/entity/UserEntity.java b/src/main/java/com/be08/smart_notes/model/User.java similarity index 93% rename from src/main/java/com/be08/smart_notes/entity/UserEntity.java rename to src/main/java/com/be08/smart_notes/model/User.java index ab8fb7e..d1b14d6 100644 --- a/src/main/java/com/be08/smart_notes/entity/UserEntity.java +++ b/src/main/java/com/be08/smart_notes/model/User.java @@ -1,4 +1,4 @@ -package com.be08.smart_notes.entity; +package com.be08.smart_notes.model; import java.time.LocalDateTime; @@ -19,7 +19,7 @@ @Builder @Entity @Table(name = "user") -public class UserEntity { +public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private int id; diff --git a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java index 568b8d7..b094750 100644 --- a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java @@ -2,8 +2,8 @@ import org.springframework.data.jpa.repository.JpaRepository; -import com.be08.smart_notes.entity.DocumentEntity; +import com.be08.smart_notes.model.Document; -public interface DocumentRepository extends JpaRepository{ +public interface DocumentRepository extends JpaRepository{ } diff --git a/src/main/java/com/be08/smart_notes/repository/NoteRepository.java b/src/main/java/com/be08/smart_notes/repository/NoteRepository.java index 5a7eb2f..0edc535 100644 --- a/src/main/java/com/be08/smart_notes/repository/NoteRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/NoteRepository.java @@ -3,9 +3,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import com.be08.smart_notes.entity.NoteEntity; +import com.be08.smart_notes.model.Note; @Repository -public interface NoteRepository extends JpaRepository{ +public interface NoteRepository extends JpaRepository{ } diff --git a/src/main/java/com/be08/smart_notes/repository/QuizRepository.java b/src/main/java/com/be08/smart_notes/repository/QuizRepository.java new file mode 100644 index 0000000..774d9bc --- /dev/null +++ b/src/main/java/com/be08/smart_notes/repository/QuizRepository.java @@ -0,0 +1,8 @@ +package com.be08.smart_notes.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.be08.smart_notes.model.Quiz; + +public interface QuizRepository extends JpaRepository { +} diff --git a/src/main/java/com/be08/smart_notes/repository/UserRepository.java b/src/main/java/com/be08/smart_notes/repository/UserRepository.java index a01dbbf..f8ed5fe 100644 --- a/src/main/java/com/be08/smart_notes/repository/UserRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/UserRepository.java @@ -3,9 +3,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import com.be08.smart_notes.entity.UserEntity; +import com.be08.smart_notes.model.User; @Repository -public interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository { } diff --git a/src/main/java/com/be08/smart_notes/service/DocumentService.java b/src/main/java/com/be08/smart_notes/service/DocumentService.java index 1f57c81..4af4856 100644 --- a/src/main/java/com/be08/smart_notes/service/DocumentService.java +++ b/src/main/java/com/be08/smart_notes/service/DocumentService.java @@ -5,16 +5,31 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import com.be08.smart_notes.entity.DocumentEntity; +import com.be08.smart_notes.model.Document; +import com.be08.smart_notes.model.Note; import com.be08.smart_notes.repository.DocumentRepository; @Service public class DocumentService { @Autowired private DocumentRepository documentRepository; + @Autowired + private NoteService noteService; - public List getAllDocuments() { - List documentList = documentRepository.findAll(); + public List getAllDocuments() { + List documentList = documentRepository.findAll(); return documentList; } + + public Document createDocument(Document newDocument) { + Document createdDocument = documentRepository.save(newDocument); + + Note newNote = Note.builder() + .id(createdDocument.getId()) + .content("") + .build(); + noteService.createOrUpdateNote(newNote); + + return createdDocument; + } } diff --git a/src/main/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index c097017..6cc2abc 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -3,7 +3,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import com.be08.smart_notes.entity.NoteEntity; +import com.be08.smart_notes.model.Note; import com.be08.smart_notes.repository.NoteRepository; @Service @@ -11,13 +11,13 @@ public class NoteService { @Autowired private NoteRepository noteRepository; - public NoteEntity getNote(int id) { - NoteEntity note = noteRepository.findById(id).orElse(null); + public Note getNote(int id) { + Note note = noteRepository.findById(id).orElse(null); return note; } - public NoteEntity createOrUpdateNote(NoteEntity newNote) { - NoteEntity createdNote = noteRepository.save(newNote); + public Note createOrUpdateNote(Note newNote) { + Note createdNote = noteRepository.save(newNote); return createdNote; } } diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index 1f6580e..6b5983b 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -10,13 +10,13 @@ import com.google.gson.Gson; import com.be08.smart_notes.common.AppConstants; -import com.be08.smart_notes.entity.NoteEntity; import com.be08.smart_notes.service.NoteService; import com.be08.smart_notes.dto.ai.GuidedInferenceRequest; import com.be08.smart_notes.dto.ai.InferenceRequest; import com.be08.smart_notes.dto.ai.InferenceRequestMessage; import com.be08.smart_notes.dto.ai.InferenceResponse; import com.be08.smart_notes.dto.ai.QuizResponse; +import com.be08.smart_notes.model.Note; @Service public class QuizGenerationService extends AIService { @@ -48,7 +48,7 @@ public QuizResponse generateSampleQuiz() { public QuizResponse generateQuizFromNote(int noteId) { checkPermission(); - NoteEntity selectedNote = noteService.getNote(noteId); + Note selectedNote = noteService.getNote(noteId); if (selectedNote == null) return null; // Prepare JSON Body From 77eba0d2c5ed4b8ac228dcb86f08f45e638810a1 Mon Sep 17 00:00:00 2001 From: Phu Vo Date: Tue, 21 Oct 2025 23:03:13 +1000 Subject: [PATCH 04/63] :hammer: feat: Initialize GlobalExceptionHander with ApiResponse, ErrorCode, and AppException --- .../smart_notes/dto/response/ApiResponse.java | 18 +++++++++ .../smart_notes/exception/AppException.java | 18 +++++++++ .../be08/smart_notes/exception/ErrorCode.java | 20 ++++++++++ .../exception/GlobalExceptionHandler.java | 40 +++++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 src/main/java/com/be08/smart_notes/dto/response/ApiResponse.java create mode 100644 src/main/java/com/be08/smart_notes/exception/AppException.java create mode 100644 src/main/java/com/be08/smart_notes/exception/ErrorCode.java create mode 100644 src/main/java/com/be08/smart_notes/exception/GlobalExceptionHandler.java diff --git a/src/main/java/com/be08/smart_notes/dto/response/ApiResponse.java b/src/main/java/com/be08/smart_notes/dto/response/ApiResponse.java new file mode 100644 index 0000000..5bfaf07 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/response/ApiResponse.java @@ -0,0 +1,18 @@ +package com.be08.smart_notes.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.*; +import lombok.experimental.FieldDefaults; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiResponse { + @Builder.Default + int code = 1000; // Default success code + String message; + T data; +} diff --git a/src/main/java/com/be08/smart_notes/exception/AppException.java b/src/main/java/com/be08/smart_notes/exception/AppException.java new file mode 100644 index 0000000..c2792b7 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/exception/AppException.java @@ -0,0 +1,18 @@ +package com.be08.smart_notes.exception; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.FieldDefaults; + +@Setter +@Getter +@FieldDefaults(level = AccessLevel.PRIVATE) +public class AppException extends RuntimeException{ + ErrorCode errorCode; + + public AppException(ErrorCode errorCode){ + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java new file mode 100644 index 0000000..1d81a92 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -0,0 +1,20 @@ +package com.be08.smart_notes.exception; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.FieldDefaults; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; + +@Getter +@AllArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) +public enum ErrorCode { + UNCATEGORIZED(9999, "Uncategorized error", HttpStatus.INTERNAL_SERVER_ERROR) + ; + + int code; + String message; + HttpStatusCode statusCode; +} diff --git a/src/main/java/com/be08/smart_notes/exception/GlobalExceptionHandler.java b/src/main/java/com/be08/smart_notes/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..4fab701 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/exception/GlobalExceptionHandler.java @@ -0,0 +1,40 @@ +package com.be08.smart_notes.exception; + +import com.be08.smart_notes.dto.response.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + @ExceptionHandler(value = Exception.class) + ResponseEntity handlingRuntimeException(RuntimeException exception){ + log.error("Exception: ", exception); + + int code = ErrorCode.UNCATEGORIZED.getCode(); + String message = ErrorCode.UNCATEGORIZED.getMessage(); + + ApiResponse apiResponse = ApiResponse.builder() + .code(code) + .message(message) + .build(); + return ResponseEntity.badRequest().body(apiResponse); + } + + @ExceptionHandler(value = AppException.class) + ResponseEntity handlingAppException(AppException exception){ + log.error("AppException: ", exception); + + ErrorCode errorCode = exception.getErrorCode(); + int code = errorCode.getCode(); + String message = errorCode.getMessage(); + + ApiResponse apiResponse = ApiResponse.builder() + .code(code) + .message(message) + .build(); + return ResponseEntity.status(errorCode.getStatusCode()).body(apiResponse); + } +} From 0d005cf6455609cf2b9a78e89016c5ec8dc232c0 Mon Sep 17 00:00:00 2001 From: Phu Vo Date: Wed, 22 Oct 2025 00:08:09 +1000 Subject: [PATCH 05/63] :hammer: feat: Update ErrorCode for the register feature in case user has already existed --- .../controller/AuthenticationController.java | 29 ++++++++++++++ .../dto/request/UserCreationRequest.java | 15 +++++++ .../be08/smart_notes/exception/ErrorCode.java | 3 +- .../repository/UserRepository.java | 2 +- .../be08/smart_notes/service/UserService.java | 40 +++++++++++++++++++ 5 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/controller/AuthenticationController.java create mode 100644 src/main/java/com/be08/smart_notes/dto/request/UserCreationRequest.java create mode 100644 src/main/java/com/be08/smart_notes/service/UserService.java diff --git a/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java b/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java new file mode 100644 index 0000000..91223a0 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java @@ -0,0 +1,29 @@ +package com.be08.smart_notes.controller; + +import com.be08.smart_notes.dto.request.UserCreationRequest; +import com.be08.smart_notes.dto.response.ApiResponse; +import com.be08.smart_notes.model.User; +import com.be08.smart_notes.service.UserService; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class AuthenticationController { + UserService userService; + + @PostMapping("/register") + ApiResponse register(@RequestBody UserCreationRequest request){ + var user = userService.createUser(request); + return ApiResponse.builder() + .data(user) + .build(); + } +} diff --git a/src/main/java/com/be08/smart_notes/dto/request/UserCreationRequest.java b/src/main/java/com/be08/smart_notes/dto/request/UserCreationRequest.java new file mode 100644 index 0000000..14bb57f --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/request/UserCreationRequest.java @@ -0,0 +1,15 @@ +package com.be08.smart_notes.dto.request; + +import lombok.*; +import lombok.experimental.FieldDefaults; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class UserCreationRequest { + String name; + String email; + String password; +} diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java index 1d81a92..7b3f202 100644 --- a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -11,7 +11,8 @@ @AllArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE) public enum ErrorCode { - UNCATEGORIZED(9999, "Uncategorized error", HttpStatus.INTERNAL_SERVER_ERROR) + UNCATEGORIZED(9999, "Uncategorized error", HttpStatus.INTERNAL_SERVER_ERROR), + USER_EXISTS(1001, "User already exists", HttpStatus.BAD_REQUEST) ; int code; diff --git a/src/main/java/com/be08/smart_notes/repository/UserRepository.java b/src/main/java/com/be08/smart_notes/repository/UserRepository.java index f8ed5fe..084be66 100644 --- a/src/main/java/com/be08/smart_notes/repository/UserRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/UserRepository.java @@ -7,5 +7,5 @@ @Repository public interface UserRepository extends JpaRepository { - + boolean existsByEmail(String email); } diff --git a/src/main/java/com/be08/smart_notes/service/UserService.java b/src/main/java/com/be08/smart_notes/service/UserService.java new file mode 100644 index 0000000..8776154 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/UserService.java @@ -0,0 +1,40 @@ +package com.be08.smart_notes.service; + +import com.be08.smart_notes.dto.request.UserCreationRequest; +import com.be08.smart_notes.exception.AppException; +import com.be08.smart_notes.exception.ErrorCode; +import com.be08.smart_notes.model.User; +import com.be08.smart_notes.repository.UserRepository; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@Slf4j +public class UserService { + UserRepository userRepository; + + public User createUser(UserCreationRequest request){ + String email = request.getEmail(); + + if(userRepository.existsByEmail(email)){ + log.error("User with email {} already exists", email); + throw new AppException(ErrorCode.USER_EXISTS); + } + + String name = request.getName(); + String password = request.getPassword(); + + User user = User.builder() + .name(name) + .email(email) + .password(password) + .build(); + + return userRepository.save(user); + } +} From a484d218fafa1ba5eb30489dd6a58fb6078cc04a Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 22 Oct 2025 22:49:45 +1100 Subject: [PATCH 06/63] feat(note): add note creation and deletion --- .../controller/DocumentController.java | 10 +++++- .../controller/NoteController.java | 16 ++++++--- .../dto/request/NoteCreationRequest.java | 19 +++++++++++ .../be08/smart_notes/enums/DocumentType.java | 6 ++++ .../com/be08/smart_notes/model/Document.java | 17 ++++++++-- .../java/com/be08/smart_notes/model/Note.java | 13 +++++++ .../repository/DocumentRepository.java | 7 ++-- .../repository/NoteRepository.java | 1 - .../smart_notes/service/DocumentService.java | 20 +++++------ .../be08/smart_notes/service/NoteService.java | 34 +++++++++++++++++-- 10 files changed, 120 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/dto/request/NoteCreationRequest.java create mode 100644 src/main/java/com/be08/smart_notes/enums/DocumentType.java diff --git a/src/main/java/com/be08/smart_notes/controller/DocumentController.java b/src/main/java/com/be08/smart_notes/controller/DocumentController.java index 3bc20b4..94686f5 100644 --- a/src/main/java/com/be08/smart_notes/controller/DocumentController.java +++ b/src/main/java/com/be08/smart_notes/controller/DocumentController.java @@ -5,7 +5,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -18,9 +20,15 @@ public class DocumentController { @Autowired private DocumentService documentService; - @GetMapping("/all") + @GetMapping public ResponseEntity getAllDocuments() { List documentList = documentService.getAllDocuments(); return ResponseEntity.status(HttpStatus.OK).body(documentList); } + + @DeleteMapping("/{id}") + public ResponseEntity deleteNote(@PathVariable int id) { + documentService.deleteDocument(id); + return ResponseEntity.status(HttpStatus.OK).body("OK"); + } } diff --git a/src/main/java/com/be08/smart_notes/controller/NoteController.java b/src/main/java/com/be08/smart_notes/controller/NoteController.java index 1e882d5..ad69dc4 100644 --- a/src/main/java/com/be08/smart_notes/controller/NoteController.java +++ b/src/main/java/com/be08/smart_notes/controller/NoteController.java @@ -3,6 +3,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.be08.smart_notes.dto.request.NoteCreationRequest; import com.be08.smart_notes.model.Note; import com.be08.smart_notes.service.NoteService; @@ -20,15 +22,21 @@ public class NoteController { @Autowired private NoteService noteService; + @PostMapping + public ResponseEntity createNote(@RequestBody NoteCreationRequest noteCreationRequest) { + Note createdNote = noteService.createNote(noteCreationRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(createdNote); + } + @GetMapping("/{id}") public ResponseEntity getNote(@PathVariable int id) { Note note = noteService.getNote(id); return ResponseEntity.status(HttpStatus.OK).body(note); } - @PutMapping("/update") - public ResponseEntity createNote(@RequestBody Note note) { - Note createdNote = noteService.createOrUpdateNote(note); - return ResponseEntity.status(HttpStatus.OK).body(createdNote); + @DeleteMapping("/{id}") + public ResponseEntity deleteNote(@PathVariable int id) { + noteService.deleteNote(id); + return ResponseEntity.status(HttpStatus.OK).body("OK"); } } diff --git a/src/main/java/com/be08/smart_notes/dto/request/NoteCreationRequest.java b/src/main/java/com/be08/smart_notes/dto/request/NoteCreationRequest.java new file mode 100644 index 0000000..f37132b --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/request/NoteCreationRequest.java @@ -0,0 +1,19 @@ +package com.be08.smart_notes.dto.request; + +import com.be08.smart_notes.enums.DocumentType; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NoteCreationRequest { + private int userId; + private DocumentType type; + private String title; + private String content; +} diff --git a/src/main/java/com/be08/smart_notes/enums/DocumentType.java b/src/main/java/com/be08/smart_notes/enums/DocumentType.java new file mode 100644 index 0000000..5e23dc0 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/enums/DocumentType.java @@ -0,0 +1,6 @@ +package com.be08.smart_notes.enums; + +public enum DocumentType { + NOTE, + PDF +} diff --git a/src/main/java/com/be08/smart_notes/model/Document.java b/src/main/java/com/be08/smart_notes/model/Document.java index a521549..9ed7924 100644 --- a/src/main/java/com/be08/smart_notes/model/Document.java +++ b/src/main/java/com/be08/smart_notes/model/Document.java @@ -2,11 +2,18 @@ import java.time.LocalDateTime; +import com.be08.smart_notes.enums.DocumentType; +import com.fasterxml.jackson.annotation.JsonIgnore; + +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; @@ -21,7 +28,7 @@ @Table(name = "document") public class Document { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @GeneratedValue(strategy = GenerationType.IDENTITY) private int id; @Column(nullable = false, name = "user_id") @@ -31,11 +38,17 @@ public class Document { private String title; @Column(nullable = false) - private String type; + @Enumerated(EnumType.STRING) + private DocumentType type; @Column(nullable = false, name = "created_at") private LocalDateTime createdAt; @Column(nullable = true, name = "updated_at") private LocalDateTime updatedAt; + + // Relationships + @JsonIgnore + @OneToOne(mappedBy = "document", cascade = CascadeType.ALL) + private Note note; } diff --git a/src/main/java/com/be08/smart_notes/model/Note.java b/src/main/java/com/be08/smart_notes/model/Note.java index 415eefc..8c00213 100644 --- a/src/main/java/com/be08/smart_notes/model/Note.java +++ b/src/main/java/com/be08/smart_notes/model/Note.java @@ -1,8 +1,13 @@ package com.be08.smart_notes.model; +import com.fasterxml.jackson.annotation.JsonIgnore; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; @@ -21,4 +26,12 @@ public class Note { @Column(nullable = false) private String content; + + // Relationship + // Use document.id as this entity's id + @JsonIgnore + @OneToOne + @MapsId + @JoinColumn(name = "id") + private Document document; } diff --git a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java index b094750..68770c3 100644 --- a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java @@ -1,9 +1,12 @@ package com.be08.smart_notes.repository; +import java.time.LocalDateTime; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.be08.smart_notes.model.Document; -public interface DocumentRepository extends JpaRepository{ - +public interface DocumentRepository extends JpaRepository { } diff --git a/src/main/java/com/be08/smart_notes/repository/NoteRepository.java b/src/main/java/com/be08/smart_notes/repository/NoteRepository.java index 0edc535..372a2db 100644 --- a/src/main/java/com/be08/smart_notes/repository/NoteRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/NoteRepository.java @@ -7,5 +7,4 @@ @Repository public interface NoteRepository extends JpaRepository{ - } diff --git a/src/main/java/com/be08/smart_notes/service/DocumentService.java b/src/main/java/com/be08/smart_notes/service/DocumentService.java index 4af4856..898aff3 100644 --- a/src/main/java/com/be08/smart_notes/service/DocumentService.java +++ b/src/main/java/com/be08/smart_notes/service/DocumentService.java @@ -1,20 +1,18 @@ package com.be08.smart_notes.service; +import java.time.LocalDateTime; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.be08.smart_notes.model.Document; -import com.be08.smart_notes.model.Note; import com.be08.smart_notes.repository.DocumentRepository; @Service public class DocumentService { @Autowired private DocumentRepository documentRepository; - @Autowired - private NoteService noteService; public List getAllDocuments() { List documentList = documentRepository.findAll(); @@ -23,13 +21,15 @@ public List getAllDocuments() { public Document createDocument(Document newDocument) { Document createdDocument = documentRepository.save(newDocument); - - Note newNote = Note.builder() - .id(createdDocument.getId()) - .content("") - .build(); - noteService.createOrUpdateNote(newNote); - return createdDocument; } + + public Document updateDocumentTitle(int id, String title) { + Document updatedDocument = documentRepository.updateTitle(id, title, LocalDateTime.now()); + return updatedDocument; + } + + public void deleteDocument(int id) { + documentRepository.deleteById(id); + } } diff --git a/src/main/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index 6cc2abc..0738c15 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -1,8 +1,14 @@ package com.be08.smart_notes.service; +import java.time.LocalDateTime; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.be08.smart_notes.dto.request.NoteCreationRequest; +import com.be08.smart_notes.enums.DocumentType; +import com.be08.smart_notes.model.Document; import com.be08.smart_notes.model.Note; import com.be08.smart_notes.repository.NoteRepository; @@ -10,14 +16,36 @@ public class NoteService { @Autowired private NoteRepository noteRepository; + @Autowired + private DocumentService documentService; public Note getNote(int id) { Note note = noteRepository.findById(id).orElse(null); return note; } + + @Transactional(rollbackFor = Exception.class) + public Note createNote(NoteCreationRequest noteCreationRequest) { + Document newDocument = Document.builder() + .userId(noteCreationRequest.getUserId()) + .title(noteCreationRequest.getTitle()) + .type(DocumentType.NOTE) + .createdAt(LocalDateTime.now()) + .build(); + documentService.createDocument(newDocument); + + Note newNote = Note.builder() + .document(newDocument) + .content(noteCreationRequest.getContent()) + .build(); + noteRepository.save(newNote); + return newNote; + } - public Note createOrUpdateNote(Note newNote) { - Note createdNote = noteRepository.save(newNote); - return createdNote; + @Transactional(rollbackFor = Exception.class) + public void deleteNote(int id) { + documentService.deleteDocument(id); + + noteRepository.deleteById(id); } } From e03092dafd44e3485a5063b2b79c18474e262928 Mon Sep 17 00:00:00 2001 From: Phu Vo Date: Thu, 23 Oct 2025 15:02:08 +1000 Subject: [PATCH 07/63] :wrench: chore(auth): Adjust the ErrorCode in case a user already exists --- src/main/java/com/be08/smart_notes/exception/ErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java index 7b3f202..877262d 100644 --- a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -12,7 +12,7 @@ @FieldDefaults(level = AccessLevel.PRIVATE) public enum ErrorCode { UNCATEGORIZED(9999, "Uncategorized error", HttpStatus.INTERNAL_SERVER_ERROR), - USER_EXISTS(1001, "User already exists", HttpStatus.BAD_REQUEST) + USER_EXISTS(2001, "User already exists", HttpStatus.BAD_REQUEST) ; int code; From 76ca226844a7b2568a995872f1918f2860c5177b Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 23 Oct 2025 18:26:59 +1100 Subject: [PATCH 08/63] feat(note): add note update and fix indentation --- .../smart_notes/controller/AIController.java | 4 +- .../controller/DocumentController.java | 4 +- .../controller/NoteController.java | 9 ++++- .../be08/smart_notes/dto/QuizQuestion.java | 4 +- .../dto/request/NoteCreationRequest.java | 2 +- .../dto/request/NoteUpdateRequest.java | 15 +++++++ .../be08/smart_notes/enums/DocumentType.java | 3 +- .../com/be08/smart_notes/model/Document.java | 6 ++- .../java/com/be08/smart_notes/model/Note.java | 9 ++--- .../com/be08/smart_notes/model/Question.java | 4 +- .../repository/DocumentRepository.java | 3 ++ .../repository/NoteRepository.java | 2 +- .../smart_notes/service/DocumentService.java | 15 +------ .../be08/smart_notes/service/NoteService.java | 30 ++++++++------ .../smart_notes/service/ai/AIService.java | 30 +++++++------- .../service/ai/QuizGenerationService.java | 39 ++++++++++--------- 16 files changed, 100 insertions(+), 79 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/dto/request/NoteUpdateRequest.java diff --git a/src/main/java/com/be08/smart_notes/controller/AIController.java b/src/main/java/com/be08/smart_notes/controller/AIController.java index fa5c2f4..f976f8b 100644 --- a/src/main/java/com/be08/smart_notes/controller/AIController.java +++ b/src/main/java/com/be08/smart_notes/controller/AIController.java @@ -16,13 +16,13 @@ public class AIController { @Autowired private QuizGenerationService quizGenerationService; - + @GetMapping("/generateQuiz/sample") public ResponseEntity generateSampleQuiz() { QuizResponse quizList = quizGenerationService.generateSampleQuiz(); return ResponseEntity.status(HttpStatus.OK).body(quizList); } - + @GetMapping("/generateQuiz/{noteId}") public ResponseEntity generateQuiz(@PathVariable int noteId) { QuizResponse quizList = quizGenerationService.generateQuizFromNote(noteId); diff --git a/src/main/java/com/be08/smart_notes/controller/DocumentController.java b/src/main/java/com/be08/smart_notes/controller/DocumentController.java index 94686f5..44c4d85 100644 --- a/src/main/java/com/be08/smart_notes/controller/DocumentController.java +++ b/src/main/java/com/be08/smart_notes/controller/DocumentController.java @@ -25,9 +25,9 @@ public ResponseEntity getAllDocuments() { List documentList = documentService.getAllDocuments(); return ResponseEntity.status(HttpStatus.OK).body(documentList); } - + @DeleteMapping("/{id}") - public ResponseEntity deleteNote(@PathVariable int id) { + public ResponseEntity deleteDocument(@PathVariable int id) { documentService.deleteDocument(id); return ResponseEntity.status(HttpStatus.OK).body("OK"); } diff --git a/src/main/java/com/be08/smart_notes/controller/NoteController.java b/src/main/java/com/be08/smart_notes/controller/NoteController.java index ad69dc4..30d239a 100644 --- a/src/main/java/com/be08/smart_notes/controller/NoteController.java +++ b/src/main/java/com/be08/smart_notes/controller/NoteController.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.RestController; import com.be08.smart_notes.dto.request.NoteCreationRequest; +import com.be08.smart_notes.dto.request.NoteUpdateRequest; import com.be08.smart_notes.model.Note; import com.be08.smart_notes.service.NoteService; @@ -27,13 +28,19 @@ public ResponseEntity createNote(@RequestBody NoteCreationRequest noteCr Note createdNote = noteService.createNote(noteCreationRequest); return ResponseEntity.status(HttpStatus.CREATED).body(createdNote); } - + @GetMapping("/{id}") public ResponseEntity getNote(@PathVariable int id) { Note note = noteService.getNote(id); return ResponseEntity.status(HttpStatus.OK).body(note); } + @PutMapping("/{id}") + public ResponseEntity updateNote(@PathVariable int id, @RequestBody NoteUpdateRequest noteUpdateRequest) { + noteService.updateNote(id, noteUpdateRequest); + return ResponseEntity.status(HttpStatus.OK).body("OK"); + } + @DeleteMapping("/{id}") public ResponseEntity deleteNote(@PathVariable int id) { noteService.deleteNote(id); diff --git a/src/main/java/com/be08/smart_notes/dto/QuizQuestion.java b/src/main/java/com/be08/smart_notes/dto/QuizQuestion.java index 675ee51..baee316 100644 --- a/src/main/java/com/be08/smart_notes/dto/QuizQuestion.java +++ b/src/main/java/com/be08/smart_notes/dto/QuizQuestion.java @@ -4,13 +4,13 @@ public class QuizQuestion { public String question; public String[] options; public int correctIndex; - + public QuizQuestion() { this.question = ""; this.options = new String[4]; this.correctIndex = -1; } - + public QuizQuestion(String question, String[] options, int correctIndex) { this.question = question; this.options = options; diff --git a/src/main/java/com/be08/smart_notes/dto/request/NoteCreationRequest.java b/src/main/java/com/be08/smart_notes/dto/request/NoteCreationRequest.java index f37132b..00505d0 100644 --- a/src/main/java/com/be08/smart_notes/dto/request/NoteCreationRequest.java +++ b/src/main/java/com/be08/smart_notes/dto/request/NoteCreationRequest.java @@ -12,7 +12,7 @@ @AllArgsConstructor @Builder public class NoteCreationRequest { - private int userId; + private int userId; // Can be removed later when authentication is implemented private DocumentType type; private String title; private String content; diff --git a/src/main/java/com/be08/smart_notes/dto/request/NoteUpdateRequest.java b/src/main/java/com/be08/smart_notes/dto/request/NoteUpdateRequest.java new file mode 100644 index 0000000..9fa31c2 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/request/NoteUpdateRequest.java @@ -0,0 +1,15 @@ +package com.be08.smart_notes.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NoteUpdateRequest { + private String title; + private String content; +} diff --git a/src/main/java/com/be08/smart_notes/enums/DocumentType.java b/src/main/java/com/be08/smart_notes/enums/DocumentType.java index 5e23dc0..04857da 100644 --- a/src/main/java/com/be08/smart_notes/enums/DocumentType.java +++ b/src/main/java/com/be08/smart_notes/enums/DocumentType.java @@ -1,6 +1,5 @@ package com.be08.smart_notes.enums; public enum DocumentType { - NOTE, - PDF + NOTE, PDF; } diff --git a/src/main/java/com/be08/smart_notes/model/Document.java b/src/main/java/com/be08/smart_notes/model/Document.java index 9ed7924..22edd0a 100644 --- a/src/main/java/com/be08/smart_notes/model/Document.java +++ b/src/main/java/com/be08/smart_notes/model/Document.java @@ -14,6 +14,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToOne; +import jakarta.persistence.PrimaryKeyJoinColumn; import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; @@ -43,12 +44,13 @@ public class Document { @Column(nullable = false, name = "created_at") private LocalDateTime createdAt; - + @Column(nullable = true, name = "updated_at") private LocalDateTime updatedAt; - + // Relationships @JsonIgnore + @PrimaryKeyJoinColumn @OneToOne(mappedBy = "document", cascade = CascadeType.ALL) private Note note; } diff --git a/src/main/java/com/be08/smart_notes/model/Note.java b/src/main/java/com/be08/smart_notes/model/Note.java index 8c00213..69dd6e9 100644 --- a/src/main/java/com/be08/smart_notes/model/Note.java +++ b/src/main/java/com/be08/smart_notes/model/Note.java @@ -1,7 +1,5 @@ package com.be08.smart_notes.model; -import com.fasterxml.jackson.annotation.JsonIgnore; - import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; @@ -24,14 +22,13 @@ public class Note { @Id private int id; - @Column(nullable = false) + @Column(nullable = true) private String content; - + // Relationship // Use document.id as this entity's id - @JsonIgnore @OneToOne - @MapsId @JoinColumn(name = "id") + @MapsId private Document document; } diff --git a/src/main/java/com/be08/smart_notes/model/Question.java b/src/main/java/com/be08/smart_notes/model/Question.java index d331e67..4991ddf 100644 --- a/src/main/java/com/be08/smart_notes/model/Question.java +++ b/src/main/java/com/be08/smart_notes/model/Question.java @@ -23,10 +23,10 @@ public class Question { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private int id; - + @Column(nullable = false, name = "quiz_id") private int quizId; - + @Column(nullable = false, name = "question_text") private String questionText; diff --git a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java index 68770c3..f2c9c20 100644 --- a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java @@ -9,4 +9,7 @@ import com.be08.smart_notes.model.Document; public interface DocumentRepository extends JpaRepository { + @Query("UPDATE Document d SET d.title = :title, d.updatedAt = :updatedAt WHERE d.id = :id") + Document updateTitle(@Param(value = "id") int id, @Param(value = "title") String title, + @Param(value = "updatedAt") LocalDateTime updatedAt); } diff --git a/src/main/java/com/be08/smart_notes/repository/NoteRepository.java b/src/main/java/com/be08/smart_notes/repository/NoteRepository.java index 372a2db..9435a38 100644 --- a/src/main/java/com/be08/smart_notes/repository/NoteRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/NoteRepository.java @@ -6,5 +6,5 @@ import com.be08.smart_notes.model.Note; @Repository -public interface NoteRepository extends JpaRepository{ +public interface NoteRepository extends JpaRepository { } diff --git a/src/main/java/com/be08/smart_notes/service/DocumentService.java b/src/main/java/com/be08/smart_notes/service/DocumentService.java index 898aff3..e36f062 100644 --- a/src/main/java/com/be08/smart_notes/service/DocumentService.java +++ b/src/main/java/com/be08/smart_notes/service/DocumentService.java @@ -1,6 +1,5 @@ package com.be08.smart_notes.service; -import java.time.LocalDateTime; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; @@ -13,22 +12,12 @@ public class DocumentService { @Autowired private DocumentRepository documentRepository; - + public List getAllDocuments() { List documentList = documentRepository.findAll(); return documentList; } - - public Document createDocument(Document newDocument) { - Document createdDocument = documentRepository.save(newDocument); - return createdDocument; - } - - public Document updateDocumentTitle(int id, String title) { - Document updatedDocument = documentRepository.updateTitle(id, title, LocalDateTime.now()); - return updatedDocument; - } - + public void deleteDocument(int id) { documentRepository.deleteById(id); } diff --git a/src/main/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index 0738c15..bbe60dc 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -4,12 +4,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import com.be08.smart_notes.dto.request.NoteCreationRequest; +import com.be08.smart_notes.dto.request.NoteUpdateRequest; import com.be08.smart_notes.enums.DocumentType; import com.be08.smart_notes.model.Document; import com.be08.smart_notes.model.Note; +import com.be08.smart_notes.repository.DocumentRepository; import com.be08.smart_notes.repository.NoteRepository; @Service @@ -17,22 +18,21 @@ public class NoteService { @Autowired private NoteRepository noteRepository; @Autowired - private DocumentService documentService; - - public Note getNote(int id) { - Note note = noteRepository.findById(id).orElse(null); + private DocumentRepository documentRepository; + + public Note getNote(int noteId) { + Note note = noteRepository.findById(noteId).orElse(null); return note; } - @Transactional(rollbackFor = Exception.class) public Note createNote(NoteCreationRequest noteCreationRequest) { Document newDocument = Document.builder() .userId(noteCreationRequest.getUserId()) .title(noteCreationRequest.getTitle()) .type(DocumentType.NOTE) .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) .build(); - documentService.createDocument(newDocument); Note newNote = Note.builder() .document(newDocument) @@ -41,11 +41,17 @@ public Note createNote(NoteCreationRequest noteCreationRequest) { noteRepository.save(newNote); return newNote; } - - @Transactional(rollbackFor = Exception.class) - public void deleteNote(int id) { - documentService.deleteDocument(id); + + public void updateNote(int id, NoteUpdateRequest updateData) { + Note note = noteRepository.findById(id).orElse(null); - noteRepository.deleteById(id); + note.getDocument().setTitle(updateData.getTitle()); + note.getDocument().setUpdatedAt(LocalDateTime.now());; + note.setContent(updateData.getContent()); + noteRepository.save(note); + } + + public void deleteNote(int noteId) { + documentRepository.deleteById(noteId); } } diff --git a/src/main/java/com/be08/smart_notes/service/ai/AIService.java b/src/main/java/com/be08/smart_notes/service/ai/AIService.java index 0709c6f..02d40b9 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/AIService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/AIService.java @@ -14,25 +14,25 @@ public class AIService { @Value("${API_TOKEN}") protected String AI_API_TOKEN; - + @Value("${API_URL}") protected String AI_API_URL; - + @Value("${MODEL}") protected String AI_API_MODEL; - + @Value("${AI_API_USER_ROLE}") protected String AI_API_USER_ROLE; - + @Value("${AI_API_SYSTEM_ROLE}") protected String AI_API_SYSTEM_ROLE; - + @Value("${AI_API_TEMPERATURE}") protected double AI_API_TEMPERATURE; - + @Value("${AI_API_TOP_P}") protected double AI_API_TOP_P; - + public boolean checkPermission() { if (AI_API_TOKEN == null || AI_API_URL == null || AI_API_MODEL == null) { System.out.println("Missing AI_API_TOKEN or AI_API_URL or AI_API_MODEL."); @@ -41,36 +41,36 @@ public boolean checkPermission() { } return true; } - + public String fetchResponseFromInferenceProvider(String JSONBody) { String response = ""; try { // Send request URL url = new URI(AI_API_URL).toURL(); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - + connection.setRequestMethod("POST"); connection.setRequestProperty("Content-Type", "application/json"); - connection.setRequestProperty ("Authorization", "Bearer " + AI_API_TOKEN); + connection.setRequestProperty("Authorization", "Bearer " + AI_API_TOKEN); connection.setDoOutput(true); - + try (OutputStream os = connection.getOutputStream()) { - os.write(JSONBody.getBytes(StandardCharsets.UTF_8)); + os.write(JSONBody.getBytes(StandardCharsets.UTF_8)); } - + // Handle response int responseCode = connection.getResponseCode(); if (responseCode != HttpURLConnection.HTTP_OK) { System.out.println("Error: HTTP Response code - " + responseCode); return ""; } - + try (Scanner scanner = new Scanner(connection.getInputStream(), StandardCharsets.UTF_8)) { while (scanner.hasNextLine()) { response += scanner.nextLine() + "\n"; } } - + connection.disconnect(); } catch (IOException | URISyntaxException e) { System.out.println(e.toString()); diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index 6b5983b..0b46b0d 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -21,12 +21,12 @@ @Service public class QuizGenerationService extends AIService { @Autowired - private NoteService noteService; - + private NoteService noteService; + public QuizResponse generateSampleQuiz() { // Below is sample response of fetchResponseFromInferenceProvider() String responseAsJSONString = "{\"id\":\"chatcmpl-123456\",\"choices\":[{\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null,\"message\":{\"content\":\"{\\\"topic\\\": \\\"Object-Oriented Programming Concepts\\\", \\\"quizzes\\\": [\\n {\\n \\\"question\\\": \\\"What is the primary focus of Object-Oriented Programming?\\\",\\n \\\"options\\\": [\\n \\\"Writing code for specific tasks\\\",\\n \\\"Organizing code around objects\\\",\\n \\\"Implementing algorithms for complex problems\\\",\\n \\\"Using pre-defined functions\\\"\\n ],\\n \\\"correctIndex\\\": 1\\n },\\n {\\n \\\"question\\\": \\\"What is the primary benefit of encapsulation?\\\",\\n \\\"options\\\": [\\n \\\"Hiding internal details of an object\\\",\\n \\\"Allowing easy modification of an object\\\",\\n \\\"Promoting code reusability through inheritance\\\",\\n \\\"Making code more efficient and faster\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"Which of the following is an example of inheritance?\\\",\\n \\\"options\\\": [\\n \\\"Creating a class called 'Dog' that inherits from 'Animal'\\\",\\n \\\"Defining a method called 'calculateArea' within a class\\\",\\n \\\"Using a constructor to initialize an object's properties\\\",\\n \\\"Writing code to display a message on the screen\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"What does polymorphism allow?\\\",\\n \\\"options\\\": [\\n \\\"The same method to behave differently in different classes\\\",\\n \\\"A single method to handle different data types\\\",\\n \\\"Classes to inherit common functionalities from their parent classes\\\",\\n \\\"Objects to access data and methods in a controlled manner\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"What is a constructor in OOP?\\\",\\n \\\"options\\\": [\\n \\\"A method that runs when an object is created\\\",\\n \\\"A method that defines the behavior of an object\\\",\\n \\\"A method that is called repeatedly for a specific task\\\",\\n \\\"A method that handles exceptions and errors\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"What is the role of an interface in OOP?\\\",\\n \\\"options\\\": [\\n \\\"A contract that defines a set of methods that classes must implement\\\",\\n \\\"A blueprint for creating a specific type of object\\\",\\n \\\"A way to communicate between different objects\\\",\\n \\\"A way to store and manage data\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n }\\n]}\",\"refusal\":null,\"role\":\"assistant\",\"audio\":null,\"function_call\":null,\"tool_calls\":[],\"reasoning_content\":null},\"stop_reason\":null}],\"created\":1751445406,\"model\":\"google/gemma-2-2b-it\",\"object\":\"chat.completion\",\"service_tier\":null,\"system_fingerprint\":null,\"usage\":{\"completion_tokens\":531,\"prompt_tokens\":953,\"total_tokens\":1484,\"completion_tokens_details\":null,\"prompt_tokens_details\":null},\"prompt_logprobs\":null}\r\n"; - + // Extract raw message content from response string Gson gson = new Gson(); QuizResponse quizResponse = null; @@ -34,23 +34,24 @@ public QuizResponse generateSampleQuiz() { // Extract raw message content from response string InferenceResponse inferenceResponse = gson.fromJson(responseAsJSONString, InferenceResponse.class); String chatMessageContent = inferenceResponse.choices[0].message.content; - + // Parse to object quizResponse = gson.fromJson(chatMessageContent, QuizResponse.class); } catch (Exception e) { System.out.println(e.toString()); e.printStackTrace(); } - + return quizResponse; } - + public QuizResponse generateQuizFromNote(int noteId) { checkPermission(); - + Note selectedNote = noteService.getNote(noteId); - if (selectedNote == null) return null; - + if (selectedNote == null) + return null; + // Prepare JSON Body String systemPrompt = null; String noteContent = null; @@ -63,34 +64,36 @@ public QuizResponse generateQuizFromNote(int noteId) { System.out.println("An error occurred."); e.printStackTrace(); } - if (systemPrompt == null || noteContent == null || guidedSchema == null) return null; + if (systemPrompt == null || noteContent == null || guidedSchema == null) + return null; // Create inference request Gson gson = new Gson(); InferenceRequestMessage systemMessage = new InferenceRequestMessage(AI_API_SYSTEM_ROLE, systemPrompt); InferenceRequestMessage userMessage = new InferenceRequestMessage(AI_API_USER_ROLE, noteContent); - InferenceRequest info = new GuidedInferenceRequest( - AI_API_MODEL, new InferenceRequestMessage[] {systemMessage, userMessage}, - AI_API_TEMPERATURE, AI_API_TOP_P, guidedSchema); + InferenceRequest info = new GuidedInferenceRequest(AI_API_MODEL, + new InferenceRequestMessage[] { systemMessage, userMessage }, AI_API_TEMPERATURE, AI_API_TOP_P, + guidedSchema); String chatJSON = gson.toJson(info); - + // Fetch response from AI API String responseAsJSONString = fetchResponseFromInferenceProvider(chatJSON); - if (responseAsJSONString.isEmpty()) return null; - + if (responseAsJSONString.isEmpty()) + return null; + QuizResponse quizResponse = null; try { // Extract raw message content from response string InferenceResponse inferenceResponse = gson.fromJson(responseAsJSONString, InferenceResponse.class); String chatMessageContent = inferenceResponse.choices[0].message.content; - + // Parse to object quizResponse = gson.fromJson(chatMessageContent, QuizResponse.class); } catch (Exception e) { System.out.println(e.toString()); e.printStackTrace(); } - + return quizResponse; } } From ccadf301d6b419f53fc6fa5b21108ded8dd41b24 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 23 Oct 2025 22:53:42 +1100 Subject: [PATCH 09/63] refactor(document): remove note entity, adding new fields to document entity --- .../controller/NoteController.java | 6 ++-- .../com/be08/smart_notes/model/Document.java | 21 ++++++------ .../java/com/be08/smart_notes/model/Note.java | 34 ------------------- .../repository/NoteRepository.java | 10 ------ .../be08/smart_notes/service/NoteService.java | 31 +++++++---------- .../service/ai/QuizGenerationService.java | 4 +-- 6 files changed, 28 insertions(+), 78 deletions(-) delete mode 100644 src/main/java/com/be08/smart_notes/model/Note.java delete mode 100644 src/main/java/com/be08/smart_notes/repository/NoteRepository.java diff --git a/src/main/java/com/be08/smart_notes/controller/NoteController.java b/src/main/java/com/be08/smart_notes/controller/NoteController.java index 30d239a..cc618f1 100644 --- a/src/main/java/com/be08/smart_notes/controller/NoteController.java +++ b/src/main/java/com/be08/smart_notes/controller/NoteController.java @@ -14,7 +14,7 @@ import com.be08.smart_notes.dto.request.NoteCreationRequest; import com.be08.smart_notes.dto.request.NoteUpdateRequest; -import com.be08.smart_notes.model.Note; +import com.be08.smart_notes.model.Document; import com.be08.smart_notes.service.NoteService; @RestController @@ -25,13 +25,13 @@ public class NoteController { @PostMapping public ResponseEntity createNote(@RequestBody NoteCreationRequest noteCreationRequest) { - Note createdNote = noteService.createNote(noteCreationRequest); + Document createdNote = noteService.createNote(noteCreationRequest); return ResponseEntity.status(HttpStatus.CREATED).body(createdNote); } @GetMapping("/{id}") public ResponseEntity getNote(@PathVariable int id) { - Note note = noteService.getNote(id); + Document note = noteService.getNote(id); return ResponseEntity.status(HttpStatus.OK).body(note); } diff --git a/src/main/java/com/be08/smart_notes/model/Document.java b/src/main/java/com/be08/smart_notes/model/Document.java index 22edd0a..4b7052e 100644 --- a/src/main/java/com/be08/smart_notes/model/Document.java +++ b/src/main/java/com/be08/smart_notes/model/Document.java @@ -3,9 +3,8 @@ import java.time.LocalDateTime; import com.be08.smart_notes.enums.DocumentType; -import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; -import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -13,8 +12,6 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.OneToOne; -import jakarta.persistence.PrimaryKeyJoinColumn; import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; @@ -27,6 +24,7 @@ @Builder @Entity @Table(name = "document") +@JsonInclude(JsonInclude.Include.NON_NULL) public class Document { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -45,12 +43,15 @@ public class Document { @Column(nullable = false, name = "created_at") private LocalDateTime createdAt; - @Column(nullable = true, name = "updated_at") + @Column(name = "updated_at") private LocalDateTime updatedAt; + + @Column + private String content; - // Relationships - @JsonIgnore - @PrimaryKeyJoinColumn - @OneToOne(mappedBy = "document", cascade = CascadeType.ALL) - private Note note; + @Column(name = "file_url") + private String fileUrl; + + @Column(name = "file_size") + private Integer fileSize; } diff --git a/src/main/java/com/be08/smart_notes/model/Note.java b/src/main/java/com/be08/smart_notes/model/Note.java deleted file mode 100644 index 69dd6e9..0000000 --- a/src/main/java/com/be08/smart_notes/model/Note.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.be08.smart_notes.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.MapsId; -import jakarta.persistence.OneToOne; -import jakarta.persistence.Table; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Entity -@Table(name = "note") -public class Note { - @Id - private int id; - - @Column(nullable = true) - private String content; - - // Relationship - // Use document.id as this entity's id - @OneToOne - @JoinColumn(name = "id") - @MapsId - private Document document; -} diff --git a/src/main/java/com/be08/smart_notes/repository/NoteRepository.java b/src/main/java/com/be08/smart_notes/repository/NoteRepository.java deleted file mode 100644 index 9435a38..0000000 --- a/src/main/java/com/be08/smart_notes/repository/NoteRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.be08.smart_notes.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import com.be08.smart_notes.model.Note; - -@Repository -public interface NoteRepository extends JpaRepository { -} diff --git a/src/main/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index bbe60dc..a346030 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -9,46 +9,39 @@ import com.be08.smart_notes.dto.request.NoteUpdateRequest; import com.be08.smart_notes.enums.DocumentType; import com.be08.smart_notes.model.Document; -import com.be08.smart_notes.model.Note; import com.be08.smart_notes.repository.DocumentRepository; -import com.be08.smart_notes.repository.NoteRepository; @Service public class NoteService { - @Autowired - private NoteRepository noteRepository; @Autowired private DocumentRepository documentRepository; - public Note getNote(int noteId) { - Note note = noteRepository.findById(noteId).orElse(null); + public Document getNote(int noteId) { + Document note = documentRepository.findById(noteId).orElse(null); return note; } - public Note createNote(NoteCreationRequest noteCreationRequest) { - Document newDocument = Document.builder() - .userId(noteCreationRequest.getUserId()) - .title(noteCreationRequest.getTitle()) + public Document createNote(NoteCreationRequest newData) { + Document newNote = Document.builder() + .userId(newData.getUserId()) + .title(newData.getTitle()) + .content(newData.getContent()) .type(DocumentType.NOTE) .createdAt(LocalDateTime.now()) .updatedAt(LocalDateTime.now()) .build(); - Note newNote = Note.builder() - .document(newDocument) - .content(noteCreationRequest.getContent()) - .build(); - noteRepository.save(newNote); + documentRepository.save(newNote); return newNote; } public void updateNote(int id, NoteUpdateRequest updateData) { - Note note = noteRepository.findById(id).orElse(null); + Document note = documentRepository.findById(id).orElse(null); - note.getDocument().setTitle(updateData.getTitle()); - note.getDocument().setUpdatedAt(LocalDateTime.now());; + note.setTitle(updateData.getTitle()); + note.setUpdatedAt(LocalDateTime.now());; note.setContent(updateData.getContent()); - noteRepository.save(note); + documentRepository.save(note); } public void deleteNote(int noteId) { diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index 0b46b0d..c9f7282 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -16,7 +16,7 @@ import com.be08.smart_notes.dto.ai.InferenceRequestMessage; import com.be08.smart_notes.dto.ai.InferenceResponse; import com.be08.smart_notes.dto.ai.QuizResponse; -import com.be08.smart_notes.model.Note; +import com.be08.smart_notes.model.Document; @Service public class QuizGenerationService extends AIService { @@ -48,7 +48,7 @@ public QuizResponse generateSampleQuiz() { public QuizResponse generateQuizFromNote(int noteId) { checkPermission(); - Note selectedNote = noteService.getNote(noteId); + Document selectedNote = noteService.getNote(noteId); if (selectedNote == null) return null; From 4c23fdb328d47731523bbefaa99b50103c37f1d3 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Fri, 24 Oct 2025 13:16:00 +1100 Subject: [PATCH 10/63] feat(validation): apply validation groups to unify note DTO for create/update --- pom.xml | 10 ++++---- .../controller/NoteController.java | 10 ++++---- .../dto/request/NoteCreationRequest.java | 19 --------------- .../dto/request/NoteUpdateRequest.java | 15 ------------ .../dto/request/NoteUpsertRequest.java | 24 +++++++++++++++++++ .../com/be08/smart_notes/model/Document.java | 6 +++-- .../com/be08/smart_notes/model/Question.java | 2 +- .../java/com/be08/smart_notes/model/Quiz.java | 2 +- .../java/com/be08/smart_notes/model/User.java | 2 +- .../be08/smart_notes/service/NoteService.java | 9 ++++--- .../validation/group/OnCreate.java | 5 ++++ .../validation/group/OnUpdate.java | 5 ++++ 12 files changed, 56 insertions(+), 53 deletions(-) delete mode 100644 src/main/java/com/be08/smart_notes/dto/request/NoteCreationRequest.java delete mode 100644 src/main/java/com/be08/smart_notes/dto/request/NoteUpdateRequest.java create mode 100644 src/main/java/com/be08/smart_notes/dto/request/NoteUpsertRequest.java create mode 100644 src/main/java/com/be08/smart_notes/validation/group/OnCreate.java create mode 100644 src/main/java/com/be08/smart_notes/validation/group/OnUpdate.java diff --git a/pom.xml b/pom.xml index 0804f45..cb47879 100644 --- a/pom.xml +++ b/pom.xml @@ -72,12 +72,12 @@ 1.18.30 - - + + - org.springdoc - springdoc-openapi-starter-webmvc-ui - 2.8.5 + org.springframework.boot + spring-boot-starter-validation + 3.5.6 diff --git a/src/main/java/com/be08/smart_notes/controller/NoteController.java b/src/main/java/com/be08/smart_notes/controller/NoteController.java index cc618f1..ea6fcc5 100644 --- a/src/main/java/com/be08/smart_notes/controller/NoteController.java +++ b/src/main/java/com/be08/smart_notes/controller/NoteController.java @@ -3,6 +3,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -12,10 +13,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.be08.smart_notes.dto.request.NoteCreationRequest; -import com.be08.smart_notes.dto.request.NoteUpdateRequest; +import com.be08.smart_notes.dto.request.NoteUpsertRequest; import com.be08.smart_notes.model.Document; import com.be08.smart_notes.service.NoteService; +import com.be08.smart_notes.validation.group.OnCreate; +import com.be08.smart_notes.validation.group.OnUpdate; @RestController @RequestMapping("/api/document/note") @@ -24,7 +26,7 @@ public class NoteController { private NoteService noteService; @PostMapping - public ResponseEntity createNote(@RequestBody NoteCreationRequest noteCreationRequest) { + public ResponseEntity createNote(@Validated(OnCreate.class) @RequestBody NoteUpsertRequest noteCreationRequest) { Document createdNote = noteService.createNote(noteCreationRequest); return ResponseEntity.status(HttpStatus.CREATED).body(createdNote); } @@ -36,7 +38,7 @@ public ResponseEntity getNote(@PathVariable int id) { } @PutMapping("/{id}") - public ResponseEntity updateNote(@PathVariable int id, @RequestBody NoteUpdateRequest noteUpdateRequest) { + public ResponseEntity updateNote(@PathVariable int id, @Validated(OnUpdate.class) @RequestBody NoteUpsertRequest noteUpdateRequest) { noteService.updateNote(id, noteUpdateRequest); return ResponseEntity.status(HttpStatus.OK).body("OK"); } diff --git a/src/main/java/com/be08/smart_notes/dto/request/NoteCreationRequest.java b/src/main/java/com/be08/smart_notes/dto/request/NoteCreationRequest.java deleted file mode 100644 index 00505d0..0000000 --- a/src/main/java/com/be08/smart_notes/dto/request/NoteCreationRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.be08.smart_notes.dto.request; - -import com.be08.smart_notes.enums.DocumentType; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class NoteCreationRequest { - private int userId; // Can be removed later when authentication is implemented - private DocumentType type; - private String title; - private String content; -} diff --git a/src/main/java/com/be08/smart_notes/dto/request/NoteUpdateRequest.java b/src/main/java/com/be08/smart_notes/dto/request/NoteUpdateRequest.java deleted file mode 100644 index 9fa31c2..0000000 --- a/src/main/java/com/be08/smart_notes/dto/request/NoteUpdateRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.be08.smart_notes.dto.request; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class NoteUpdateRequest { - private String title; - private String content; -} diff --git a/src/main/java/com/be08/smart_notes/dto/request/NoteUpsertRequest.java b/src/main/java/com/be08/smart_notes/dto/request/NoteUpsertRequest.java new file mode 100644 index 0000000..3e32eed --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/request/NoteUpsertRequest.java @@ -0,0 +1,24 @@ +package com.be08.smart_notes.dto.request; + +import com.be08.smart_notes.validation.group.OnCreate; +import com.be08.smart_notes.validation.group.OnUpdate; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NoteUpsertRequest { + @NotNull(groups = OnCreate.class) + private int userId; + + @NotNull(groups = {OnCreate.class, OnUpdate.class}) + private String title; + @NotNull(groups = {OnCreate.class, OnUpdate.class}) + private String content; +} diff --git a/src/main/java/com/be08/smart_notes/model/Document.java b/src/main/java/com/be08/smart_notes/model/Document.java index 4b7052e..5f17443 100644 --- a/src/main/java/com/be08/smart_notes/model/Document.java +++ b/src/main/java/com/be08/smart_notes/model/Document.java @@ -23,14 +23,14 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "document") +@Table(name = "documents") @JsonInclude(JsonInclude.Include.NON_NULL) public class Document { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private int id; - @Column(nullable = false, name = "user_id") + @Column(nullable = false) private int userId; @Column(nullable = false) @@ -46,9 +46,11 @@ public class Document { @Column(name = "updated_at") private LocalDateTime updatedAt; + // Information for note document @Column private String content; + // Information for pdf document @Column(name = "file_url") private String fileUrl; diff --git a/src/main/java/com/be08/smart_notes/model/Question.java b/src/main/java/com/be08/smart_notes/model/Question.java index 4991ddf..dec8eb7 100644 --- a/src/main/java/com/be08/smart_notes/model/Question.java +++ b/src/main/java/com/be08/smart_notes/model/Question.java @@ -18,7 +18,7 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "question") +@Table(name = "questions") public class Question { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/be08/smart_notes/model/Quiz.java b/src/main/java/com/be08/smart_notes/model/Quiz.java index bd4d7a4..1ce15a8 100644 --- a/src/main/java/com/be08/smart_notes/model/Quiz.java +++ b/src/main/java/com/be08/smart_notes/model/Quiz.java @@ -16,7 +16,7 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "quiz") +@Table(name = "quizzes") public class Quiz { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/be08/smart_notes/model/User.java b/src/main/java/com/be08/smart_notes/model/User.java index d1b14d6..b7fdb5e 100644 --- a/src/main/java/com/be08/smart_notes/model/User.java +++ b/src/main/java/com/be08/smart_notes/model/User.java @@ -18,7 +18,7 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "user") +@Table(name = "users") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index a346030..fa9e9fe 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -5,8 +5,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import com.be08.smart_notes.dto.request.NoteCreationRequest; -import com.be08.smart_notes.dto.request.NoteUpdateRequest; +import com.be08.smart_notes.dto.request.NoteUpsertRequest; import com.be08.smart_notes.enums.DocumentType; import com.be08.smart_notes.model.Document; import com.be08.smart_notes.repository.DocumentRepository; @@ -21,7 +20,7 @@ public Document getNote(int noteId) { return note; } - public Document createNote(NoteCreationRequest newData) { + public Document createNote(NoteUpsertRequest newData) { Document newNote = Document.builder() .userId(newData.getUserId()) .title(newData.getTitle()) @@ -35,8 +34,8 @@ public Document createNote(NoteCreationRequest newData) { return newNote; } - public void updateNote(int id, NoteUpdateRequest updateData) { - Document note = documentRepository.findById(id).orElse(null); + public void updateNote(int noteId, NoteUpsertRequest updateData) { + Document note = documentRepository.findById(noteId).orElse(null); note.setTitle(updateData.getTitle()); note.setUpdatedAt(LocalDateTime.now());; diff --git a/src/main/java/com/be08/smart_notes/validation/group/OnCreate.java b/src/main/java/com/be08/smart_notes/validation/group/OnCreate.java new file mode 100644 index 0000000..d6f0983 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/validation/group/OnCreate.java @@ -0,0 +1,5 @@ +package com.be08.smart_notes.validation.group; + +public interface OnCreate { + +} diff --git a/src/main/java/com/be08/smart_notes/validation/group/OnUpdate.java b/src/main/java/com/be08/smart_notes/validation/group/OnUpdate.java new file mode 100644 index 0000000..acc22df --- /dev/null +++ b/src/main/java/com/be08/smart_notes/validation/group/OnUpdate.java @@ -0,0 +1,5 @@ +package com.be08.smart_notes.validation.group; + +public interface OnUpdate { + +} From 0b00ef70d2e8277c1983729d3b6dcaf235b406e3 Mon Sep 17 00:00:00 2001 From: Phu Vo <168263467+pvdev1805@users.noreply.github.com> Date: Fri, 24 Oct 2025 20:17:17 +1000 Subject: [PATCH 11/63] Feature/auth register (#1) * :hammer: feat(auth-register): Handle register a new user (without Spring Security: JWT + BCrypt) * :recycle: refactor(auth-register): Refactor table names in database * :safety_vest: feat(auth-register): Handle ErrorCode + message for validation exception * :safety_vest: feat(auth-register): Resolve invalid error code key for validation --- pom.xml | 37 ++++++++++- .../controller/AuthenticationController.java | 17 ++--- .../dto/request/UserCreationRequest.java | 13 ++++ .../dto/response/AuthenticationResponse.java | 13 ++++ .../dto/response/UserResponse.java | 22 +++++++ .../be08/smart_notes/exception/ErrorCode.java | 10 ++- .../exception/GlobalExceptionHandler.java | 23 +++++++ .../be08/smart_notes/mapper/UserMapper.java | 16 +++++ .../service/AuthenticationService.java | 63 +++++++++++++++++++ .../be08/smart_notes/service/UserService.java | 19 +++--- 10 files changed, 214 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/dto/response/AuthenticationResponse.java create mode 100644 src/main/java/com/be08/smart_notes/dto/response/UserResponse.java create mode 100644 src/main/java/com/be08/smart_notes/mapper/UserMapper.java create mode 100644 src/main/java/com/be08/smart_notes/service/AuthenticationService.java diff --git a/pom.xml b/pom.xml index 0804f45..85d38d2 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,8 @@ 17 + 1.18.30 + 1.6.3 @@ -55,6 +57,11 @@ spring-boot-starter-test test + + + org.springframework.boot + spring-boot-starter-validation + @@ -69,7 +76,7 @@ org.projectlombok lombok - 1.18.30 + ${lombok.version} @@ -79,6 +86,15 @@ springdoc-openapi-starter-webmvc-ui 2.8.5 + + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + @@ -87,6 +103,25 @@ org.springframework.boot spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + diff --git a/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java b/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java index 91223a0..cef9927 100644 --- a/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java +++ b/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java @@ -2,8 +2,10 @@ import com.be08.smart_notes.dto.request.UserCreationRequest; import com.be08.smart_notes.dto.response.ApiResponse; -import com.be08.smart_notes.model.User; -import com.be08.smart_notes.service.UserService; +import com.be08.smart_notes.dto.response.AuthenticationResponse; +import com.be08.smart_notes.dto.response.UserResponse; +import com.be08.smart_notes.service.AuthenticationService; +import jakarta.validation.Valid; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; @@ -17,13 +19,14 @@ @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class AuthenticationController { - UserService userService; + AuthenticationService authenticationService; @PostMapping("/register") - ApiResponse register(@RequestBody UserCreationRequest request){ - var user = userService.createUser(request); - return ApiResponse.builder() - .data(user) + ApiResponse register(@RequestBody @Valid UserCreationRequest request){ + AuthenticationResponse response = authenticationService.register(request); + return ApiResponse.builder() + .message("User registered successfully") + .data(response) .build(); } } diff --git a/src/main/java/com/be08/smart_notes/dto/request/UserCreationRequest.java b/src/main/java/com/be08/smart_notes/dto/request/UserCreationRequest.java index 14bb57f..4972d30 100644 --- a/src/main/java/com/be08/smart_notes/dto/request/UserCreationRequest.java +++ b/src/main/java/com/be08/smart_notes/dto/request/UserCreationRequest.java @@ -1,5 +1,9 @@ package com.be08.smart_notes.dto.request; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.*; import lombok.experimental.FieldDefaults; @@ -9,7 +13,16 @@ @Builder @FieldDefaults(level = AccessLevel.PRIVATE) public class UserCreationRequest { + @NotBlank(message = "NAME_EMPTY") String name; + + @NotBlank(message = "EMAIL_EMPTY") + @Email(message = "INVALID_EMAIL_FORMAT") + @Size(max = 255, message = "INVALID_EMAIL_SIZE") String email; + + @NotBlank(message = "PASSWORD_EMPTY") + @Size(min = 8, message = "INVALID_PASSWORD_SIZE") + @Pattern(regexp = "^(?=\\S+$)(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9]).+$", message = "INVALID_PASSWORD_PATTERN") String password; } diff --git a/src/main/java/com/be08/smart_notes/dto/response/AuthenticationResponse.java b/src/main/java/com/be08/smart_notes/dto/response/AuthenticationResponse.java new file mode 100644 index 0000000..294b680 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/response/AuthenticationResponse.java @@ -0,0 +1,13 @@ +package com.be08.smart_notes.dto.response; + +import lombok.*; +import lombok.experimental.FieldDefaults; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class AuthenticationResponse { + boolean isAuthenticated; +} diff --git a/src/main/java/com/be08/smart_notes/dto/response/UserResponse.java b/src/main/java/com/be08/smart_notes/dto/response/UserResponse.java new file mode 100644 index 0000000..26ac79c --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/response/UserResponse.java @@ -0,0 +1,22 @@ +package com.be08.smart_notes.dto.response; + +import lombok.*; +import lombok.experimental.FieldDefaults; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class UserResponse { + int id; + String name; + String email; + String avatarUrl; + + @Builder.Default + LocalDateTime createdAt = LocalDateTime.now(); // Default to current time + LocalDateTime updatedAt; +} diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java index 877262d..3154fbf 100644 --- a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -12,7 +12,15 @@ @FieldDefaults(level = AccessLevel.PRIVATE) public enum ErrorCode { UNCATEGORIZED(9999, "Uncategorized error", HttpStatus.INTERNAL_SERVER_ERROR), - USER_EXISTS(2001, "User already exists", HttpStatus.BAD_REQUEST) + INVALID_ERROR_CODE_KEY(9998, "Invalid error code key", HttpStatus.INTERNAL_SERVER_ERROR), + USER_EXISTS(2001, "User already exists", HttpStatus.BAD_REQUEST), + NAME_EMPTY(2002, "Name cannot be empty", HttpStatus.BAD_REQUEST), + EMAIL_EMPTY(2003, "Email cannot be empty", HttpStatus.BAD_REQUEST), + INVALID_EMAIL_FORMAT(2004, "Invalid email format", HttpStatus.BAD_REQUEST), + INVALID_EMAIL_SIZE(2005, "Email must be less than 255 characters", HttpStatus.BAD_REQUEST), + PASSWORD_EMPTY(2006, "Password cannot be empty", HttpStatus.BAD_REQUEST), + INVALID_PASSWORD_SIZE(2007, "Password must be at least 8 characters long", HttpStatus.BAD_REQUEST), + INVALID_PASSWORD_PATTERN(2008, "Password must contain at least one uppercase letter, one lowercase letter, and one digit, and no whitespace", HttpStatus.BAD_REQUEST) ; int code; diff --git a/src/main/java/com/be08/smart_notes/exception/GlobalExceptionHandler.java b/src/main/java/com/be08/smart_notes/exception/GlobalExceptionHandler.java index 4fab701..965daf5 100644 --- a/src/main/java/com/be08/smart_notes/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/be08/smart_notes/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import com.be08.smart_notes.dto.response.ApiResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -37,4 +38,26 @@ ResponseEntity handlingAppException(AppException exception){ .build(); return ResponseEntity.status(errorCode.getStatusCode()).body(apiResponse); } + + @ExceptionHandler(value = MethodArgumentNotValidException.class) + ResponseEntity handlingValidationException(MethodArgumentNotValidException exception){ + String enumKey = exception.getFieldError().getDefaultMessage(); + + ErrorCode errorCode = ErrorCode.INVALID_ERROR_CODE_KEY; + + try { + errorCode = ErrorCode.valueOf(enumKey); + } catch (IllegalArgumentException e) { + log.error("Invalid ErrorCode key: {}", enumKey); + } + + int code = errorCode.getCode(); + String message = errorCode.getMessage(); + + ApiResponse apiResponse = ApiResponse.builder() + .code(code) + .message(message) + .build(); + return ResponseEntity.badRequest().body(apiResponse); + } } diff --git a/src/main/java/com/be08/smart_notes/mapper/UserMapper.java b/src/main/java/com/be08/smart_notes/mapper/UserMapper.java new file mode 100644 index 0000000..9812166 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/mapper/UserMapper.java @@ -0,0 +1,16 @@ +package com.be08.smart_notes.mapper; + +import com.be08.smart_notes.dto.request.UserCreationRequest; +import com.be08.smart_notes.dto.response.UserResponse; +import com.be08.smart_notes.model.User; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.time.LocalDateTime; + +@Mapper(componentModel = "spring") +public interface UserMapper { + User toUser(UserCreationRequest request); + + UserResponse toUserResponse(User user); +} diff --git a/src/main/java/com/be08/smart_notes/service/AuthenticationService.java b/src/main/java/com/be08/smart_notes/service/AuthenticationService.java new file mode 100644 index 0000000..3dc76f8 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/AuthenticationService.java @@ -0,0 +1,63 @@ +package com.be08.smart_notes.service; + +import com.be08.smart_notes.dto.request.UserCreationRequest; +import com.be08.smart_notes.dto.response.AuthenticationResponse; +import com.be08.smart_notes.exception.AppException; +import com.be08.smart_notes.exception.ErrorCode; +import com.be08.smart_notes.mapper.UserMapper; +import com.be08.smart_notes.model.User; +import com.be08.smart_notes.repository.UserRepository; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@Slf4j +public class AuthenticationService { + UserRepository userRepository; + UserMapper userMapper; + + public AuthenticationResponse register(UserCreationRequest request){ + String email = request.getEmail(); + log.info("Registering user with email: {}", email); + String password = request.getPassword(); + + // basic business-level check (defense in depth) + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Email is required"); + } + if (password == null || password.length() < 8) { + throw new IllegalArgumentException("Password must be at least 8 characters"); + } + + if(userRepository.existsByEmail(email)){ + log.error("User with email {} already exists", email); + throw new AppException(ErrorCode.USER_EXISTS); + } + + User user = userMapper.toUser(request); + user.setCreatedAt(LocalDateTime.now()); + + try { + userRepository.save(user); + + return AuthenticationResponse.builder() + .isAuthenticated(true) + .build(); + } catch (DataIntegrityViolationException exception){ + // final safeguard for concurrent inserts — DB unique constraint + log.error("Data integrity violation while creating user with email {}: {}", email, exception.getMessage()); + } + + return AuthenticationResponse.builder() + .isAuthenticated(true) + .build(); + } +} diff --git a/src/main/java/com/be08/smart_notes/service/UserService.java b/src/main/java/com/be08/smart_notes/service/UserService.java index 8776154..8b52660 100644 --- a/src/main/java/com/be08/smart_notes/service/UserService.java +++ b/src/main/java/com/be08/smart_notes/service/UserService.java @@ -1,8 +1,10 @@ package com.be08.smart_notes.service; import com.be08.smart_notes.dto.request.UserCreationRequest; +import com.be08.smart_notes.dto.response.UserResponse; import com.be08.smart_notes.exception.AppException; import com.be08.smart_notes.exception.ErrorCode; +import com.be08.smart_notes.mapper.UserMapper; import com.be08.smart_notes.model.User; import com.be08.smart_notes.repository.UserRepository; import lombok.AccessLevel; @@ -11,14 +13,17 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) @Slf4j public class UserService { UserRepository userRepository; + UserMapper userMapper; - public User createUser(UserCreationRequest request){ + public UserResponse createUser(UserCreationRequest request){ String email = request.getEmail(); if(userRepository.existsByEmail(email)){ @@ -26,15 +31,9 @@ public User createUser(UserCreationRequest request){ throw new AppException(ErrorCode.USER_EXISTS); } - String name = request.getName(); - String password = request.getPassword(); - - User user = User.builder() - .name(name) - .email(email) - .password(password) - .build(); + User user = userMapper.toUser(request); + user.setCreatedAt(LocalDateTime.now()); - return userRepository.save(user); + return userMapper.toUserResponse(userRepository.save(user)); } } From 3a12cda45e72ce8dc61be2d6b64a39ca4f38b7cf Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Fri, 24 Oct 2025 22:40:16 +1100 Subject: [PATCH 12/63] refactor(document): standardize api response --- .../controller/DocumentController.java | 12 ++++++++-- .../controller/NoteController.java | 23 +++++++++++++++---- .../smart_notes/dto/response/ApiResponse.java | 23 +++++++++++++++++++ .../com/be08/smart_notes/model/Document.java | 2 +- .../com/be08/smart_notes/model/Question.java | 2 +- .../java/com/be08/smart_notes/model/Quiz.java | 2 +- .../java/com/be08/smart_notes/model/User.java | 2 +- 7 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/dto/response/ApiResponse.java diff --git a/src/main/java/com/be08/smart_notes/controller/DocumentController.java b/src/main/java/com/be08/smart_notes/controller/DocumentController.java index 44c4d85..d97a857 100644 --- a/src/main/java/com/be08/smart_notes/controller/DocumentController.java +++ b/src/main/java/com/be08/smart_notes/controller/DocumentController.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.be08.smart_notes.dto.response.ApiResponse; import com.be08.smart_notes.model.Document; import com.be08.smart_notes.service.DocumentService; @@ -23,12 +24,19 @@ public class DocumentController { @GetMapping public ResponseEntity getAllDocuments() { List documentList = documentService.getAllDocuments(); - return ResponseEntity.status(HttpStatus.OK).body(documentList); + ApiResponse apiResponse = ApiResponse.builder() + .message("All document fetched successfully") + .data(documentList) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } @DeleteMapping("/{id}") public ResponseEntity deleteDocument(@PathVariable int id) { documentService.deleteDocument(id); - return ResponseEntity.status(HttpStatus.OK).body("OK"); + ApiResponse apiResponse = ApiResponse.builder() + .message("Document deleted successfully") + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } } diff --git a/src/main/java/com/be08/smart_notes/controller/NoteController.java b/src/main/java/com/be08/smart_notes/controller/NoteController.java index ea6fcc5..8fcbc52 100644 --- a/src/main/java/com/be08/smart_notes/controller/NoteController.java +++ b/src/main/java/com/be08/smart_notes/controller/NoteController.java @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RestController; import com.be08.smart_notes.dto.request.NoteUpsertRequest; +import com.be08.smart_notes.dto.response.ApiResponse; import com.be08.smart_notes.model.Document; import com.be08.smart_notes.service.NoteService; import com.be08.smart_notes.validation.group.OnCreate; @@ -28,24 +29,38 @@ public class NoteController { @PostMapping public ResponseEntity createNote(@Validated(OnCreate.class) @RequestBody NoteUpsertRequest noteCreationRequest) { Document createdNote = noteService.createNote(noteCreationRequest); - return ResponseEntity.status(HttpStatus.CREATED).body(createdNote); + ApiResponse apiResponse = ApiResponse.builder() + .message("Note created successfully") + .data(createdNote) + .build(); + return ResponseEntity.status(HttpStatus.CREATED).body(apiResponse); } @GetMapping("/{id}") public ResponseEntity getNote(@PathVariable int id) { Document note = noteService.getNote(id); - return ResponseEntity.status(HttpStatus.OK).body(note); + ApiResponse apiResponse = ApiResponse.builder() + .message("Note fetched successfully") + .data(note) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } @PutMapping("/{id}") public ResponseEntity updateNote(@PathVariable int id, @Validated(OnUpdate.class) @RequestBody NoteUpsertRequest noteUpdateRequest) { noteService.updateNote(id, noteUpdateRequest); - return ResponseEntity.status(HttpStatus.OK).body("OK"); + ApiResponse apiResponse = ApiResponse.builder() + .message("Note updated successfully") + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } @DeleteMapping("/{id}") public ResponseEntity deleteNote(@PathVariable int id) { noteService.deleteNote(id); - return ResponseEntity.status(HttpStatus.OK).body("OK"); + ApiResponse apiResponse = ApiResponse.builder() + .message("Note deleted successfully") + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } } diff --git a/src/main/java/com/be08/smart_notes/dto/response/ApiResponse.java b/src/main/java/com/be08/smart_notes/dto/response/ApiResponse.java new file mode 100644 index 0000000..f121128 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/response/ApiResponse.java @@ -0,0 +1,23 @@ +package com.be08.smart_notes.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldDefaults; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiResponse { + @Builder.Default + int code = 1000; // Default success code + String message; + T data; +} diff --git a/src/main/java/com/be08/smart_notes/model/Document.java b/src/main/java/com/be08/smart_notes/model/Document.java index 5f17443..bfeadf6 100644 --- a/src/main/java/com/be08/smart_notes/model/Document.java +++ b/src/main/java/com/be08/smart_notes/model/Document.java @@ -23,7 +23,7 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "documents") +@Table(name = "document") @JsonInclude(JsonInclude.Include.NON_NULL) public class Document { @Id diff --git a/src/main/java/com/be08/smart_notes/model/Question.java b/src/main/java/com/be08/smart_notes/model/Question.java index dec8eb7..4991ddf 100644 --- a/src/main/java/com/be08/smart_notes/model/Question.java +++ b/src/main/java/com/be08/smart_notes/model/Question.java @@ -18,7 +18,7 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "questions") +@Table(name = "question") public class Question { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/be08/smart_notes/model/Quiz.java b/src/main/java/com/be08/smart_notes/model/Quiz.java index 1ce15a8..bd4d7a4 100644 --- a/src/main/java/com/be08/smart_notes/model/Quiz.java +++ b/src/main/java/com/be08/smart_notes/model/Quiz.java @@ -16,7 +16,7 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "quizzes") +@Table(name = "quiz") public class Quiz { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/be08/smart_notes/model/User.java b/src/main/java/com/be08/smart_notes/model/User.java index b7fdb5e..d1b14d6 100644 --- a/src/main/java/com/be08/smart_notes/model/User.java +++ b/src/main/java/com/be08/smart_notes/model/User.java @@ -18,7 +18,7 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "users") +@Table(name = "user") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) From 20a212845a154e089555899e74cc3564d76f4200 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Sat, 25 Oct 2025 23:07:54 +1100 Subject: [PATCH 13/63] refactor(ai-quiz): unify request/response DTOs and improve service logic --- pom.xml | 8 ++ .../be08/smart_notes/dto/QuizQuestion.java | 19 ---- .../dto/ai/AIInferenceRequest.java | 27 ++++++ .../dto/ai/AIInferenceResponse.java | 27 ++++++ .../dto/ai/GuidedInferenceRequest.java | 14 --- .../smart_notes/dto/ai/InferenceRequest.java | 12 --- .../dto/ai/InferenceRequestMessage.java | 11 --- .../smart_notes/dto/ai/InferenceResponse.java | 8 -- .../dto/ai/InferenceResponseChoice.java | 5 - .../dto/ai/InferenceResponseMessage.java | 6 -- .../be08/smart_notes/dto/ai/QuizResponse.java | 35 ++++++- .../smart_notes/service/ai/AIService.java | 86 ++++++++++++++---- .../service/ai/QuizGenerationService.java | 91 +++++++++---------- src/main/resources/schemas/quiz_response.json | 7 +- target/classes/META-INF/MANIFEST.MF | 6 -- 15 files changed, 209 insertions(+), 153 deletions(-) delete mode 100644 src/main/java/com/be08/smart_notes/dto/QuizQuestion.java create mode 100644 src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java create mode 100644 src/main/java/com/be08/smart_notes/dto/ai/AIInferenceResponse.java delete mode 100644 src/main/java/com/be08/smart_notes/dto/ai/GuidedInferenceRequest.java delete mode 100644 src/main/java/com/be08/smart_notes/dto/ai/InferenceRequest.java delete mode 100644 src/main/java/com/be08/smart_notes/dto/ai/InferenceRequestMessage.java delete mode 100644 src/main/java/com/be08/smart_notes/dto/ai/InferenceResponse.java delete mode 100644 src/main/java/com/be08/smart_notes/dto/ai/InferenceResponseChoice.java delete mode 100644 src/main/java/com/be08/smart_notes/dto/ai/InferenceResponseMessage.java delete mode 100644 target/classes/META-INF/MANIFEST.MF diff --git a/pom.xml b/pom.xml index cb47879..dce7282 100644 --- a/pom.xml +++ b/pom.xml @@ -79,6 +79,14 @@ spring-boot-starter-validation 3.5.6 + + + + + com.fasterxml.jackson.module + jackson-module-jsonSchema + 2.17.0 + diff --git a/src/main/java/com/be08/smart_notes/dto/QuizQuestion.java b/src/main/java/com/be08/smart_notes/dto/QuizQuestion.java deleted file mode 100644 index baee316..0000000 --- a/src/main/java/com/be08/smart_notes/dto/QuizQuestion.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.be08.smart_notes.dto; - -public class QuizQuestion { - public String question; - public String[] options; - public int correctIndex; - - public QuizQuestion() { - this.question = ""; - this.options = new String[4]; - this.correctIndex = -1; - } - - public QuizQuestion(String question, String[] options, int correctIndex) { - this.question = question; - this.options = options; - this.correctIndex = correctIndex; - } -} diff --git a/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java b/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java new file mode 100644 index 0000000..70dec3c --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java @@ -0,0 +1,27 @@ +package com.be08.smart_notes.dto.ai; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@AllArgsConstructor +@Builder +public class AIInferenceRequest { + // Required + private String model; + private RequestMessage[] messages; + + // Optional + private double temperature; + private double top_p; + private String guided_json; + + // Static nested class + @Data + @AllArgsConstructor + public static class RequestMessage { + private String role; + private String content; + } +} diff --git a/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceResponse.java b/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceResponse.java new file mode 100644 index 0000000..bcd900e --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceResponse.java @@ -0,0 +1,27 @@ +package com.be08.smart_notes.dto.ai; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class AIInferenceResponse { + private String id; + private String model; + private String object; + private ResponseChoice[] choices; + + // Static nested class + @Data + @AllArgsConstructor + public static class ResponseChoice { + private ResponseMessage message; + + @Data + @AllArgsConstructor + public static class ResponseMessage { + private String content; + private String role; + } + } +} diff --git a/src/main/java/com/be08/smart_notes/dto/ai/GuidedInferenceRequest.java b/src/main/java/com/be08/smart_notes/dto/ai/GuidedInferenceRequest.java deleted file mode 100644 index c34fcde..0000000 --- a/src/main/java/com/be08/smart_notes/dto/ai/GuidedInferenceRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.be08.smart_notes.dto.ai; - -public class GuidedInferenceRequest extends InferenceRequest { - public double temperature; - public double top_p; - public String guided_json; - - public GuidedInferenceRequest(String model, InferenceRequestMessage[] messages, double temperature, double top_p, String guided_json) { - super(model, messages); - this.temperature = temperature; - this.top_p = top_p; - this.guided_json = guided_json; - } -} diff --git a/src/main/java/com/be08/smart_notes/dto/ai/InferenceRequest.java b/src/main/java/com/be08/smart_notes/dto/ai/InferenceRequest.java deleted file mode 100644 index 241f9e5..0000000 --- a/src/main/java/com/be08/smart_notes/dto/ai/InferenceRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.be08.smart_notes.dto.ai; - -public class InferenceRequest { - public String model; - public InferenceRequestMessage[] messages; - - public InferenceRequest(String model, InferenceRequestMessage[] messages) { - super(); - this.model = model; - this.messages = messages; - } -} diff --git a/src/main/java/com/be08/smart_notes/dto/ai/InferenceRequestMessage.java b/src/main/java/com/be08/smart_notes/dto/ai/InferenceRequestMessage.java deleted file mode 100644 index 45fd9dd..0000000 --- a/src/main/java/com/be08/smart_notes/dto/ai/InferenceRequestMessage.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.be08.smart_notes.dto.ai; - -public class InferenceRequestMessage { - public String role; - public String content; - - public InferenceRequestMessage(String role, String content) { - this.role = role; - this.content = content; - } -} diff --git a/src/main/java/com/be08/smart_notes/dto/ai/InferenceResponse.java b/src/main/java/com/be08/smart_notes/dto/ai/InferenceResponse.java deleted file mode 100644 index a3ad9f4..0000000 --- a/src/main/java/com/be08/smart_notes/dto/ai/InferenceResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.be08.smart_notes.dto.ai; - -public class InferenceResponse { - public String id; - public InferenceResponseChoice[] choices; - public String model; - public String object; -} diff --git a/src/main/java/com/be08/smart_notes/dto/ai/InferenceResponseChoice.java b/src/main/java/com/be08/smart_notes/dto/ai/InferenceResponseChoice.java deleted file mode 100644 index 309f4cf..0000000 --- a/src/main/java/com/be08/smart_notes/dto/ai/InferenceResponseChoice.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.be08.smart_notes.dto.ai; - -public class InferenceResponseChoice { - public InferenceResponseMessage message; -} diff --git a/src/main/java/com/be08/smart_notes/dto/ai/InferenceResponseMessage.java b/src/main/java/com/be08/smart_notes/dto/ai/InferenceResponseMessage.java deleted file mode 100644 index 22eee1e..0000000 --- a/src/main/java/com/be08/smart_notes/dto/ai/InferenceResponseMessage.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.be08.smart_notes.dto.ai; - -public class InferenceResponseMessage { - public String content; - public String role; -} diff --git a/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java b/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java index 5b8b427..0f388e4 100644 --- a/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java @@ -1,10 +1,37 @@ package com.be08.smart_notes.dto.ai; -import java.util.ArrayList; +import java.util.List; -import com.be08.smart_notes.dto.QuizQuestion; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +@Data +@NoArgsConstructor +@AllArgsConstructor public class QuizResponse { - public String topic; - public ArrayList quizzes; + @NotBlank + private String topic; + @NotNull + private List questions; + + @Data + @AllArgsConstructor + public static class Question { + @NotBlank + private String question; + + @NotNull + @Size(min = 4, max = 4) + private String[] options; + + private int correctIndex; + } } diff --git a/src/main/java/com/be08/smart_notes/service/ai/AIService.java b/src/main/java/com/be08/smart_notes/service/ai/AIService.java index 02d40b9..7248720 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/AIService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/AIService.java @@ -1,16 +1,26 @@ package com.be08.smart_notes.service.ai; +import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStreamReader; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.StandardCharsets; -import java.util.Scanner; +import com.be08.smart_notes.dto.ai.AIInferenceRequest; +import com.be08.smart_notes.dto.ai.QuizResponse; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.module.jsonSchema.JsonSchema; +import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; +import com.google.gson.Gson; import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +@Service public class AIService { @Value("${API_TOKEN}") protected String AI_API_TOKEN; @@ -33,17 +43,56 @@ public class AIService { @Value("${AI_API_TOP_P}") protected double AI_API_TOP_P; - public boolean checkPermission() { - if (AI_API_TOKEN == null || AI_API_URL == null || AI_API_MODEL == null) { - System.out.println("Missing AI_API_TOKEN or AI_API_URL or AI_API_MODEL."); - System.out.println("Please check your .env file and try again."); - return false; - } - return true; - } - - public String fetchResponseFromInferenceProvider(String JSONBody) { - String response = ""; + public String generateContent(String systemPrompt, String noteContent, String guidedSchema) { + // Check environment setup + if (!checkPermission()) { + return null; + } + + // Create inference JSON body + Gson gson = new Gson(); + AIInferenceRequest info = AIInferenceRequest.builder() + .model(AI_API_MODEL) + .temperature(AI_API_TEMPERATURE) + .top_p(AI_API_TOP_P) + .guided_json(guidedSchema) + .messages(new AIInferenceRequest.RequestMessage[] { + new AIInferenceRequest.RequestMessage(AI_API_SYSTEM_ROLE, systemPrompt), + new AIInferenceRequest.RequestMessage(AI_API_USER_ROLE, noteContent) + }) + .build(); + String chatJSON = gson.toJson(info); + + // Fetch and return response from AI API + return fetchResponseFromInferenceProvider(chatJSON); + } + + private boolean checkPermission() { + if (AI_API_TOKEN == null || AI_API_URL == null || AI_API_MODEL == null) { + System.out.println("Missing AI_API_TOKEN or AI_API_URL or AI_API_MODEL."); + System.out.println("Please check your .env file and try again."); + return false; + } + return true; + } + + private String generateGuidedSchema(Class ClassType) { + try { + ObjectMapper mapper = new ObjectMapper(); + JsonSchemaGenerator schemaGenerator = new JsonSchemaGenerator(mapper); + JsonSchema schema = schemaGenerator.generateSchema(ClassType); + + String schemaString = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(schema); + return schemaString; + } catch (JsonProcessingException e) { + System.out.println(e.toString()); + e.printStackTrace(); + } + return null; + } + + private String fetchResponseFromInferenceProvider(String JSONBody) { + StringBuilder response = new StringBuilder(); try { // Send request URL url = new URI(AI_API_URL).toURL(); @@ -65,17 +114,18 @@ public String fetchResponseFromInferenceProvider(String JSONBody) { return ""; } - try (Scanner scanner = new Scanner(connection.getInputStream(), StandardCharsets.UTF_8)) { - while (scanner.hasNextLine()) { - response += scanner.nextLine() + "\n"; - } - } + try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) { + String line = null; + while ((line = bufferedReader.readLine()) != null) { + response.append(line); + } + } connection.disconnect(); } catch (IOException | URISyntaxException e) { System.out.println(e.toString()); e.printStackTrace(); } - return response; + return response.toString(); } } \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index c9f7282..f187b9f 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -11,89 +11,88 @@ import com.google.gson.Gson; import com.be08.smart_notes.common.AppConstants; import com.be08.smart_notes.service.NoteService; -import com.be08.smart_notes.dto.ai.GuidedInferenceRequest; -import com.be08.smart_notes.dto.ai.InferenceRequest; -import com.be08.smart_notes.dto.ai.InferenceRequestMessage; -import com.be08.smart_notes.dto.ai.InferenceResponse; +import com.be08.smart_notes.dto.ai.AIInferenceRequest; +import com.be08.smart_notes.dto.ai.AIInferenceResponse; import com.be08.smart_notes.dto.ai.QuizResponse; import com.be08.smart_notes.model.Document; @Service -public class QuizGenerationService extends AIService { +public class QuizGenerationService { + private String systemPrompt; + private String quizResponseSchema; + + @Autowired + private AIService aiService; @Autowired private NoteService noteService; + public QuizGenerationService() { + try { + systemPrompt = Files.readString( + Path.of(AppConstants.SYSTEM_PROMPT_TEMPLATE_PATH), + StandardCharsets.UTF_8 + ); + quizResponseSchema = Files.readString( + Path.of(AppConstants.QUIZ_RESPONSE_SCHEMA_PATH), + StandardCharsets.UTF_8 + ); + } catch (IOException e) { + System.out.println("An error occurred."); + e.printStackTrace(); + } + } + public QuizResponse generateSampleQuiz() { // Below is sample response of fetchResponseFromInferenceProvider() String responseAsJSONString = "{\"id\":\"chatcmpl-123456\",\"choices\":[{\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null,\"message\":{\"content\":\"{\\\"topic\\\": \\\"Object-Oriented Programming Concepts\\\", \\\"quizzes\\\": [\\n {\\n \\\"question\\\": \\\"What is the primary focus of Object-Oriented Programming?\\\",\\n \\\"options\\\": [\\n \\\"Writing code for specific tasks\\\",\\n \\\"Organizing code around objects\\\",\\n \\\"Implementing algorithms for complex problems\\\",\\n \\\"Using pre-defined functions\\\"\\n ],\\n \\\"correctIndex\\\": 1\\n },\\n {\\n \\\"question\\\": \\\"What is the primary benefit of encapsulation?\\\",\\n \\\"options\\\": [\\n \\\"Hiding internal details of an object\\\",\\n \\\"Allowing easy modification of an object\\\",\\n \\\"Promoting code reusability through inheritance\\\",\\n \\\"Making code more efficient and faster\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"Which of the following is an example of inheritance?\\\",\\n \\\"options\\\": [\\n \\\"Creating a class called 'Dog' that inherits from 'Animal'\\\",\\n \\\"Defining a method called 'calculateArea' within a class\\\",\\n \\\"Using a constructor to initialize an object's properties\\\",\\n \\\"Writing code to display a message on the screen\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"What does polymorphism allow?\\\",\\n \\\"options\\\": [\\n \\\"The same method to behave differently in different classes\\\",\\n \\\"A single method to handle different data types\\\",\\n \\\"Classes to inherit common functionalities from their parent classes\\\",\\n \\\"Objects to access data and methods in a controlled manner\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"What is a constructor in OOP?\\\",\\n \\\"options\\\": [\\n \\\"A method that runs when an object is created\\\",\\n \\\"A method that defines the behavior of an object\\\",\\n \\\"A method that is called repeatedly for a specific task\\\",\\n \\\"A method that handles exceptions and errors\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"What is the role of an interface in OOP?\\\",\\n \\\"options\\\": [\\n \\\"A contract that defines a set of methods that classes must implement\\\",\\n \\\"A blueprint for creating a specific type of object\\\",\\n \\\"A way to communicate between different objects\\\",\\n \\\"A way to store and manage data\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n }\\n]}\",\"refusal\":null,\"role\":\"assistant\",\"audio\":null,\"function_call\":null,\"tool_calls\":[],\"reasoning_content\":null},\"stop_reason\":null}],\"created\":1751445406,\"model\":\"google/gemma-2-2b-it\",\"object\":\"chat.completion\",\"service_tier\":null,\"system_fingerprint\":null,\"usage\":{\"completion_tokens\":531,\"prompt_tokens\":953,\"total_tokens\":1484,\"completion_tokens_details\":null,\"prompt_tokens_details\":null},\"prompt_logprobs\":null}\r\n"; // Extract raw message content from response string Gson gson = new Gson(); - QuizResponse quizResponse = null; try { // Extract raw message content from response string - InferenceResponse inferenceResponse = gson.fromJson(responseAsJSONString, InferenceResponse.class); - String chatMessageContent = inferenceResponse.choices[0].message.content; + AIInferenceResponse inferenceResponse = gson.fromJson(responseAsJSONString, AIInferenceResponse.class); + String chatMessageContent = inferenceResponse.getChoices()[0].getMessage().getContent(); // Parse to object - quizResponse = gson.fromJson(chatMessageContent, QuizResponse.class); + return gson.fromJson(chatMessageContent, QuizResponse.class); } catch (Exception e) { System.out.println(e.toString()); e.printStackTrace(); } - return quizResponse; + return null; } public QuizResponse generateQuizFromNote(int noteId) { - checkPermission(); + if (this.systemPrompt == null) { + return null; + } + // Get note Document selectedNote = noteService.getNote(noteId); - if (selectedNote == null) - return null; + if (selectedNote == null) { + return null; + } - // Prepare JSON Body - String systemPrompt = null; - String noteContent = null; - String guidedSchema = null; - try { - noteContent = selectedNote.getContent(); - systemPrompt = Files.readString(Path.of(AppConstants.SYSTEM_PROMPT_TEMPLATE_PATH), StandardCharsets.UTF_8); - guidedSchema = Files.readString(Path.of(AppConstants.QUIZ_RESPONSE_SCHEMA_PATH), StandardCharsets.UTF_8); - } catch (IOException e) { - System.out.println("An error occurred."); - e.printStackTrace(); - } - if (systemPrompt == null || noteContent == null || guidedSchema == null) - return null; + // Generate content + String generatedContent = aiService.generateContent(this.systemPrompt, selectedNote.getContent(), quizResponseSchema); + if (generatedContent == null || generatedContent.isEmpty()) { + return null; + } - // Create inference request - Gson gson = new Gson(); - InferenceRequestMessage systemMessage = new InferenceRequestMessage(AI_API_SYSTEM_ROLE, systemPrompt); - InferenceRequestMessage userMessage = new InferenceRequestMessage(AI_API_USER_ROLE, noteContent); - InferenceRequest info = new GuidedInferenceRequest(AI_API_MODEL, - new InferenceRequestMessage[] { systemMessage, userMessage }, AI_API_TEMPERATURE, AI_API_TOP_P, - guidedSchema); - String chatJSON = gson.toJson(info); - - // Fetch response from AI API - String responseAsJSONString = fetchResponseFromInferenceProvider(chatJSON); - if (responseAsJSONString.isEmpty()) - return null; - - QuizResponse quizResponse = null; try { // Extract raw message content from response string - InferenceResponse inferenceResponse = gson.fromJson(responseAsJSONString, InferenceResponse.class); - String chatMessageContent = inferenceResponse.choices[0].message.content; + Gson gson = new Gson(); + AIInferenceResponse inferenceResponse = gson.fromJson(generatedContent, AIInferenceResponse.class); + String chatMessageContent = inferenceResponse.getChoices()[0].getMessage().getContent(); // Parse to object - quizResponse = gson.fromJson(chatMessageContent, QuizResponse.class); + return gson.fromJson(chatMessageContent, QuizResponse.class); } catch (Exception e) { System.out.println(e.toString()); e.printStackTrace(); } - return quizResponse; + return null; } } diff --git a/src/main/resources/schemas/quiz_response.json b/src/main/resources/schemas/quiz_response.json index 342f6c1..95be83c 100644 --- a/src/main/resources/schemas/quiz_response.json +++ b/src/main/resources/schemas/quiz_response.json @@ -4,7 +4,7 @@ "topic": { "type": "string" }, - "quizzes": { + "questions": { "type": "array", "items": { "type": "object", @@ -21,12 +21,11 @@ "correctIndex": { "type": "integer" } - } - } + } } } }, "required": [ "topic", - "quizzes" + "questions" ] } \ No newline at end of file diff --git a/target/classes/META-INF/MANIFEST.MF b/target/classes/META-INF/MANIFEST.MF deleted file mode 100644 index e1086ce..0000000 --- a/target/classes/META-INF/MANIFEST.MF +++ /dev/null @@ -1,6 +0,0 @@ -Manifest-Version: 1.0 -Build-Jdk-Spec: 21 -Implementation-Title: smart-notes -Implementation-Version: 0.0.1-SNAPSHOT -Created-By: Maven Integration for Eclipse - From 64a6cd698d79e73f7e564ee66f059a1002b656ef Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Sun, 26 Oct 2025 22:58:31 +1100 Subject: [PATCH 14/63] feat(ai-quiz): use RestClient for sending request to inference provider --- pom.xml | 16 +++ .../smart_notes/config/RestClientConfig.java | 25 ++++ .../dto/ai/AIInferenceRequest.java | 11 +- .../be08/smart_notes/dto/ai/QuizResponse.java | 9 +- .../smart_notes/service/ai/AIService.java | 118 ++++++------------ .../service/ai/QuizGenerationService.java | 21 +--- src/main/resources/schemas/quiz_response.json | 3 +- 7 files changed, 101 insertions(+), 102 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/config/RestClientConfig.java diff --git a/pom.xml b/pom.xml index dce7282..b789d36 100644 --- a/pom.xml +++ b/pom.xml @@ -87,6 +87,22 @@ jackson-module-jsonSchema 2.17.0 + + + com.github.victools + jsonschema-generator + 4.31.1 + + + com.github.victools + jsonschema-module-jackson + 4.31.1 + + + com.github.victools + jsonschema-module-jakarta-validation + 4.31.1 + diff --git a/src/main/java/com/be08/smart_notes/config/RestClientConfig.java b/src/main/java/com/be08/smart_notes/config/RestClientConfig.java new file mode 100644 index 0000000..c92d8ba --- /dev/null +++ b/src/main/java/com/be08/smart_notes/config/RestClientConfig.java @@ -0,0 +1,25 @@ +package com.be08.smart_notes.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestClient; + +@Configuration +public class RestClientConfig { + @Value("${API_TOKEN}") + private String AI_API_TOKEN; + + @Value("${API_URL}") + private String AI_API_URL; + + @Bean + public RestClient nebiusRestClient() { + return RestClient.builder() + .baseUrl(AI_API_URL) + .defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .defaultHeader("Authorization", "Bearer " + AI_API_TOKEN) + .build(); + } +} diff --git a/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java b/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java index 70dec3c..96ace1f 100644 --- a/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java +++ b/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java @@ -1,8 +1,10 @@ package com.be08.smart_notes.dto.ai; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @AllArgsConstructor @@ -14,11 +16,16 @@ public class AIInferenceRequest { // Optional private double temperature; - private double top_p; - private String guided_json; + + @JsonProperty("top_p") + private double topP; + + @JsonProperty("guided_json") + private String guidedJson; // Static nested class @Data + @NoArgsConstructor @AllArgsConstructor public static class RequestMessage { private String role; diff --git a/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java b/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java index 0f388e4..7437c02 100644 --- a/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java @@ -17,21 +17,22 @@ @NoArgsConstructor @AllArgsConstructor public class QuizResponse { - @NotBlank + @NotNull private String topic; @NotNull private List questions; @Data + @NoArgsConstructor @AllArgsConstructor public static class Question { - @NotBlank + @NotNull private String question; @NotNull - @Size(min = 4, max = 4) private String[] options; - private int correctIndex; + @NotNull + private Integer correctIndex; } } diff --git a/src/main/java/com/be08/smart_notes/service/ai/AIService.java b/src/main/java/com/be08/smart_notes/service/ai/AIService.java index 7248720..f6c6097 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/AIService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/AIService.java @@ -1,32 +1,21 @@ package com.be08.smart_notes.service.ai; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.net.HttpURLConnection; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.charset.StandardCharsets; - import com.be08.smart_notes.dto.ai.AIInferenceRequest; -import com.be08.smart_notes.dto.ai.QuizResponse; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.module.jsonSchema.JsonSchema; -import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; -import com.google.gson.Gson; +import com.be08.smart_notes.dto.ai.AIInferenceResponse; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.victools.jsonschema.generator.*; +import com.github.victools.jsonschema.module.jackson.JacksonModule; +import com.github.victools.jsonschema.module.jakarta.validation.JakartaValidationModule; +import com.github.victools.jsonschema.module.jakarta.validation.JakartaValidationOption; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; @Service public class AIService { - @Value("${API_TOKEN}") - protected String AI_API_TOKEN; - - @Value("${API_URL}") - protected String AI_API_URL; + @Autowired + private RestClient restClient; @Value("${MODEL}") protected String AI_API_MODEL; @@ -50,82 +39,55 @@ public String generateContent(String systemPrompt, String noteContent, String gu } // Create inference JSON body - Gson gson = new Gson(); AIInferenceRequest info = AIInferenceRequest.builder() .model(AI_API_MODEL) .temperature(AI_API_TEMPERATURE) - .top_p(AI_API_TOP_P) - .guided_json(guidedSchema) + .topP(AI_API_TOP_P) + .guidedJson(guidedSchema) .messages(new AIInferenceRequest.RequestMessage[] { new AIInferenceRequest.RequestMessage(AI_API_SYSTEM_ROLE, systemPrompt), new AIInferenceRequest.RequestMessage(AI_API_USER_ROLE, noteContent) }) .build(); - String chatJSON = gson.toJson(info); // Fetch and return response from AI API - return fetchResponseFromInferenceProvider(chatJSON); + AIInferenceResponse inferenceResponse = fetchResponseFromInferenceProvider(info); + return inferenceResponse.getChoices()[0].getMessage().getContent(); } private boolean checkPermission() { - if (AI_API_TOKEN == null || AI_API_URL == null || AI_API_MODEL == null) { - System.out.println("Missing AI_API_TOKEN or AI_API_URL or AI_API_MODEL."); + if (AI_API_MODEL == null) { + System.out.println("Missing AI_API_MODEL."); System.out.println("Please check your .env file and try again."); return false; } return true; } + private AIInferenceResponse fetchResponseFromInferenceProvider(AIInferenceRequest info) { + AIInferenceResponse response = restClient.post() + .body(info) + .retrieve() + .body(AIInferenceResponse.class); + return response; + } + + // Unused methods, will debug later private String generateGuidedSchema(Class ClassType) { - try { - ObjectMapper mapper = new ObjectMapper(); - JsonSchemaGenerator schemaGenerator = new JsonSchemaGenerator(mapper); - JsonSchema schema = schemaGenerator.generateSchema(ClassType); - - String schemaString = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(schema); - return schemaString; - } catch (JsonProcessingException e) { - System.out.println(e.toString()); - e.printStackTrace(); - } - return null; - } + SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON); - private String fetchResponseFromInferenceProvider(String JSONBody) { - StringBuilder response = new StringBuilder(); - try { - // Send request - URL url = new URI(AI_API_URL).toURL(); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - - connection.setRequestMethod("POST"); - connection.setRequestProperty("Content-Type", "application/json"); - connection.setRequestProperty("Authorization", "Bearer " + AI_API_TOKEN); - connection.setDoOutput(true); - - try (OutputStream os = connection.getOutputStream()) { - os.write(JSONBody.getBytes(StandardCharsets.UTF_8)); - } - - // Handle response - int responseCode = connection.getResponseCode(); - if (responseCode != HttpURLConnection.HTTP_OK) { - System.out.println("Error: HTTP Response code - " + responseCode); - return ""; - } - - try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) { - String line = null; - while ((line = bufferedReader.readLine()) != null) { - response.append(line); - } - } - - connection.disconnect(); - } catch (IOException | URISyntaxException e) { - System.out.println(e.toString()); - e.printStackTrace(); - } - return response.toString(); - } + JakartaValidationModule validationModule = new JakartaValidationModule( + JakartaValidationOption.NOT_NULLABLE_FIELD_IS_REQUIRED, + JakartaValidationOption.INCLUDE_PATTERN_EXPRESSIONS + ); + configBuilder.with(new JacksonModule()); + configBuilder.with(validationModule); + + SchemaGeneratorConfig config = configBuilder.build(); + SchemaGenerator generator = new SchemaGenerator(config); + ObjectNode jsonSchema = generator.generateSchema(ClassType); + jsonSchema.remove("$schema"); + + return jsonSchema.toString(); + } } \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index f187b9f..10ffb76 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -11,7 +11,6 @@ import com.google.gson.Gson; import com.be08.smart_notes.common.AppConstants; import com.be08.smart_notes.service.NoteService; -import com.be08.smart_notes.dto.ai.AIInferenceRequest; import com.be08.smart_notes.dto.ai.AIInferenceResponse; import com.be08.smart_notes.dto.ai.QuizResponse; import com.be08.smart_notes.model.Document; @@ -75,24 +74,12 @@ public QuizResponse generateQuizFromNote(int noteId) { } // Generate content - String generatedContent = aiService.generateContent(this.systemPrompt, selectedNote.getContent(), quizResponseSchema); + String generatedContent = aiService.generateContent(this.systemPrompt, selectedNote.getContent(), quizResponseSchema); if (generatedContent == null || generatedContent.isEmpty()) { return null; } - try { - // Extract raw message content from response string - Gson gson = new Gson(); - AIInferenceResponse inferenceResponse = gson.fromJson(generatedContent, AIInferenceResponse.class); - String chatMessageContent = inferenceResponse.getChoices()[0].getMessage().getContent(); - - // Parse to object - return gson.fromJson(chatMessageContent, QuizResponse.class); - } catch (Exception e) { - System.out.println(e.toString()); - e.printStackTrace(); - } - - return null; - } + Gson gson = new Gson(); + return gson.fromJson(generatedContent, QuizResponse.class); + } } diff --git a/src/main/resources/schemas/quiz_response.json b/src/main/resources/schemas/quiz_response.json index 95be83c..abdfafb 100644 --- a/src/main/resources/schemas/quiz_response.json +++ b/src/main/resources/schemas/quiz_response.json @@ -21,7 +21,8 @@ "correctIndex": { "type": "integer" } - } } + } + } } }, "required": [ From 0eb2ce7ad951468619266eb6c64e52f41955ced2 Mon Sep 17 00:00:00 2001 From: Phu Vo <168263467+pvdev1805@users.noreply.github.com> Date: Sun, 26 Oct 2025 22:45:34 +1000 Subject: [PATCH 15/63] Feature/auth login (#2) * :hammer: feat(auth-login): Handle login user (without Spring Security: JWT + BCrypt) * :wrench: feat(auth-login): Implement BCrypt hasing and refactor AuthenticationService * :recycle: refactor(auth-login): Refactor UserResponse and AuthenticationService * :passport_control: feat(auth-login): Handle Access Token and Refresh Token using JWT with RSA key pair * :passport_control: feat(auth-login): Apply KeyStore to save public and private key * :passport_control: feat(auth-login): Handle refresh Access Token + Refresh Token requests * :hammer: feat(auth-me): Handle /me endpoint for current user profile * :lock: feat(auth-security): Implement custom AuthenticationEntryPoint for consistent 401 JSON response --- pom.xml | 42 +++++ .../smart_notes/common/SecurityConstants.java | 9 ++ ...omBearerTokenAuthenticationEntryPoint.java | 40 +++++ .../smart_notes/config/JwtTokenConfig.java | 150 ++++++++++++++++++ .../smart_notes/config/SecurityConfig.java | 62 ++++++++ .../controller/AuthenticationController.java | 43 ++++- .../controller/UserController.java | 33 ++++ .../smart_notes/dto/request/LoginRequest.java | 21 +++ .../dto/request/RefreshTokenRequest.java | 15 ++ .../dto/request/UserCreationRequest.java | 1 + .../dto/response/AuthenticationResponse.java | 2 + .../dto/response/UserResponse.java | 3 +- .../be08/smart_notes/exception/ErrorCode.java | 13 +- .../be08/smart_notes/mapper/UserMapper.java | 4 + .../repository/UserRepository.java | 3 + .../service/AuthenticationService.java | 106 +++++++++---- .../be08/smart_notes/service/JwtService.java | 85 ++++++++++ .../be08/smart_notes/service/UserService.java | 29 ++++ src/main/resources/application.properties | 15 +- src/main/resources/keys/jwt-keystore.jks | Bin 0 -> 2240 bytes 20 files changed, 638 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/common/SecurityConstants.java create mode 100644 src/main/java/com/be08/smart_notes/config/CustomBearerTokenAuthenticationEntryPoint.java create mode 100644 src/main/java/com/be08/smart_notes/config/JwtTokenConfig.java create mode 100644 src/main/java/com/be08/smart_notes/config/SecurityConfig.java create mode 100644 src/main/java/com/be08/smart_notes/controller/UserController.java create mode 100644 src/main/java/com/be08/smart_notes/dto/request/LoginRequest.java create mode 100644 src/main/java/com/be08/smart_notes/dto/request/RefreshTokenRequest.java create mode 100644 src/main/java/com/be08/smart_notes/service/JwtService.java create mode 100644 src/main/resources/keys/jwt-keystore.jks diff --git a/pom.xml b/pom.xml index 85d38d2..02aaf5d 100644 --- a/pom.xml +++ b/pom.xml @@ -58,9 +58,12 @@ test + + org.springframework.boot spring-boot-starter-validation + 3.5.6 @@ -95,6 +98,45 @@ ${mapstruct.version} + + + + org.springframework.security + spring-security-crypto + 7.0.0-RC1 + + + + + + org.springframework.boot + spring-boot-starter-security + 3.5.6 + + + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + 3.5.7 + + + + + + com.nimbusds + nimbus-jose-jwt + 10.5 + + + + + + org.springframework.security + spring-security-oauth2-jose + + diff --git a/src/main/java/com/be08/smart_notes/common/SecurityConstants.java b/src/main/java/com/be08/smart_notes/common/SecurityConstants.java new file mode 100644 index 0000000..6784ece --- /dev/null +++ b/src/main/java/com/be08/smart_notes/common/SecurityConstants.java @@ -0,0 +1,9 @@ +package com.be08.smart_notes.common; + +public class SecurityConstants { + public static final String[] PUBLIC_ENDPOINTS = { + "/api/auth/register", + "/api/auth/login", + "/api/auth/refresh" + }; +} diff --git a/src/main/java/com/be08/smart_notes/config/CustomBearerTokenAuthenticationEntryPoint.java b/src/main/java/com/be08/smart_notes/config/CustomBearerTokenAuthenticationEntryPoint.java new file mode 100644 index 0000000..7691634 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/config/CustomBearerTokenAuthenticationEntryPoint.java @@ -0,0 +1,40 @@ +package com.be08.smart_notes.config; + +import com.be08.smart_notes.dto.response.ApiResponse; +import com.be08.smart_notes.exception.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; + +public class CustomBearerTokenAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + + public CustomBearerTokenAuthenticationEntryPoint(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + ErrorCode errorCode = ErrorCode.UNAUTHORIZED; + + // Create ApiResponse with error details + ApiResponse apiResponse = ApiResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + + // Set HTTP response status and content type + response.setStatus(errorCode.getStatusCode().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + // Write ApiResponse to response body as JSON in output stream + response.getWriter().write(objectMapper.writeValueAsString(apiResponse)); + response.getWriter().flush(); + } +} diff --git a/src/main/java/com/be08/smart_notes/config/JwtTokenConfig.java b/src/main/java/com/be08/smart_notes/config/JwtTokenConfig.java new file mode 100644 index 0000000..5edb328 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/config/JwtTokenConfig.java @@ -0,0 +1,150 @@ +package com.be08.smart_notes.config; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.KeySourceException; +import com.nimbusds.jose.jwk.*; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import lombok.SneakyThrows; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +import java.io.InputStream; +import java.security.*; +import java.security.cert.Certificate; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.LinkedList; +import java.util.List; + +@Configuration +public class JwtTokenConfig { + @Value("${jwt.keystore.location}") + private Resource keyStoreResource; + + @Value("${jwt.keystore.password}") + private String keyStorePassword; + + @Value("${jwt.key.password}") + private String keyPassword; + + // Alias of the key used to SIGN new tokens (CURRENT key) + @Value("${jwt.signing.key.alias}") + private String signingKeyAlias; + + // List of aliases used to VERIFY old/current tokens + @Value("${jwt.verification.key.aliases}") + private String[] verificationKeyAliases; + + /** + * Loads ALL keys from the KeyStore specified in 'verificationKeyAliases' + * and wraps them in a JWKSource for validation purposes (Key Rotation). + * @return JWKSource containing all Public Keys for verification + */ + @Bean + @SneakyThrows + public JWKSource jwkSource() { + List jwkList = new LinkedList<>(); + + try (InputStream inputStream = keyStoreResource.getInputStream()) { + KeyStore keyStore = KeyStore.getInstance("JKS"); + keyStore.load(inputStream, keyStorePassword.toCharArray()); + + for (String alias : verificationKeyAliases) { + Key key = keyStore.getKey(alias, keyPassword.toCharArray()); + Certificate cert = keyStore.getCertificate(alias); + + if (key instanceof PrivateKey && cert != null) { + // Create RSAKey with both Public and Private Key + RSAKey rsaKey = new RSAKey.Builder((RSAPublicKey) cert.getPublicKey()) + .privateKey((RSAPrivateKey) key) + .keyID(alias) // Use alias as Key ID (kid) for JWS Header + .build(); + jwkList.add(rsaKey); + } + } + } + + if (jwkList.isEmpty()) { + throw new IllegalStateException("No valid JWK found for signing or verification."); + } + + // Returns an ImmutableJWKSet containing all loaded keys for verification + return new ImmutableJWKSet<>(new JWKSet(jwkList)); + } + + /** + * Creates a KeyPair bean containing ONLY the current signing key. + * This KeyPair is used to extract the PrivateKey for signing new tokens. + * @return KeyPair containing the current signing key + */ + @Bean + @SneakyThrows + public KeyPair signingKeyPair() { + try (InputStream inputStream = keyStoreResource.getInputStream()) { + KeyStore keyStore = KeyStore.getInstance("JKS"); + keyStore.load(inputStream, keyStorePassword.toCharArray()); + + Key key = keyStore.getKey(signingKeyAlias, keyPassword.toCharArray()); + Certificate cert = keyStore.getCertificate(signingKeyAlias); + + if (key instanceof PrivateKey && cert != null) { + return new KeyPair(cert.getPublicKey(), (PrivateKey) key); + } + } + throw new IllegalStateException("Signing KeyPair not found or invalid for alias: " + signingKeyAlias); + } + + /** + * JwtEncoder (used for creating new tokens): + * Uses the Private Key from the signingKeyPair() for signing. + * @param signingKeyPair KeyPair containing the current signing key + * @return JwtEncoder for signing new tokens + */ + @Bean + JwtEncoder jwtEncoder(KeyPair signingKeyPair) { + RSAPublicKey publicKey = (RSAPublicKey) signingKeyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) signingKeyPair.getPrivate(); + + // The key ID is crucial for the JwtEncoder to sign the token with the correct 'kid' + RSAKey rsaKey = new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID(signingKeyAlias) + .build(); + + JWKSource jwks = new ImmutableJWKSet<>(new JWKSet(rsaKey)); + return new NimbusJwtEncoder(jwks); + } + + /** + * JwtDecoder (used for verifying tokens): + * Uses the list of Public Keys from the jwkSource() for verification (Key Rotation). + * @param jwkSource JWKSource containing all Public Keys for verification + * @return JwtDecoder for verifying tokens + */ + @Bean + JwtDecoder jwtDecoder(JWKSource jwkSource) { + + ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); + + // Configures the processor to use the JWKSource for key selection based on JWS header 'kid' + JWSKeySelector keySelector = + new JWSVerificationKeySelector<>( + JWSAlgorithm.RS256, jwkSource); + jwtProcessor.setJWSKeySelector(keySelector); + + // This is the correct way to wrap the Nimbus processor in the Spring JwtDecoder + return new NimbusJwtDecoder(jwtProcessor); + } +} diff --git a/src/main/java/com/be08/smart_notes/config/SecurityConfig.java b/src/main/java/com/be08/smart_notes/config/SecurityConfig.java new file mode 100644 index 0000000..964c353 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/config/SecurityConfig.java @@ -0,0 +1,62 @@ +package com.be08.smart_notes.config; + +import com.be08.smart_notes.common.SecurityConstants; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public ObjectMapper objectMapper(){ + return new ObjectMapper(); + } + + @Bean + PasswordEncoder passwordEncoder(){ + return new BCryptPasswordEncoder(10); + } + + /** + * Configure security filter chain + * @param httpSecurity + * @return Configured SecurityFilterChain + * @throws Exception + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { + httpSecurity + // Disable CSRF protection for stateless REST APIs + .csrf(AbstractHttpConfigurer::disable) + + // Define authorization rules + .authorizeHttpRequests(authorize -> authorize + // Permit all requests to public authentication endpoints + .requestMatchers(SecurityConstants.PUBLIC_ENDPOINTS) + .permitAll() + + // Require authentication for all other requests + .anyRequest().authenticated() + ) + // Configure OAuth2 Resource Server to use JWT for authentication + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(Customizer.withDefaults()) + // Custom authentication entry point for handling auth errors + .authenticationEntryPoint(new CustomBearerTokenAuthenticationEntryPoint(objectMapper())) + ) + // Set session management to stateless + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + return httpSecurity.build(); + } +} diff --git a/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java b/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java index cef9927..1d066f5 100644 --- a/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java +++ b/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java @@ -1,10 +1,13 @@ package com.be08.smart_notes.controller; +import com.be08.smart_notes.dto.request.LoginRequest; +import com.be08.smart_notes.dto.request.RefreshTokenRequest; import com.be08.smart_notes.dto.request.UserCreationRequest; import com.be08.smart_notes.dto.response.ApiResponse; import com.be08.smart_notes.dto.response.AuthenticationResponse; import com.be08.smart_notes.dto.response.UserResponse; import com.be08.smart_notes.service.AuthenticationService; +import com.be08.smart_notes.service.UserService; import jakarta.validation.Valid; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; @@ -20,13 +23,47 @@ @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class AuthenticationController { AuthenticationService authenticationService; + UserService userService; + /** + * User Registration + * @param request + * @return ApiResponse containing UserResponse + */ @PostMapping("/register") - ApiResponse register(@RequestBody @Valid UserCreationRequest request){ - AuthenticationResponse response = authenticationService.register(request); - return ApiResponse.builder() + ApiResponse register(@RequestBody @Valid UserCreationRequest request){ + UserResponse response = userService.createUser(request); + return ApiResponse.builder() .message("User registered successfully") .data(response) .build(); } + + /** + * User Login + * @param request + * @return ApiResponse containing AuthenticationResponse + */ + @PostMapping("/login") + ApiResponse login(@RequestBody @Valid LoginRequest request){ + AuthenticationResponse response = authenticationService.login(request); + return ApiResponse.builder() + .message("User logged in successfully") + .data(response) + .build(); + } + + /** + * Refresh JWT Token + * @param request + * @return ApiResponse containing new AuthenticationResponse + */ + @PostMapping("/refresh") + ApiResponse refreshToken(@RequestBody @Valid RefreshTokenRequest request){ + AuthenticationResponse response = authenticationService.refreshToken(request); + return ApiResponse.builder() + .message("Token refreshed successfully") + .data(response) + .build(); + } } diff --git a/src/main/java/com/be08/smart_notes/controller/UserController.java b/src/main/java/com/be08/smart_notes/controller/UserController.java new file mode 100644 index 0000000..b77239a --- /dev/null +++ b/src/main/java/com/be08/smart_notes/controller/UserController.java @@ -0,0 +1,33 @@ +package com.be08.smart_notes.controller; + +import com.be08.smart_notes.dto.response.ApiResponse; +import com.be08.smart_notes.dto.response.UserResponse; +import com.be08.smart_notes.service.UserService; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class UserController { + UserService userService; + + /** + * Get details of the currently authenticated user + * @return ApiResponse containing UserResponse + */ + @GetMapping("/me") + public ApiResponse getMe(){ + UserResponse response = userService.getMe(); + + return ApiResponse.builder() + .message("User details retrieved successfully!") + .data(response) + .build(); + } +} diff --git a/src/main/java/com/be08/smart_notes/dto/request/LoginRequest.java b/src/main/java/com/be08/smart_notes/dto/request/LoginRequest.java new file mode 100644 index 0000000..f35418f --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/request/LoginRequest.java @@ -0,0 +1,21 @@ +package com.be08.smart_notes.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.*; +import lombok.experimental.FieldDefaults; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +@ToString(exclude = {"password"}) +public class LoginRequest { + @NotBlank(message = "EMAIL_EMPTY") + @Email(message = "INVALID_EMAIL_FORMAT") + String email; + + @NotBlank(message = "PASSWORD_EMPTY") + String password; +} diff --git a/src/main/java/com/be08/smart_notes/dto/request/RefreshTokenRequest.java b/src/main/java/com/be08/smart_notes/dto/request/RefreshTokenRequest.java new file mode 100644 index 0000000..7177fd7 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/request/RefreshTokenRequest.java @@ -0,0 +1,15 @@ +package com.be08.smart_notes.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.*; +import lombok.experimental.FieldDefaults; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class RefreshTokenRequest { + @NotBlank(message = "REFRESH_TOKEN_EMPTY") + String refreshToken; +} diff --git a/src/main/java/com/be08/smart_notes/dto/request/UserCreationRequest.java b/src/main/java/com/be08/smart_notes/dto/request/UserCreationRequest.java index 4972d30..465fef6 100644 --- a/src/main/java/com/be08/smart_notes/dto/request/UserCreationRequest.java +++ b/src/main/java/com/be08/smart_notes/dto/request/UserCreationRequest.java @@ -12,6 +12,7 @@ @AllArgsConstructor @Builder @FieldDefaults(level = AccessLevel.PRIVATE) +@ToString(exclude = {"password"}) public class UserCreationRequest { @NotBlank(message = "NAME_EMPTY") String name; diff --git a/src/main/java/com/be08/smart_notes/dto/response/AuthenticationResponse.java b/src/main/java/com/be08/smart_notes/dto/response/AuthenticationResponse.java index 294b680..7aad9f4 100644 --- a/src/main/java/com/be08/smart_notes/dto/response/AuthenticationResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/response/AuthenticationResponse.java @@ -10,4 +10,6 @@ @FieldDefaults(level = AccessLevel.PRIVATE) public class AuthenticationResponse { boolean isAuthenticated; + String accessToken; + String refreshToken; } diff --git a/src/main/java/com/be08/smart_notes/dto/response/UserResponse.java b/src/main/java/com/be08/smart_notes/dto/response/UserResponse.java index 26ac79c..6b6c3f5 100644 --- a/src/main/java/com/be08/smart_notes/dto/response/UserResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/response/UserResponse.java @@ -16,7 +16,6 @@ public class UserResponse { String email; String avatarUrl; - @Builder.Default - LocalDateTime createdAt = LocalDateTime.now(); // Default to current time + LocalDateTime createdAt; LocalDateTime updatedAt; } diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java index 3154fbf..e411e29 100644 --- a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -11,8 +11,14 @@ @AllArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE) public enum ErrorCode { + // 99xx - General errors UNCATEGORIZED(9999, "Uncategorized error", HttpStatus.INTERNAL_SERVER_ERROR), + // Used when an invalid error code key is provided INVALID_ERROR_CODE_KEY(9998, "Invalid error code key", HttpStatus.INTERNAL_SERVER_ERROR), + // Used when authentication is required but not provided + UNAUTHORIZED(9997, "Unauthorized: Full authentication is required to access this resource", HttpStatus.UNAUTHORIZED), + + // 20xx - User-related errors USER_EXISTS(2001, "User already exists", HttpStatus.BAD_REQUEST), NAME_EMPTY(2002, "Name cannot be empty", HttpStatus.BAD_REQUEST), EMAIL_EMPTY(2003, "Email cannot be empty", HttpStatus.BAD_REQUEST), @@ -20,7 +26,12 @@ public enum ErrorCode { INVALID_EMAIL_SIZE(2005, "Email must be less than 255 characters", HttpStatus.BAD_REQUEST), PASSWORD_EMPTY(2006, "Password cannot be empty", HttpStatus.BAD_REQUEST), INVALID_PASSWORD_SIZE(2007, "Password must be at least 8 characters long", HttpStatus.BAD_REQUEST), - INVALID_PASSWORD_PATTERN(2008, "Password must contain at least one uppercase letter, one lowercase letter, and one digit, and no whitespace", HttpStatus.BAD_REQUEST) + INVALID_PASSWORD_PATTERN(2008, "Password must contain at least one uppercase letter, one lowercase letter, and one digit, and no whitespace", HttpStatus.BAD_REQUEST), + USER_NOT_FOUND(2009, "User not found", HttpStatus.NOT_FOUND), + UNAUTHENTICATED(2010, "Unauthenticated access", HttpStatus.UNAUTHORIZED), + + // 21xx - Token-related errors + REFRESH_TOKEN_EMPTY(2102, "Refresh token cannot be empty", HttpStatus.BAD_REQUEST), ; int code; diff --git a/src/main/java/com/be08/smart_notes/mapper/UserMapper.java b/src/main/java/com/be08/smart_notes/mapper/UserMapper.java index 9812166..108ed15 100644 --- a/src/main/java/com/be08/smart_notes/mapper/UserMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/UserMapper.java @@ -10,6 +10,10 @@ @Mapper(componentModel = "spring") public interface UserMapper { + @Mapping(target = "id", ignore = true) + @Mapping(target = "avatarUrl", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) User toUser(UserCreationRequest request); UserResponse toUserResponse(User user); diff --git a/src/main/java/com/be08/smart_notes/repository/UserRepository.java b/src/main/java/com/be08/smart_notes/repository/UserRepository.java index 084be66..c3cd65e 100644 --- a/src/main/java/com/be08/smart_notes/repository/UserRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/UserRepository.java @@ -5,7 +5,10 @@ import com.be08.smart_notes.model.User; +import java.util.Optional; + @Repository public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); boolean existsByEmail(String email); } diff --git a/src/main/java/com/be08/smart_notes/service/AuthenticationService.java b/src/main/java/com/be08/smart_notes/service/AuthenticationService.java index 3dc76f8..cbd1823 100644 --- a/src/main/java/com/be08/smart_notes/service/AuthenticationService.java +++ b/src/main/java/com/be08/smart_notes/service/AuthenticationService.java @@ -1,63 +1,107 @@ package com.be08.smart_notes.service; -import com.be08.smart_notes.dto.request.UserCreationRequest; +import com.be08.smart_notes.dto.request.LoginRequest; +import com.be08.smart_notes.dto.request.RefreshTokenRequest; import com.be08.smart_notes.dto.response.AuthenticationResponse; import com.be08.smart_notes.exception.AppException; import com.be08.smart_notes.exception.ErrorCode; -import com.be08.smart_notes.mapper.UserMapper; import com.be08.smart_notes.model.User; import com.be08.smart_notes.repository.UserRepository; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; import lombok.extern.slf4j.Slf4j; -import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.stereotype.Service; -import java.time.LocalDateTime; - @Service @RequiredArgsConstructor -@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@FieldDefaults(level = AccessLevel.PRIVATE) @Slf4j public class AuthenticationService { - UserRepository userRepository; - UserMapper userMapper; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final JwtDecoder jwtDecoder; + + // Inject the key alias to be used for signing tokens + @Value("${jwt.signing.key.alias}") + private String signingKeyAlias; - public AuthenticationResponse register(UserCreationRequest request){ + /** + * Authenticate user and generate JWT tokens + * @param request + * @return AuthenticationResponse containing access and refresh tokens + */ + public AuthenticationResponse login(LoginRequest request) { String email = request.getEmail(); - log.info("Registering user with email: {}", email); - String password = request.getPassword(); - // basic business-level check (defense in depth) - if (email == null || email.isBlank()) { - throw new IllegalArgumentException("Email is required"); - } - if (password == null || password.length() < 8) { - throw new IllegalArgumentException("Password must be at least 8 characters"); - } + User user = userRepository.findByEmail(email) + .orElseThrow(() -> { + log.warn("Login Failed: User with email {} not found", email); + return new AppException(ErrorCode.UNAUTHENTICATED); + }); - if(userRepository.existsByEmail(email)){ - log.error("User with email {} already exists", email); - throw new AppException(ErrorCode.USER_EXISTS); + String rawPassword = request.getPassword(); + String hashedPassword = user.getPassword(); + + if(!passwordEncoder.matches(rawPassword, hashedPassword)){ + log.warn("Login Failed: Invalid password for user with email {}", email); + throw new AppException(ErrorCode.UNAUTHENTICATED); } - User user = userMapper.toUser(request); - user.setCreatedAt(LocalDateTime.now()); + // Create Access Token and Refresh Token, passing the key alias + String accessToken = jwtService.generateAccessToken(user, signingKeyAlias); + String refreshToken = jwtService.generateRefreshToken(user, signingKeyAlias); + + log.info("User with email {} authenticated successfully", email); + return AuthenticationResponse.builder() + .isAuthenticated(true) + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + /** + * Refresh JWT tokens using a valid refresh token + * @param request + * @return AuthenticationResponse containing new access and refresh tokens + */ + public AuthenticationResponse refreshToken(RefreshTokenRequest request){ + String refreshToken = request.getRefreshToken(); + + int userId; try { - userRepository.save(user); - - return AuthenticationResponse.builder() - .isAuthenticated(true) - .build(); - } catch (DataIntegrityViolationException exception){ - // final safeguard for concurrent inserts — DB unique constraint - log.error("Data integrity violation while creating user with email {}: {}", email, exception.getMessage()); + // Decode and validate the refresh token + Jwt jwt = jwtDecoder.decode(refreshToken); + + // Extract user ID from token subject + userId = Integer.parseInt(jwt.getSubject()); + } catch (JwtException exception) { + log.warn("Refresh Token failed validation: {}", exception.getMessage()); + throw new AppException(ErrorCode.UNAUTHENTICATED); } + User user = userRepository.findById(userId) + .orElseThrow(() -> { + log.warn("Refresh Token Failed: User with ID {} not found", userId); + return new AppException(ErrorCode.UNAUTHENTICATED); + }); + + String newAccessToken = jwtService.generateAccessToken(user, signingKeyAlias); + String newRefreshToken = jwtService.generateRefreshToken(user, signingKeyAlias); + + log.info("New tokens generated successfully for user ID {}", userId); + return AuthenticationResponse.builder() .isAuthenticated(true) + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) .build(); } } diff --git a/src/main/java/com/be08/smart_notes/service/JwtService.java b/src/main/java/com/be08/smart_notes/service/JwtService.java new file mode 100644 index 0000000..489fdc0 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/JwtService.java @@ -0,0 +1,85 @@ +package com.be08.smart_notes.service; + +import com.be08.smart_notes.model.User; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) +public class JwtService { + + private final JwtEncoder jwtEncoder; + + // Injected from application.properties + @Value("${jwt.access-token.expiration-minutes}") + private long ACCESS_TOKEN_EXPIRATION_MINUTES; + + @Value("${jwt.refresh-token.expiration-days}") + private long REFRESH_TOKEN_EXPIRATION_DAYS; + + /** + * Generates a JWT token for the given user with specified expiration and key ID. + * @param user + * @param expirationDuration + * @param unit + * @param keyId + * @return Generated JWT token as String + */ + public String generateToken(User user, long expirationDuration, ChronoUnit unit, String keyId) { + Instant now = Instant.now(); + + // 1. Define claims (Payload) + JwtClaimsSet claims = JwtClaimsSet.builder() + .issuer("smart-notes-auth-server") + .issuedAt(now) + .expiresAt(now.plus(expirationDuration, unit)) + .subject(String.valueOf(user.getId())) // Subject is User ID + .claim("email", user.getEmail()) + .claim("scope", "USER") + .build(); + + // 2. Add Key ID (kid) header parameter to identify the signing key + // This is crucial for Key Rotation to work in JwtDecoder + JwtEncoderParameters encoderParameters = JwtEncoderParameters.from( + JwsHeader.with(SignatureAlgorithm.RS256).keyId(keyId).build(), + claims + ); + + // 3. Encode and Sign the token + return this.jwtEncoder.encode(encoderParameters).getTokenValue(); + } + + /** + * Access Token generation + * @param user + * @param keyId + * @return Generated Access Token as String + */ + public String generateAccessToken(User user, String keyId) { + return generateToken(user, ACCESS_TOKEN_EXPIRATION_MINUTES, ChronoUnit.MINUTES, keyId); + } + + /** + * Refresh Token generation + * @param user + * @param keyId + * @return Generated Refresh Token as String + */ + public String generateRefreshToken(User user, String keyId) { + return generateToken(user, REFRESH_TOKEN_EXPIRATION_DAYS, ChronoUnit.DAYS, keyId); + } +} diff --git a/src/main/java/com/be08/smart_notes/service/UserService.java b/src/main/java/com/be08/smart_notes/service/UserService.java index 8b52660..cf2fc9a 100644 --- a/src/main/java/com/be08/smart_notes/service/UserService.java +++ b/src/main/java/com/be08/smart_notes/service/UserService.java @@ -11,6 +11,8 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -23,6 +25,8 @@ public class UserService { UserRepository userRepository; UserMapper userMapper; + PasswordEncoder passwordEncoder; + public UserResponse createUser(UserCreationRequest request){ String email = request.getEmail(); @@ -34,6 +38,31 @@ public UserResponse createUser(UserCreationRequest request){ User user = userMapper.toUser(request); user.setCreatedAt(LocalDateTime.now()); + // Encode the password before saving + user.setPassword(passwordEncoder.encode(request.getPassword())); + + log.info("User with email {} registered successfully", email); + return userMapper.toUserResponse(userRepository.save(user)); } + + /** + * Get the currently authenticated user's details + * @return UserResponse of the authenticated user + */ + public UserResponse getMe(){ + // Get the security context + var context = SecurityContextHolder.getContext(); + + // Extract user ID from authentication principal + int userId = Integer.parseInt(context.getAuthentication().getName()); + + // Fetch user from repository + User user = userRepository.findById(userId).orElseThrow(() -> { + log.error("User with id {} not found", userId); + return new AppException(ErrorCode.USER_NOT_FOUND); + }); + + return userMapper.toUserResponse(user); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8c3da34..e87bdaa 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -11,4 +11,17 @@ spring.datasource.password=${DB_PASSWORD} spring.datasource.diver-class-name =com.mysql.cj.jdbc.Driver spring.jpa.show-sql=true -spring.jpa.properties.hibernate.format_sql=true \ No newline at end of file +spring.jpa.properties.hibernate.format_sql=true + +# JWT Configuration +jwt.access-token.expiration-minutes=15 +jwt.refresh-token.expiration-days=7 + +# JWT KeyStore Configuration +jwt.keystore.location=classpath:keys/jwt-keystore.jks +jwt.keystore.password=PhuVoDev +jwt.key.password=AliceTat + +# JWT Key Rotation Configuration +jwt.signing.key.alias=jwtkey-2025 +jwt.verification.key.aliases=jwtkey-2025 \ No newline at end of file diff --git a/src/main/resources/keys/jwt-keystore.jks b/src/main/resources/keys/jwt-keystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..0d32d1424def5d2d5acd22d4c33af49a8e04d831 GIT binary patch literal 2240 zcmchX`8O1LAIE2x7~2ThhBTIh8CxM_O}3sPObd-+j4d;kv0YokMRtZpC~Lact{7VE z%TSRBk(#o_Z6sUPcy!Nm&$)lW^TYdl&g=dC;rl)B&*!}Nm-d$c008Jvz`w=g9TMb2 z3{z1@t7{#4c@h!@0RRBx08NAJqIqFQm0>^#@DxZ82!zlei=$I)b2?NFJN%$#ZF~UL zGmf+#-a9_MJOVIvGKHvtrkzr1+^Plf#gx1m9e=5uKI+2-l8FbloLS9K0m#w4Of}W+ zwb(egkSx*N28bz4){wbK4h>w(w?czutb#=eUCB>GR3B3U?Hb$mdb5Uq zgl9;^x`kM+J)Ur*6`d2xG|z(TYC-SC%-S{65%x;}hcRlip) z@;5Tp#%9|n=Zxx4+3pIo&(=0D-ffvbq&(QY;bJo(sEpv%E8mf}HF^6%Q+lEyx=;wk zOUmHpy3R5{xM0fIqPZnPW7VlAGf8Q2UYq1DZ2EWV>7-X0idQpM9V5DZx=4w$0xM)!z-VH!vpbBL%i0f9E=aOzKUOw%PH0AF9I2YctTa zyQGq$QaUu$J4vrqmoPEwIdFXaML4DBFM|Wl{o==#5B8O+@p3b!A>-9X9G4`y#^Fa5 z(t`~vuMDiE(p>KV6ZkfZzAthdjrvIfflTh7hz+ga^gLMcI$*<0<2T-sCV&8we~Pn~ z>qaT`;n04HS~V)+@fT~#7;l0a75@l(FI8!(aZ-UdLAO;2MH&_MXT=NY(XHMjV&YkJ z?@U8NP3qM7l0a!G#f>b(x*3z5@c0Rc+5I*5+dl_}9Ak2XL~=;Mf{VM6`HjQAQpnINE-qd42FjmKYT1iLxG{oF)OLVwT+LI6oo?Cb@W|;o*3xW_Q2Huly^#I z_Z@p&oeq>{ha~pqIP5cb-1`ZJA6tMt2Z6hHCkVBM{u-yU|7=j>Oy}0wum{^{20bn- zez{F?(W;8)e4XJb!o+m;R|$Om#UBkK$(a{Y2WaND_j>cwvO03p8u+r-MH%)bs$4wN z+o;U#oS08sDO{I!3EB!nKqUANLo>u)Vp<-K$WYNyYP2KH`*?IqWJ_jIJfrW0j#w3_ zlMB%Ec*VA7wbZWc?}kgLFhv%`+ir7xM@#`?x&isN6}osY(!Axbh4=2Of|t3dp*64Z z)0PU^S}R>>4=%7Gwrnzylgqlqvp6tyy1gUf%TIp{R-*E_!fXlcD?T;;+7QKD{!0|j zdxrSDb#AQWY4gfRLVu_a#+ z4;xEbohzT2k8l?W&b^Q)n^BQHL#?hheUfiFSv~VA64gXqiN+bxw#b3ZvYte*xIBZZ zV>b%*;d**Yoy%;x2GWYGC#k&3@Ln`iXSEs+&DR5>gKQ1Q`wJQxV)PH*nTZ&o7)C!Y zR%O-6B~qE;^tbNomopYSuXmm#^od%1PjzCSmwYN7k6+f5vzMt%D!9z5ca`To((aC2 zatKy6fi1vj>0vKvVwP}p0pZTXo*?H@22m~cLOwgaDS6+2;_q&R;#9TJIsgEeil%}6 z(KOJNA}|OD0v(|qGlo3V?omU-Vce(UE#f!fKp-y^fF@yh(A+Sv0|bTu8987OXu)4_ z1aTBgB@+DteO>+BF+ym8UnUoV`!bmvLMGoNxMKJYoA%HbU z(SSg}^M_9MQ_$aZ6Jly?JydijUP}!&Zk1YhTyB-&LRI|WdSZDzN39gQrl!I9Zh%s# zlG#|T?dV;*#)`2TS?1?Xg10#D!|AcP+ot(1YR)!`f2)v%CAK67eN;)#fci$~Q*6xf zXUGG^V^vtAA16aCn{3JLFJg57iMo^&=GibOs{R}2d-igYz_mT+l4_lF`KnKPlVENN zaa23At5TK5ysvY2{lie7r(ECgU70%V@wG{IQ$qTA+}7*v0+Q_K?AsP4;|XTiY`Wd}{Zn!I~l0;;^zk zATSUBER{tg(Nc#Kl;VSkK!gdn4PTMUW+2;z6QW|0K@OaiFI$ewH#L-x zC@*B|J{O2L8x%Hoor{;Sj&Nt5)@IKZN{f+xR}pu+0z$p@o0{yTiMOhD^cbJ^nA2)G zNXE-m-rhVl6niWDRSZPH4%^i4tl_&n9j7C&Ytr-gQHxn zcc%Jg1rXW-*w_%gD3?OY>&9(B%4X9I{f|n?j;NftwxTlBKxf7HEIn=AD=;So23T80&_M0MT}+nH>l1aaE#kY9 Date: Tue, 28 Oct 2025 00:10:21 +1000 Subject: [PATCH 16/63] Feature/auth logout (#3) * :wrench: feat(auth-logout): Handle /logout with blacklisting access token using in-memory cache * :wrench: feat(auth-logout): Handle Blacklisting Access Token using Redis * :hammer: feat(auth-logout): Handle Revoking previously used Refresh Token after executing refresh request using Redis --- pom.xml | 15 +++ .../be08/smart_notes/config/RedisConfig.java | 29 +++++ .../smart_notes/config/SecurityConfig.java | 60 +++++++++- .../controller/AuthenticationController.java | 16 +++ .../be08/smart_notes/mapper/UserMapper.java | 2 - .../service/AuthenticationService.java | 23 +++- .../be08/smart_notes/service/JwtService.java | 5 + .../smart_notes/service/LogoutService.java | 103 ++++++++++++++++++ src/main/resources/application.properties | 6 +- 9 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/config/RedisConfig.java create mode 100644 src/main/java/com/be08/smart_notes/service/LogoutService.java diff --git a/pom.xml b/pom.xml index 02aaf5d..f6e0aea 100644 --- a/pom.xml +++ b/pom.xml @@ -137,6 +137,21 @@ spring-security-oauth2-jose + + + + org.springframework.boot + spring-boot-starter-data-redis + 3.5.7 + + + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + diff --git a/src/main/java/com/be08/smart_notes/config/RedisConfig.java b/src/main/java/com/be08/smart_notes/config/RedisConfig.java new file mode 100644 index 0000000..673e0fa --- /dev/null +++ b/src/main/java/com/be08/smart_notes/config/RedisConfig.java @@ -0,0 +1,29 @@ +package com.be08.smart_notes.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory){ + RedisTemplate template = new RedisTemplate<>(); + + template.setConnectionFactory(connectionFactory); + + StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); + template.setKeySerializer(stringRedisSerializer); + template.setHashKeySerializer(stringRedisSerializer); + + GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer(); + template.setValueSerializer(jsonRedisSerializer); + template.setHashValueSerializer(jsonRedisSerializer); + + template.afterPropertiesSet(); + return template; + } +} diff --git a/src/main/java/com/be08/smart_notes/config/SecurityConfig.java b/src/main/java/com/be08/smart_notes/config/SecurityConfig.java index 964c353..2d7eace 100644 --- a/src/main/java/com/be08/smart_notes/config/SecurityConfig.java +++ b/src/main/java/com/be08/smart_notes/config/SecurityConfig.java @@ -1,17 +1,33 @@ package com.be08.smart_notes.config; import com.be08.smart_notes.common.SecurityConstants; +import com.be08.smart_notes.service.LogoutService; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManagerResolver; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; + +import java.io.IOException; @Configuration @EnableWebSecurity @@ -19,7 +35,12 @@ public class SecurityConfig { @Bean public ObjectMapper objectMapper(){ - return new ObjectMapper(); + ObjectMapper mapper = new ObjectMapper(); + + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + return mapper; } @Bean @@ -27,6 +48,38 @@ PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(10); } + public static class BlacklistFilter extends BearerTokenAuthenticationFilter { + private final LogoutService logoutService; + + public BlacklistFilter(LogoutService logoutService){ + super((AuthenticationManagerResolver) authentication -> null); + this.logoutService = logoutService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // Check if the authentication principal is a JWT and has been decoded by the resource server with JwtDecoder + if(authentication != null && authentication.getPrincipal() instanceof Jwt){ + Jwt jwt = (Jwt) authentication.getPrincipal(); + + String tokenId = jwt.getId(); + + // If the token ID is blacklisted, clear the security context and throw an exception + if(logoutService.isAccessTokenBlacklisted(tokenId)){ + // Clear the security context to prevent further processing + SecurityContextHolder.clearContext(); + + // Allow the CustomBearerTokenAuthenticationEntryPoint to handle the response + throw new BadCredentialsException("The Access Token is blacklisted"); + } + } + + filterChain.doFilter(request, response); + } + } + /** * Configure security filter chain * @param httpSecurity @@ -34,11 +87,14 @@ PasswordEncoder passwordEncoder(){ * @throws Exception */ @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, ObjectMapper objectMapper, LogoutService logoutService) throws Exception { httpSecurity // Disable CSRF protection for stateless REST APIs .csrf(AbstractHttpConfigurer::disable) + // Add custom BlacklistFilter after the AnonymousAuthenticationFilter + .addFilterAfter(new BlacklistFilter(logoutService), AnonymousAuthenticationFilter.class) + // Define authorization rules .authorizeHttpRequests(authorize -> authorize // Permit all requests to public authentication endpoints diff --git a/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java b/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java index 1d066f5..4aabdb0 100644 --- a/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java +++ b/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java @@ -12,6 +12,7 @@ import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -66,4 +67,19 @@ ApiResponse refreshToken(@RequestBody @Valid RefreshToke .data(response) .build(); } + + /** + * User Logout + * Invalidate the current access token by adding its ID (jti) to the blacklist + * @param authentication + * @return ApiResponse with logout confirmation + */ + @PostMapping("/logout") + ApiResponse logout(Authentication authentication){ + authenticationService.logout(authentication); + + return ApiResponse.builder() + .message("Logged out successfully! Access token is now invalid.") + .build(); + } } diff --git a/src/main/java/com/be08/smart_notes/mapper/UserMapper.java b/src/main/java/com/be08/smart_notes/mapper/UserMapper.java index 108ed15..387e01e 100644 --- a/src/main/java/com/be08/smart_notes/mapper/UserMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/UserMapper.java @@ -6,8 +6,6 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import java.time.LocalDateTime; - @Mapper(componentModel = "spring") public interface UserMapper { @Mapping(target = "id", ignore = true) diff --git a/src/main/java/com/be08/smart_notes/service/AuthenticationService.java b/src/main/java/com/be08/smart_notes/service/AuthenticationService.java index cbd1823..9e0f67a 100644 --- a/src/main/java/com/be08/smart_notes/service/AuthenticationService.java +++ b/src/main/java/com/be08/smart_notes/service/AuthenticationService.java @@ -12,6 +12,7 @@ import lombok.experimental.FieldDefaults; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; @@ -27,6 +28,7 @@ public class AuthenticationService { private final PasswordEncoder passwordEncoder; private final JwtService jwtService; private final JwtDecoder jwtDecoder; + private final LogoutService logoutService; // Inject the key alias to be used for signing tokens @Value("${jwt.signing.key.alias}") @@ -76,9 +78,16 @@ public AuthenticationResponse refreshToken(RefreshTokenRequest request){ int userId; + Jwt jwt; + try { // Decode and validate the refresh token - Jwt jwt = jwtDecoder.decode(refreshToken); + jwt = jwtDecoder.decode(refreshToken); + + if(logoutService.isRefreshTokenBlacklisted(jwt.getId())){ + log.warn("Refresh Token is blacklisted: jti {}", jwt.getId()); + throw new JwtException("Refresh Token has been revoked (blacklisted)"); + } // Extract user ID from token subject userId = Integer.parseInt(jwt.getSubject()); @@ -98,10 +107,22 @@ public AuthenticationResponse refreshToken(RefreshTokenRequest request){ log.info("New tokens generated successfully for user ID {}", userId); + // Blacklist the previously used refresh token to prevent reuse + logoutService.blacklistRefreshToken(jwt); + return AuthenticationResponse.builder() .isAuthenticated(true) .accessToken(newAccessToken) .refreshToken(newRefreshToken) .build(); } + + /** + * Logout user by invalidating the current access token to blacklist its jti and prevent further use + * @param authentication + */ + public void logout(Authentication authentication){ + Jwt jwt = (Jwt) authentication.getPrincipal(); + logoutService.blacklistAccessToken(jwt); + } } diff --git a/src/main/java/com/be08/smart_notes/service/JwtService.java b/src/main/java/com/be08/smart_notes/service/JwtService.java index 489fdc0..bfc92a7 100644 --- a/src/main/java/com/be08/smart_notes/service/JwtService.java +++ b/src/main/java/com/be08/smart_notes/service/JwtService.java @@ -16,6 +16,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -42,12 +43,16 @@ public class JwtService { public String generateToken(User user, long expirationDuration, ChronoUnit unit, String keyId) { Instant now = Instant.now(); + // Unique Token ID (jti) for token identification + String jti = UUID.randomUUID().toString(); + // 1. Define claims (Payload) JwtClaimsSet claims = JwtClaimsSet.builder() .issuer("smart-notes-auth-server") .issuedAt(now) .expiresAt(now.plus(expirationDuration, unit)) .subject(String.valueOf(user.getId())) // Subject is User ID + .id(jti) // Unique JWT Token ID .claim("email", user.getEmail()) .claim("scope", "USER") .build(); diff --git a/src/main/java/com/be08/smart_notes/service/LogoutService.java b/src/main/java/com/be08/smart_notes/service/LogoutService.java new file mode 100644 index 0000000..db48f9a --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/LogoutService.java @@ -0,0 +1,103 @@ +package com.be08.smart_notes.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; + +@Service +@RequiredArgsConstructor +@Slf4j +public class LogoutService { + private final RedisTemplate redisTemplate; + private static final String ACCESS_TOKEN_BLACKLIST_KEY_PREFIX = "jwt:access_token_blacklist:"; + private static final String REFRESH_TOKEN_BLACKLIST_KEY_PREFIX = "jwt:refresh_token_blacklist:"; + + /** + * Blacklist the given JWT access token by storing its ID (jti) in Redis with an expiration time. + * @param jwt the JWT access token to blacklist + */ + public void blacklistAccessToken(Jwt jwt){ + String jti = jwt.getId(); + Instant expirationTime = jwt.getExpiresAt(); + + if(jti == null || expirationTime == null){ + log.warn("Cannot blacklist access token: JWT ID (jti) not found"); + return; + } + + // Calculate the TTL (Time-to-live) for the blacklist entry + Duration ttl = Duration.between(Instant.now(), expirationTime); + + // If the token is already expired, no need to blacklist + if(ttl.isNegative()){ + log.warn("Cannot blacklist access token with jti {}: Token already expired", jti); + return; + } + + // Store the jti in Redis with the calculated TTL + String redisKey = ACCESS_TOKEN_BLACKLIST_KEY_PREFIX + jti; + + // Using a simple string value to indicate blacklisting + // Set TTL to automatically remove the entry after expiration + redisTemplate.opsForValue().set(redisKey, "invalid", ttl); + + log.info("Access token with jti {} has been blacklisted. Valid until {}", jti, jwt.getExpiresAt()); + } + + /** + * Blacklist the given JWT refresh token by storing its ID (jti) in Redis with an expiration time. + * @param jwt the JWT refresh token to blacklist + */ + public void blacklistRefreshToken(Jwt jwt){ + String jti = jwt.getId(); + Instant expirationTime = jwt.getExpiresAt(); + + if(jti == null || expirationTime == null){ + log.warn("Cannot blacklist refresh token: JWT ID (jti) not found"); + return; + } + + // Calculate TTL for the blacklist entry + Duration ttl = Duration.between(Instant.now(), expirationTime); + + // If the token is already expired, no need to blacklist + if(ttl.isNegative()){ + log.warn("Cannot blacklist refresh token with jti {}: Token already expired", jti); + return; + } + + // Store the jti in Redis with the calculated TTL + String redisKey = REFRESH_TOKEN_BLACKLIST_KEY_PREFIX + jti; + + // Using a simple string value to indicate blacklisting + // Set TTL to automatically remove the entry after expiration + redisTemplate.opsForValue().set(redisKey, "invalid", ttl); + + log.info("Refresh token with jti {} has been blacklisted. Valid until {}", jti, jwt.getExpiresAt()); + } + + /** + * Check if the given access token ID (jti) is in the Redis blacklist. + * @param tokenId the JWT ID (jti) of the access token + * @return true if the token ID is blacklisted, false otherwise + */ + public boolean isAccessTokenBlacklisted(String tokenId){ + String redisKey = ACCESS_TOKEN_BLACKLIST_KEY_PREFIX + tokenId; + return redisTemplate.hasKey(redisKey); + } + + /** + * Check if the given refresh token ID (jti) is in the Redis blacklist. + * @param tokenId the JWT ID (jti) of the refresh token + * @return true if the token ID is blacklisted, false otherwise + */ + public boolean isRefreshTokenBlacklisted(String tokenId) { + String redisKey = REFRESH_TOKEN_BLACKLIST_KEY_PREFIX + tokenId; + return redisTemplate.hasKey(redisKey); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e87bdaa..17f3e34 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -24,4 +24,8 @@ jwt.key.password=AliceTat # JWT Key Rotation Configuration jwt.signing.key.alias=jwtkey-2025 -jwt.verification.key.aliases=jwtkey-2025 \ No newline at end of file +jwt.verification.key.aliases=jwtkey-2025 + +# Spring Data Redis Configuration +spring.data.redis.host=localhost +spring.data.redis.port=6379 \ No newline at end of file From c4590575a9b9db89e7353c62c52cf53319ab1f02 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 28 Oct 2025 23:01:52 +1100 Subject: [PATCH 17/63] feat(ai-quiz): add quiz service with quiz creation and deletion --- pom.xml | 30 ++++++++++++- .../smart_notes/controller/AIController.java | 28 +++++++----- .../controller/QuizController.java | 37 +++++++++++++++ .../dto/ai/QuizGenerationRequest.java | 14 ++++++ .../{QuizResponse.java => QuizQuestion.java} | 21 +++++---- .../be08/smart_notes/mapper/QuizMapper.java | 20 +++++++++ .../com/be08/smart_notes/model/Question.java | 26 +++++------ .../java/com/be08/smart_notes/model/Quiz.java | 21 +++++---- .../repository/DocumentRepository.java | 10 +++-- .../be08/smart_notes/service/NoteService.java | 6 +++ .../be08/smart_notes/service/QuizService.java | 36 +++++++++++++++ .../service/ai/QuizGenerationService.java | 45 ++++++++++++++++--- src/main/resources/prompts/system_prompt.txt | 17 ++----- .../resources/prompts/system_prompt_v0.txt | 14 ++++++ src/main/resources/schemas/quiz_response.json | 20 ++++++--- .../resources/schemas/quiz_response_v0.json | 32 +++++++++++++ 16 files changed, 302 insertions(+), 75 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/controller/QuizController.java create mode 100644 src/main/java/com/be08/smart_notes/dto/ai/QuizGenerationRequest.java rename src/main/java/com/be08/smart_notes/dto/ai/{QuizResponse.java => QuizQuestion.java} (55%) create mode 100644 src/main/java/com/be08/smart_notes/mapper/QuizMapper.java create mode 100644 src/main/java/com/be08/smart_notes/service/QuizService.java create mode 100644 src/main/resources/prompts/system_prompt_v0.txt create mode 100644 src/main/resources/schemas/quiz_response_v0.json diff --git a/pom.xml b/pom.xml index b789d36..24e2989 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,8 @@ 17 + 1.18.30 + 1.6.3 @@ -69,8 +71,16 @@ org.projectlombok lombok - 1.18.30 + ${lombok.version} + + + + + org.mapstruct + mapstruct + ${mapstruct.version} + @@ -111,6 +121,24 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + diff --git a/src/main/java/com/be08/smart_notes/controller/AIController.java b/src/main/java/com/be08/smart_notes/controller/AIController.java index f976f8b..1dea8a5 100644 --- a/src/main/java/com/be08/smart_notes/controller/AIController.java +++ b/src/main/java/com/be08/smart_notes/controller/AIController.java @@ -1,31 +1,39 @@ package com.be08.smart_notes.controller; +import com.be08.smart_notes.dto.ai.QuizGenerationRequest; +import com.be08.smart_notes.dto.ai.QuizQuestion; +import com.be08.smart_notes.model.Document; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; -import com.be08.smart_notes.dto.ai.QuizResponse; import com.be08.smart_notes.service.ai.QuizGenerationService; +import java.util.List; + @RestController -@RequestMapping("/api/ai") +@RequestMapping("/api/ai/quiz/") public class AIController { @Autowired private QuizGenerationService quizGenerationService; - @GetMapping("/generateQuiz/sample") + @GetMapping("/generate/sample") public ResponseEntity generateSampleQuiz() { - QuizResponse quizList = quizGenerationService.generateSampleQuiz(); + QuizQuestion quizList = quizGenerationService.generateSampleQuiz(); return ResponseEntity.status(HttpStatus.OK).body(quizList); } - @GetMapping("/generateQuiz/{noteId}") + @GetMapping("/generate/{noteId}") public ResponseEntity generateQuiz(@PathVariable int noteId) { - QuizResponse quizList = quizGenerationService.generateQuizFromNote(noteId); + int userId = 1; + QuizQuestion quizList = quizGenerationService.generateQuizFromSingleNote(userId, noteId); return ResponseEntity.status(HttpStatus.OK).body(quizList); } + +// @PostMapping("/generate") +// public ResponseEntity generateQuizFromNotes(@RequestBody QuizGenerationRequest quizGenerationRequest) { +// List quizList = quizGenerationService.generateQuizFromListOfNotes(quizGenerationRequest.getIds()); +// return ResponseEntity.status(HttpStatus.OK).body(quizList); +// } } diff --git a/src/main/java/com/be08/smart_notes/controller/QuizController.java b/src/main/java/com/be08/smart_notes/controller/QuizController.java new file mode 100644 index 0000000..45ce324 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/controller/QuizController.java @@ -0,0 +1,37 @@ +package com.be08.smart_notes.controller; + +import com.be08.smart_notes.dto.ai.QuizQuestion; +import com.be08.smart_notes.dto.response.ApiResponse; +import com.be08.smart_notes.model.Quiz; +import com.be08.smart_notes.service.QuizService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/quiz") +public class QuizController { + @Autowired + private QuizService quizService; + + @PostMapping + public ResponseEntity createQuiz(@RequestBody QuizQuestion quizQuestion) { + int userId = 1; + Quiz quiz = quizService.createQuiz(userId, 1, quizQuestion); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz created successfully") + .data(quiz) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteQuiz(@PathVariable int id) { + quizService.deleteQuiz(id); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz deleted successfully") + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } +} diff --git a/src/main/java/com/be08/smart_notes/dto/ai/QuizGenerationRequest.java b/src/main/java/com/be08/smart_notes/dto/ai/QuizGenerationRequest.java new file mode 100644 index 0000000..2d4581a --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/ai/QuizGenerationRequest.java @@ -0,0 +1,14 @@ +package com.be08.smart_notes.dto.ai; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class QuizGenerationRequest { + private List ids; +} diff --git a/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java b/src/main/java/com/be08/smart_notes/dto/ai/QuizQuestion.java similarity index 55% rename from src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java rename to src/main/java/com/be08/smart_notes/dto/ai/QuizQuestion.java index 7437c02..aeff72a 100644 --- a/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/ai/QuizQuestion.java @@ -2,21 +2,15 @@ import java.util.List; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; -import jakarta.validation.constraints.DecimalMin; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import org.springframework.beans.factory.annotation.Value; @Data @NoArgsConstructor @AllArgsConstructor -public class QuizResponse { +public class QuizQuestion { @NotNull private String topic; @NotNull @@ -30,9 +24,18 @@ public static class Question { private String question; @NotNull - private String[] options; + private String optionA; @NotNull - private Integer correctIndex; + private String optionB; + + @NotNull + private String optionC; + + @NotNull + private String optionD; + + @NotNull + private String correctAnswer; } } diff --git a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java new file mode 100644 index 0000000..62e9fc2 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java @@ -0,0 +1,20 @@ +package com.be08.smart_notes.mapper; + +import com.be08.smart_notes.dto.ai.QuizQuestion; +import com.be08.smart_notes.model.Question; +import com.be08.smart_notes.model.Quiz; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface QuizMapper { + @Mapping(target = "title", source = "dto.topic") + Quiz fromQuizResponseToQuiz(QuizQuestion dto); + + @Mapping(target = "questionText", source = "dto.question") + Question fromQuizResponseQuestionToQuestion(QuizQuestion.Question dto); + +// @Mapping(target = "topic", source = "entity.title") +// @Mapping(target = "questions", source = "entity.questions") +// QuizResponse toQuizResponse(Quiz entity); +} diff --git a/src/main/java/com/be08/smart_notes/model/Question.java b/src/main/java/com/be08/smart_notes/model/Question.java index 4991ddf..92692a0 100644 --- a/src/main/java/com/be08/smart_notes/model/Question.java +++ b/src/main/java/com/be08/smart_notes/model/Question.java @@ -1,13 +1,7 @@ package com.be08.smart_notes.model; -import java.time.LocalDateTime; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -22,10 +16,7 @@ public class Question { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private int id; - - @Column(nullable = false, name = "quiz_id") - private int quizId; + private Integer id; @Column(nullable = false, name = "question_text") private String questionText; @@ -43,11 +34,14 @@ public class Question { private String optionD; @Column(nullable = false, name = "correct_answer") - private char correctAnswer; + private Character correctAnswer; @Column(nullable = false, name = "source_document_id") - private int sourceDocumentId; + private Integer sourceDocumentId; - @Column(nullable = false, name = "created_at") - private LocalDateTime createdAt; + // Relationship + @JsonIgnore + @ManyToOne + @JoinColumn(name = "quiz_id", referencedColumnName = "id") + private Quiz quiz; } diff --git a/src/main/java/com/be08/smart_notes/model/Quiz.java b/src/main/java/com/be08/smart_notes/model/Quiz.java index bd4d7a4..d05f3db 100644 --- a/src/main/java/com/be08/smart_notes/model/Quiz.java +++ b/src/main/java/com/be08/smart_notes/model/Quiz.java @@ -1,16 +1,14 @@ package com.be08.smart_notes.model; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.time.LocalDateTime; +import java.util.List; + @Data @NoArgsConstructor @AllArgsConstructor @@ -20,11 +18,18 @@ public class Quiz { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private int id; + private Integer id; @Column(nullable = false, name = "user_id") - private int userId; + private Integer userId; @Column(nullable = false) private String title; + + @Column(nullable = false, name = "created_at") + private LocalDateTime createdAt; + + // Relationship + @OneToMany(mappedBy = "quiz", cascade = CascadeType.ALL, orphanRemoval = true) + private List questions; } diff --git a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java index f2c9c20..f6003dc 100644 --- a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java @@ -1,6 +1,7 @@ package com.be08.smart_notes.repository; -import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -9,7 +10,8 @@ import com.be08.smart_notes.model.Document; public interface DocumentRepository extends JpaRepository { - @Query("UPDATE Document d SET d.title = :title, d.updatedAt = :updatedAt WHERE d.id = :id") - Document updateTitle(@Param(value = "id") int id, @Param(value = "title") String title, - @Param(value = "updatedAt") LocalDateTime updatedAt); + List findAllByIdIn(List ids); + + @Query("SELECT d.content FROM Document d WHERE d.id IN :ids") + List findAllContentFromIdIn(@Param(value="ids") List ids); } diff --git a/src/main/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index fa9e9fe..738cf05 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -1,6 +1,8 @@ package com.be08.smart_notes.service; import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -46,4 +48,8 @@ public void updateNote(int noteId, NoteUpsertRequest updateData) { public void deleteNote(int noteId) { documentRepository.deleteById(noteId); } + + public List getAllNotesByIds(List noteIds) { + return documentRepository.findAllByIdIn(noteIds); + } } diff --git a/src/main/java/com/be08/smart_notes/service/QuizService.java b/src/main/java/com/be08/smart_notes/service/QuizService.java new file mode 100644 index 0000000..bab3996 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/QuizService.java @@ -0,0 +1,36 @@ +package com.be08.smart_notes.service; + +import com.be08.smart_notes.dto.ai.QuizQuestion; +import com.be08.smart_notes.mapper.QuizMapper; +import com.be08.smart_notes.model.Question; +import com.be08.smart_notes.model.Quiz; +import com.be08.smart_notes.repository.QuizRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +public class QuizService { + @Autowired + private QuizRepository quizRepository; + @Autowired + private QuizMapper quizMapper; + + public Quiz createQuiz(int userId, int sourceDocumentId, QuizQuestion quizQuestion) { + Quiz quiz = quizMapper.fromQuizResponseToQuiz(quizQuestion); + quiz.setCreatedAt(LocalDateTime.now()); + quiz.setUserId(userId); + + for (Question question : quiz.getQuestions()) { + question.setSourceDocumentId(sourceDocumentId); + question.setQuiz(null); + } + + return quizRepository.save(quiz); + } + + public void deleteQuiz(int id) { + quizRepository.deleteById(id); + } +} diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index 10ffb76..ceb1f92 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -4,7 +4,12 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; +import com.be08.smart_notes.dto.ai.QuizQuestion; +import com.be08.smart_notes.service.QuizService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -12,7 +17,6 @@ import com.be08.smart_notes.common.AppConstants; import com.be08.smart_notes.service.NoteService; import com.be08.smart_notes.dto.ai.AIInferenceResponse; -import com.be08.smart_notes.dto.ai.QuizResponse; import com.be08.smart_notes.model.Document; @Service @@ -20,6 +24,9 @@ public class QuizGenerationService { private String systemPrompt; private String quizResponseSchema; + @Autowired + private QuizService quizService; + @Autowired private AIService aiService; @Autowired @@ -41,7 +48,7 @@ public QuizGenerationService() { } } - public QuizResponse generateSampleQuiz() { + public QuizQuestion generateSampleQuiz() { // Below is sample response of fetchResponseFromInferenceProvider() String responseAsJSONString = "{\"id\":\"chatcmpl-123456\",\"choices\":[{\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null,\"message\":{\"content\":\"{\\\"topic\\\": \\\"Object-Oriented Programming Concepts\\\", \\\"quizzes\\\": [\\n {\\n \\\"question\\\": \\\"What is the primary focus of Object-Oriented Programming?\\\",\\n \\\"options\\\": [\\n \\\"Writing code for specific tasks\\\",\\n \\\"Organizing code around objects\\\",\\n \\\"Implementing algorithms for complex problems\\\",\\n \\\"Using pre-defined functions\\\"\\n ],\\n \\\"correctIndex\\\": 1\\n },\\n {\\n \\\"question\\\": \\\"What is the primary benefit of encapsulation?\\\",\\n \\\"options\\\": [\\n \\\"Hiding internal details of an object\\\",\\n \\\"Allowing easy modification of an object\\\",\\n \\\"Promoting code reusability through inheritance\\\",\\n \\\"Making code more efficient and faster\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"Which of the following is an example of inheritance?\\\",\\n \\\"options\\\": [\\n \\\"Creating a class called 'Dog' that inherits from 'Animal'\\\",\\n \\\"Defining a method called 'calculateArea' within a class\\\",\\n \\\"Using a constructor to initialize an object's properties\\\",\\n \\\"Writing code to display a message on the screen\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"What does polymorphism allow?\\\",\\n \\\"options\\\": [\\n \\\"The same method to behave differently in different classes\\\",\\n \\\"A single method to handle different data types\\\",\\n \\\"Classes to inherit common functionalities from their parent classes\\\",\\n \\\"Objects to access data and methods in a controlled manner\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"What is a constructor in OOP?\\\",\\n \\\"options\\\": [\\n \\\"A method that runs when an object is created\\\",\\n \\\"A method that defines the behavior of an object\\\",\\n \\\"A method that is called repeatedly for a specific task\\\",\\n \\\"A method that handles exceptions and errors\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"What is the role of an interface in OOP?\\\",\\n \\\"options\\\": [\\n \\\"A contract that defines a set of methods that classes must implement\\\",\\n \\\"A blueprint for creating a specific type of object\\\",\\n \\\"A way to communicate between different objects\\\",\\n \\\"A way to store and manage data\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n }\\n]}\",\"refusal\":null,\"role\":\"assistant\",\"audio\":null,\"function_call\":null,\"tool_calls\":[],\"reasoning_content\":null},\"stop_reason\":null}],\"created\":1751445406,\"model\":\"google/gemma-2-2b-it\",\"object\":\"chat.completion\",\"service_tier\":null,\"system_fingerprint\":null,\"usage\":{\"completion_tokens\":531,\"prompt_tokens\":953,\"total_tokens\":1484,\"completion_tokens_details\":null,\"prompt_tokens_details\":null},\"prompt_logprobs\":null}\r\n"; @@ -53,7 +60,7 @@ public QuizResponse generateSampleQuiz() { String chatMessageContent = inferenceResponse.getChoices()[0].getMessage().getContent(); // Parse to object - return gson.fromJson(chatMessageContent, QuizResponse.class); + return gson.fromJson(chatMessageContent, QuizQuestion.class); } catch (Exception e) { System.out.println(e.toString()); e.printStackTrace(); @@ -62,7 +69,7 @@ public QuizResponse generateSampleQuiz() { return null; } - public QuizResponse generateQuizFromNote(int noteId) { + public QuizQuestion generateQuizFromSingleNote(int userId, int noteId) { if (this.systemPrompt == null) { return null; } @@ -74,12 +81,36 @@ public QuizResponse generateQuizFromNote(int noteId) { } // Generate content - String generatedContent = aiService.generateContent(this.systemPrompt, selectedNote.getContent(), quizResponseSchema); + String generatedContent = aiService.generateContent( + String.format(this.systemPrompt, 10), + selectedNote.getContent(), + quizResponseSchema + ); if (generatedContent == null || generatedContent.isEmpty()) { return null; } - Gson gson = new Gson(); - return gson.fromJson(generatedContent, QuizResponse.class); + ObjectMapper objectMapper = new ObjectMapper(); + try { + QuizQuestion quizQuestion = objectMapper.readValue(generatedContent, QuizQuestion.class); + quizService.createQuiz(userId, noteId, quizQuestion); + return quizQuestion; + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public List generateQuizFromListOfNotes(List noteIds) { + if (this.systemPrompt == null) { + return null; + } + + // Get note + List noteList = noteService.getAllNotesByIds(noteIds); + if (noteList.isEmpty()) { + return null; + } + + return noteList; } } diff --git a/src/main/resources/prompts/system_prompt.txt b/src/main/resources/prompts/system_prompt.txt index 37a3dcb..5889891 100644 --- a/src/main/resources/prompts/system_prompt.txt +++ b/src/main/resources/prompts/system_prompt.txt @@ -1,14 +1,5 @@ -I will give you a study note, generate quizzes based on the content for knowledge review. -Total number of questions is flexible, depending the length of note content. -For each question, generate 4 options where only one of the options is correct. +I will give you a study note and pre-defined setting as below, generate quizzes based on the content for knowledge review. +For each question, generate 4 options (A, B, C, D) where only one of the options is correct. -Format your response as follows: -**QUESTION** 1: [Your question here]? **END QUESTION** -**OPTION** 1: [First option] **END OPTION** -**OPTION** 2: [Second option] **END OPTION** -**OPTION** 3: [Third option] **END OPTION** -**OPTION** 4: [Fourth option] **END OPTION** -**ANS**: [Correct answer index (0-3)] **END ANS** - -Strictly follow above pattern for all questions. Ensure text is properly formatted. -It must start with a question, then the options array, and finally the correct answer index. \ No newline at end of file +Setting: +- number of questions: %s \ No newline at end of file diff --git a/src/main/resources/prompts/system_prompt_v0.txt b/src/main/resources/prompts/system_prompt_v0.txt new file mode 100644 index 0000000..37a3dcb --- /dev/null +++ b/src/main/resources/prompts/system_prompt_v0.txt @@ -0,0 +1,14 @@ +I will give you a study note, generate quizzes based on the content for knowledge review. +Total number of questions is flexible, depending the length of note content. +For each question, generate 4 options where only one of the options is correct. + +Format your response as follows: +**QUESTION** 1: [Your question here]? **END QUESTION** +**OPTION** 1: [First option] **END OPTION** +**OPTION** 2: [Second option] **END OPTION** +**OPTION** 3: [Third option] **END OPTION** +**OPTION** 4: [Fourth option] **END OPTION** +**ANS**: [Correct answer index (0-3)] **END ANS** + +Strictly follow above pattern for all questions. Ensure text is properly formatted. +It must start with a question, then the options array, and finally the correct answer index. \ No newline at end of file diff --git a/src/main/resources/schemas/quiz_response.json b/src/main/resources/schemas/quiz_response.json index abdfafb..dccae8f 100644 --- a/src/main/resources/schemas/quiz_response.json +++ b/src/main/resources/schemas/quiz_response.json @@ -12,14 +12,20 @@ "question": { "type": "string" }, - "options": { - "type": "array", - "items": { - "type": "string" - } + "optionA": { + "type": "string" + }, + "optionB": { + "type": "string" + }, + "optionC": { + "type": "string" + }, + "optionD": { + "type": "string" }, - "correctIndex": { - "type": "integer" + "correctAnswer": { + "type": "string" } } } diff --git a/src/main/resources/schemas/quiz_response_v0.json b/src/main/resources/schemas/quiz_response_v0.json new file mode 100644 index 0000000..abdfafb --- /dev/null +++ b/src/main/resources/schemas/quiz_response_v0.json @@ -0,0 +1,32 @@ +{ + "type": "object", + "properties": { + "topic": { + "type": "string" + }, + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "question": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "correctIndex": { + "type": "integer" + } + } + } + } + }, + "required": [ + "topic", + "questions" + ] +} \ No newline at end of file From c08cc9472ac17f9dca3a84ed673aa6cdbb30aa0a Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 29 Oct 2025 15:37:05 +1100 Subject: [PATCH 18/63] fix(ai-quiz): fix unstable ai inference response --- .../smart_notes/controller/AIController.java | 24 +++-- .../controller/QuizController.java | 22 +++-- .../smart_notes/dto/ai/AIQuizResponse.java | 46 ++++++++++ .../{QuizQuestion.java => QuizResponse.java} | 21 ++++- .../dto/request/NoteUpsertRequest.java | 2 +- .../be08/smart_notes/mapper/QuizMapper.java | 27 ++++-- .../be08/smart_notes/service/QuizService.java | 19 ++-- .../smart_notes/service/ai/AIService.java | 8 +- .../service/ai/QuizGenerationService.java | 87 ++++++++++++------- src/main/resources/prompts/system_prompt.txt | 11 +++ .../resources/prompts/system_prompt_v1.txt | 5 ++ src/main/resources/schemas/quiz_response.json | 24 +++-- .../resources/schemas/quiz_response_v1.json | 46 ++++++++++ 13 files changed, 263 insertions(+), 79 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/dto/ai/AIQuizResponse.java rename src/main/java/com/be08/smart_notes/dto/ai/{QuizQuestion.java => QuizResponse.java} (66%) create mode 100644 src/main/resources/prompts/system_prompt_v1.txt create mode 100644 src/main/resources/schemas/quiz_response_v1.json diff --git a/src/main/java/com/be08/smart_notes/controller/AIController.java b/src/main/java/com/be08/smart_notes/controller/AIController.java index 1dea8a5..996a9d1 100644 --- a/src/main/java/com/be08/smart_notes/controller/AIController.java +++ b/src/main/java/com/be08/smart_notes/controller/AIController.java @@ -1,8 +1,7 @@ package com.be08.smart_notes.controller; -import com.be08.smart_notes.dto.ai.QuizGenerationRequest; -import com.be08.smart_notes.dto.ai.QuizQuestion; -import com.be08.smart_notes.model.Document; +import com.be08.smart_notes.dto.ai.QuizResponse; +import com.be08.smart_notes.dto.response.ApiResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -10,8 +9,6 @@ import com.be08.smart_notes.service.ai.QuizGenerationService; -import java.util.List; - @RestController @RequestMapping("/api/ai/quiz/") public class AIController { @@ -20,15 +17,24 @@ public class AIController { @GetMapping("/generate/sample") public ResponseEntity generateSampleQuiz() { - QuizQuestion quizList = quizGenerationService.generateSampleQuiz(); - return ResponseEntity.status(HttpStatus.OK).body(quizList); + int userId = 1; + QuizResponse quizResponse = quizGenerationService.generateSampleQuiz(userId); + ApiResponse apiResponse = ApiResponse.builder() + .message("Sample quiz generated created successfully") + .data(quizResponse) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } @GetMapping("/generate/{noteId}") public ResponseEntity generateQuiz(@PathVariable int noteId) { int userId = 1; - QuizQuestion quizList = quizGenerationService.generateQuizFromSingleNote(userId, noteId); - return ResponseEntity.status(HttpStatus.OK).body(quizList); + QuizResponse quizResponse = quizGenerationService.generateQuizFromSingleNote(userId, noteId); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz generated created successfully") + .data(quizResponse) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } // @PostMapping("/generate") diff --git a/src/main/java/com/be08/smart_notes/controller/QuizController.java b/src/main/java/com/be08/smart_notes/controller/QuizController.java index 45ce324..35773a0 100644 --- a/src/main/java/com/be08/smart_notes/controller/QuizController.java +++ b/src/main/java/com/be08/smart_notes/controller/QuizController.java @@ -1,8 +1,8 @@ package com.be08.smart_notes.controller; -import com.be08.smart_notes.dto.ai.QuizQuestion; +import com.be08.smart_notes.dto.ai.AIQuizResponse; +import com.be08.smart_notes.dto.ai.QuizResponse; import com.be08.smart_notes.dto.response.ApiResponse; -import com.be08.smart_notes.model.Quiz; import com.be08.smart_notes.service.QuizService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; @@ -16,22 +16,32 @@ public class QuizController { private QuizService quizService; @PostMapping - public ResponseEntity createQuiz(@RequestBody QuizQuestion quizQuestion) { + public ResponseEntity createQuiz(@RequestBody AIQuizResponse aiQuizResponse) { int userId = 1; - Quiz quiz = quizService.createQuiz(userId, 1, quizQuestion); + QuizResponse quizResponse = quizService.saveQuizFromAIResponse(userId, 1, aiQuizResponse); ApiResponse apiResponse = ApiResponse.builder() .message("Quiz created successfully") - .data(quiz) + .data(quizResponse) .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } @DeleteMapping("/{id}") public ResponseEntity deleteQuiz(@PathVariable int id) { - quizService.deleteQuiz(id); + quizService.deleteQuizById(id); ApiResponse apiResponse = ApiResponse.builder() .message("Quiz deleted successfully") .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } + + @GetMapping("/{id}") + public ResponseEntity getQuiz(@PathVariable int id) { + QuizResponse quizResponse = quizService.getQuizById(id); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz fetched successfully") + .data(quizResponse) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } } diff --git a/src/main/java/com/be08/smart_notes/dto/ai/AIQuizResponse.java b/src/main/java/com/be08/smart_notes/dto/ai/AIQuizResponse.java new file mode 100644 index 0000000..3283270 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/ai/AIQuizResponse.java @@ -0,0 +1,46 @@ +package com.be08.smart_notes.dto.ai; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AIQuizResponse { + @NotNull + @JsonProperty(value = "topic") + private String title; + + @NotNull + @JsonProperty(value = "questions") + private List questions; + + // Static nested class + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Question { + @NotNull + @JsonProperty(value = "question") + private String questionText; + + @NotNull + @Size(min = 4, max = 4) + @JsonProperty(value = "options") + private String[] options; + + @NotNull + @Min(0) + @Max(3) + @JsonProperty(value = "correct_index") + private Integer correctIndex; + } +} diff --git a/src/main/java/com/be08/smart_notes/dto/ai/QuizQuestion.java b/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java similarity index 66% rename from src/main/java/com/be08/smart_notes/dto/ai/QuizQuestion.java rename to src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java index aeff72a..6c8665e 100644 --- a/src/main/java/com/be08/smart_notes/dto/ai/QuizQuestion.java +++ b/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java @@ -1,5 +1,6 @@ package com.be08.smart_notes.dto.ai; +import java.time.LocalDateTime; import java.util.List; import jakarta.validation.constraints.NotNull; @@ -10,18 +11,29 @@ @Data @NoArgsConstructor @AllArgsConstructor -public class QuizQuestion { +public class QuizResponse { @NotNull - private String topic; + private Integer id; + + @NotNull + private LocalDateTime createdAt; + + @NotNull + private String title; + @NotNull private List questions; + // Static nested class @Data @NoArgsConstructor @AllArgsConstructor public static class Question { @NotNull - private String question; + private Integer id; + + @NotNull + private String questionText; @NotNull private String optionA; @@ -37,5 +49,8 @@ public static class Question { @NotNull private String correctAnswer; + + @NotNull + private Integer sourceDocumentId; } } diff --git a/src/main/java/com/be08/smart_notes/dto/request/NoteUpsertRequest.java b/src/main/java/com/be08/smart_notes/dto/request/NoteUpsertRequest.java index 3e32eed..5ca9b36 100644 --- a/src/main/java/com/be08/smart_notes/dto/request/NoteUpsertRequest.java +++ b/src/main/java/com/be08/smart_notes/dto/request/NoteUpsertRequest.java @@ -15,7 +15,7 @@ @Builder public class NoteUpsertRequest { @NotNull(groups = OnCreate.class) - private int userId; + private Integer userId; @NotNull(groups = {OnCreate.class, OnUpdate.class}) private String title; diff --git a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java index 62e9fc2..a34ef52 100644 --- a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java @@ -1,6 +1,7 @@ package com.be08.smart_notes.mapper; -import com.be08.smart_notes.dto.ai.QuizQuestion; +import com.be08.smart_notes.dto.ai.AIQuizResponse; +import com.be08.smart_notes.dto.ai.QuizResponse; import com.be08.smart_notes.model.Question; import com.be08.smart_notes.model.Quiz; import org.mapstruct.Mapper; @@ -8,13 +9,23 @@ @Mapper(componentModel = "spring") public interface QuizMapper { - @Mapping(target = "title", source = "dto.topic") - Quiz fromQuizResponseToQuiz(QuizQuestion dto); + // Quiz & Question entity <--> QuizResponse DTO + QuizResponse fromQuizToQuizResponse(Quiz entity); + Quiz fromQuizToQuizResponse(QuizResponse dto); - @Mapping(target = "questionText", source = "dto.question") - Question fromQuizResponseQuestionToQuestion(QuizQuestion.Question dto); + // AIQuizResponse DTO --> Quiz & Question entity + Quiz fromAIQuizResponseToQuiz(AIQuizResponse dto); -// @Mapping(target = "topic", source = "entity.title") -// @Mapping(target = "questions", source = "entity.questions") -// QuizResponse toQuizResponse(Quiz entity); + @Mapping(target = "id", ignore = true) + @Mapping(target = "sourceDocumentId", ignore = true) + @Mapping(target = "optionA", expression = "java(dto.getOptions()[0])") + @Mapping(target = "optionB", expression = "java(dto.getOptions()[1])") + @Mapping(target = "optionC", expression = "java(dto.getOptions()[2])") + @Mapping(target = "optionD", expression = "java(dto.getOptions()[3])") + @Mapping(target = "correctAnswer", expression = "java(indexToLetter(dto.getCorrectIndex()))") + Question fromAIQuizResponseQuestionToQuestion(AIQuizResponse.Question dto); + + default Character indexToLetter(Integer index) { + return (char) ('A' + index); + } } diff --git a/src/main/java/com/be08/smart_notes/service/QuizService.java b/src/main/java/com/be08/smart_notes/service/QuizService.java index bab3996..3249d84 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizService.java @@ -1,6 +1,7 @@ package com.be08.smart_notes.service; -import com.be08.smart_notes.dto.ai.QuizQuestion; +import com.be08.smart_notes.dto.ai.AIQuizResponse; +import com.be08.smart_notes.dto.ai.QuizResponse; import com.be08.smart_notes.mapper.QuizMapper; import com.be08.smart_notes.model.Question; import com.be08.smart_notes.model.Quiz; @@ -17,20 +18,26 @@ public class QuizService { @Autowired private QuizMapper quizMapper; - public Quiz createQuiz(int userId, int sourceDocumentId, QuizQuestion quizQuestion) { - Quiz quiz = quizMapper.fromQuizResponseToQuiz(quizQuestion); + public QuizResponse saveQuizFromAIResponse(int userId, int sourceDocumentId, AIQuizResponse aiQuizResponse) { + Quiz quiz = quizMapper.fromAIQuizResponseToQuiz(aiQuizResponse); quiz.setCreatedAt(LocalDateTime.now()); quiz.setUserId(userId); for (Question question : quiz.getQuestions()) { question.setSourceDocumentId(sourceDocumentId); - question.setQuiz(null); + question.setQuiz(quiz); } - return quizRepository.save(quiz); + Quiz savedQuiz = quizRepository.save(quiz); + return quizMapper.fromQuizToQuizResponse(savedQuiz); } - public void deleteQuiz(int id) { + public QuizResponse getQuizById(int id) { + Quiz quiz = quizRepository.findById(id).orElse(null); + return quizMapper.fromQuizToQuizResponse(quiz); + } + + public void deleteQuizById(int id) { quizRepository.deleteById(id); } } diff --git a/src/main/java/com/be08/smart_notes/service/ai/AIService.java b/src/main/java/com/be08/smart_notes/service/ai/AIService.java index f6c6097..706c561 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/AIService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/AIService.java @@ -34,7 +34,7 @@ public class AIService { public String generateContent(String systemPrompt, String noteContent, String guidedSchema) { // Check environment setup - if (!checkPermission()) { + if (!checkConfiguration()) { return null; } @@ -55,9 +55,9 @@ public String generateContent(String systemPrompt, String noteContent, String gu return inferenceResponse.getChoices()[0].getMessage().getContent(); } - private boolean checkPermission() { - if (AI_API_MODEL == null) { - System.out.println("Missing AI_API_MODEL."); + private boolean checkConfiguration() { + if (AI_API_MODEL == null || AI_API_USER_ROLE == null || AI_API_SYSTEM_ROLE == null || AI_API_TEMPERATURE <= 0 || AI_API_TOP_P <= 0) { + System.out.println("Missing AI Inference Configuration."); System.out.println("Please check your .env file and try again."); return false; } diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index ceb1f92..a690dfc 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -6,31 +6,35 @@ import java.nio.file.Path; import java.util.List; -import com.be08.smart_notes.dto.ai.QuizQuestion; +import com.be08.smart_notes.dto.ai.AIQuizResponse; +import com.be08.smart_notes.dto.ai.QuizResponse; +import com.be08.smart_notes.mapper.QuizMapper; +import com.be08.smart_notes.model.Quiz; import com.be08.smart_notes.service.QuizService; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import com.google.gson.Gson; import com.be08.smart_notes.common.AppConstants; import com.be08.smart_notes.service.NoteService; -import com.be08.smart_notes.dto.ai.AIInferenceResponse; import com.be08.smart_notes.model.Document; @Service +@Slf4j public class QuizGenerationService { private String systemPrompt; private String quizResponseSchema; - - @Autowired - private QuizService quizService; + private static int DEFAULT_TOTAL_QUESTIONS = 10; @Autowired private AIService aiService; @Autowired private NoteService noteService; + @Autowired + private QuizService quizService; + @Autowired + private QuizMapper quizMapper; public QuizGenerationService() { try { @@ -43,24 +47,23 @@ public QuizGenerationService() { StandardCharsets.UTF_8 ); } catch (IOException e) { - System.out.println("An error occurred."); + System.out.println("An error occurred while loading AI configuration."); e.printStackTrace(); } } - public QuizQuestion generateSampleQuiz() { - // Below is sample response of fetchResponseFromInferenceProvider() - String responseAsJSONString = "{\"id\":\"chatcmpl-123456\",\"choices\":[{\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null,\"message\":{\"content\":\"{\\\"topic\\\": \\\"Object-Oriented Programming Concepts\\\", \\\"quizzes\\\": [\\n {\\n \\\"question\\\": \\\"What is the primary focus of Object-Oriented Programming?\\\",\\n \\\"options\\\": [\\n \\\"Writing code for specific tasks\\\",\\n \\\"Organizing code around objects\\\",\\n \\\"Implementing algorithms for complex problems\\\",\\n \\\"Using pre-defined functions\\\"\\n ],\\n \\\"correctIndex\\\": 1\\n },\\n {\\n \\\"question\\\": \\\"What is the primary benefit of encapsulation?\\\",\\n \\\"options\\\": [\\n \\\"Hiding internal details of an object\\\",\\n \\\"Allowing easy modification of an object\\\",\\n \\\"Promoting code reusability through inheritance\\\",\\n \\\"Making code more efficient and faster\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"Which of the following is an example of inheritance?\\\",\\n \\\"options\\\": [\\n \\\"Creating a class called 'Dog' that inherits from 'Animal'\\\",\\n \\\"Defining a method called 'calculateArea' within a class\\\",\\n \\\"Using a constructor to initialize an object's properties\\\",\\n \\\"Writing code to display a message on the screen\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"What does polymorphism allow?\\\",\\n \\\"options\\\": [\\n \\\"The same method to behave differently in different classes\\\",\\n \\\"A single method to handle different data types\\\",\\n \\\"Classes to inherit common functionalities from their parent classes\\\",\\n \\\"Objects to access data and methods in a controlled manner\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"What is a constructor in OOP?\\\",\\n \\\"options\\\": [\\n \\\"A method that runs when an object is created\\\",\\n \\\"A method that defines the behavior of an object\\\",\\n \\\"A method that is called repeatedly for a specific task\\\",\\n \\\"A method that handles exceptions and errors\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n },\\n {\\n \\\"question\\\": \\\"What is the role of an interface in OOP?\\\",\\n \\\"options\\\": [\\n \\\"A contract that defines a set of methods that classes must implement\\\",\\n \\\"A blueprint for creating a specific type of object\\\",\\n \\\"A way to communicate between different objects\\\",\\n \\\"A way to store and manage data\\\"\\n ],\\n \\\"correctIndex\\\": 0\\n }\\n]}\",\"refusal\":null,\"role\":\"assistant\",\"audio\":null,\"function_call\":null,\"tool_calls\":[],\"reasoning_content\":null},\"stop_reason\":null}],\"created\":1751445406,\"model\":\"google/gemma-2-2b-it\",\"object\":\"chat.completion\",\"service_tier\":null,\"system_fingerprint\":null,\"usage\":{\"completion_tokens\":531,\"prompt_tokens\":953,\"total_tokens\":1484,\"completion_tokens_details\":null,\"prompt_tokens_details\":null},\"prompt_logprobs\":null}\r\n"; + public QuizResponse generateSampleQuiz(int userId) { + // Below is sample generated content + String generatedContent = "{\"topic\":\"Object-Oriented Programming Concepts\",\"questions\":[{\"question\":\"What is the main purpose of Object-Oriented Programming (OOP)?\",\"options\":[\"A. To simplify data structures\",\"B. To organize code around objects\",\"C. To create complex algorithms\",\"D. To optimize code execution speed\"], \"correct_index\": 1}, {\"question\":\"Which of the following best describes encapsulation in OOP?\",\"options\":[\"A. Hiding internal data and exposing only necessary information\",\"B. Creating multiple objects from a single class\",\"C. Passing data between different classes\",\"D. Defining the structure of a class\",\"\"], \"correct_index\": 1}, {\"question\":\"What is the primary function of a constructor in OOP?\",\"options\":[\"A. To delete an object from memory\",\"B. To store data for an object\",\"C. To initialize an object when it is created\",\"D. To define the behavior of an object\",\"\"], \"correct_index\": 3}, {\"question\":\"How does inheritance work in OOP?\",\"options\":[\"A. It allows objects to inherit properties and methods from other objects\",\"B. It creates a new class based on an existing one and adds new features\",\"C. It allows objects to access private members of other objects\",\"D. It enables objects to communicate with each other through messages\",\"\"], \"correct_index\": 1}, {\"question\":\"What does polymorphism refer to in OOP?\",\"options\":[\"A. The ability of an object to be accessed from multiple classes\",\"B. The ability of an object to behave differently based on its context\",\"C. The ability of an object to be used in different programming languages\",\"D. The ability of an object to be inherited from other objects\",\"\"], \"correct_index\": 1}, {\"question\":\"Which of the following is NOT a benefit of OOP?\",\"options\":[\"A. Improved code reusability\",\"B. Easier code maintenance\",\"C. Increased program complexity\",\"D. Enhanced code readability\",\"\"], \"correct_index\": 3}, {\"question\":\"What is the primary difference between a class and an object?\",\"options\":[\"A. A class is a blueprint for creating objects, while an object is an instance of that blueprint\",\"B. A class is a data structure, while an object is a programming language\",\"C. A class is a variable, while an object is a function\",\"D. A class is a method, while an object is a program\",\"\"], \"correct_index\": 1}, {\"question\":\"What is the main purpose of a static method?\",\"options\":[\"A. To define a method that is specific to a particular object\",\"B. To define a method that belongs to a class and not to individual objects\",\"C. To define a method that is called when an object is created\",\"D. To define a method that is called when an object is destroyed\",\"\"], \"correct_index\": 1}, {\"question\":\"Which of the following is an example of a common mistake to avoid in OOP?\",\"options\":[\"A. Using inheritance when it is not needed\",\"B. Using public access modifiers for every method\",\"C. Using static methods for every method\",\"D. Creating complex objects that are not needed\",\"\"], \"correct_index\": 1}, {\"question\":\"What is the purpose of an interface in OOP?\",\"options\":[\"A. To define the behavior of a class\",\"B. To create a contract that classes must follow\",\"C. To define the structure of a class\",\"D. To create a blueprint for creating objects\",\"\"], \"correct_index\": 1}, {\"question\":\"What is the purpose of a method overriding?\",\"options\":[\"A. To create a new class that is based on an existing one\",\"B. To define a new method with a different implementation in a child class\",\"C. To create a new method that overrides the behavior of a parent class\",\"D. To create a new class that inherits from a different class\",\"\"], \"correct_index\": 3}]}\n"; // Extract raw message content from response string - Gson gson = new Gson(); + ObjectMapper objectMapper = new ObjectMapper(); try { // Extract raw message content from response string - AIInferenceResponse inferenceResponse = gson.fromJson(responseAsJSONString, AIInferenceResponse.class); - String chatMessageContent = inferenceResponse.getChoices()[0].getMessage().getContent(); + AIQuizResponse aiQuizResponse = objectMapper.readValue(generatedContent, AIQuizResponse.class); - // Parse to object - return gson.fromJson(chatMessageContent, QuizQuestion.class); + Quiz sampleQuizEntity = quizMapper.fromAIQuizResponseToQuiz(aiQuizResponse); + return quizMapper.fromQuizToQuizResponse(sampleQuizEntity); } catch (Exception e) { System.out.println(e.toString()); e.printStackTrace(); @@ -69,7 +72,7 @@ public QuizQuestion generateSampleQuiz() { return null; } - public QuizQuestion generateQuizFromSingleNote(int userId, int noteId) { + public QuizResponse generateQuizFromSingleNote(int userId, int noteId) { if (this.systemPrompt == null) { return null; } @@ -82,35 +85,61 @@ public QuizQuestion generateQuizFromSingleNote(int userId, int noteId) { // Generate content String generatedContent = aiService.generateContent( - String.format(this.systemPrompt, 10), + String.format(this.systemPrompt, DEFAULT_TOTAL_QUESTIONS), selectedNote.getContent(), quizResponseSchema ); if (generatedContent == null || generatedContent.isEmpty()) { return null; } + System.out.println("generatedContent: \n" + generatedContent); ObjectMapper objectMapper = new ObjectMapper(); try { - QuizQuestion quizQuestion = objectMapper.readValue(generatedContent, QuizQuestion.class); - quizService.createQuiz(userId, noteId, quizQuestion); - return quizQuestion; - } catch (JsonProcessingException e) { + AIQuizResponse aiQuizResponse = objectMapper.readValue(generatedContent, AIQuizResponse.class); + System.out.println("\naiQuizResponse: \n" + aiQuizResponse); + + QuizResponse quizResponse = quizService.saveQuizFromAIResponse(userId, noteId, aiQuizResponse); + System.out.println("\nsavedQuiz: \n" + quizResponse); + return quizResponse; + } catch (Exception e) { throw new RuntimeException(e); } } - public List generateQuizFromListOfNotes(List noteIds) { + public QuizResponse generateQuizFromListOfNotes(int userId, List noteIds) { if (this.systemPrompt == null) { return null; } + return null; // Get note - List noteList = noteService.getAllNotesByIds(noteIds); - if (noteList.isEmpty()) { - return null; - } - - return noteList; +// List noteList = noteService.getAllNotesByIds(noteIds); +// if (noteList.isEmpty()) { +// return null; +// } +// +// String systemPrompt = String.format(this.systemPrompt, DEFAULT_TOTAL_QUESTIONS); +// ObjectMapper objectMapper = new ObjectMapper(); +// for (Document note : noteList) { +// String generatedContent = aiService.generateContent( +// systemPrompt, +// note.getContent(), +// quizResponseSchema +// ); +// if (generatedContent == null || generatedContent.isEmpty()) { +// return null; +// } +// +// try { +// AIQuizResponse quizResponse = objectMapper.readValue(generatedContent, AIQuizResponse.class); +// quizService.createQuiz(userId, note.getId(), quizResponse); +// } catch (Exception e) { +// throw new RuntimeException(e); +// } +// } +// +// AIQuizResponse quizResponse = +// return noteList; } } diff --git a/src/main/resources/prompts/system_prompt.txt b/src/main/resources/prompts/system_prompt.txt index 5889891..92ed37c 100644 --- a/src/main/resources/prompts/system_prompt.txt +++ b/src/main/resources/prompts/system_prompt.txt @@ -1,5 +1,16 @@ I will give you a study note and pre-defined setting as below, generate quizzes based on the content for knowledge review. For each question, generate 4 options (A, B, C, D) where only one of the options is correct. +Format your response as follows: +**QUESTION** 1: [Your question here]? **END QUESTION** +**OPTION** 1: [First option] **END OPTION** +**OPTION** 2: [Second option] **END OPTION** +**OPTION** 3: [Third option] **END OPTION** +**OPTION** 4: [Fourth option] **END OPTION** +**ANS**: [Correct answer index (0-3)] **END ANS** + +Strictly follow above pattern for all questions. Ensure text is properly formatted. +It must start with a question, then the options array, and finally the correct answer index. + Setting: - number of questions: %s \ No newline at end of file diff --git a/src/main/resources/prompts/system_prompt_v1.txt b/src/main/resources/prompts/system_prompt_v1.txt new file mode 100644 index 0000000..5889891 --- /dev/null +++ b/src/main/resources/prompts/system_prompt_v1.txt @@ -0,0 +1,5 @@ +I will give you a study note and pre-defined setting as below, generate quizzes based on the content for knowledge review. +For each question, generate 4 options (A, B, C, D) where only one of the options is correct. + +Setting: +- number of questions: %s \ No newline at end of file diff --git a/src/main/resources/schemas/quiz_response.json b/src/main/resources/schemas/quiz_response.json index dccae8f..954aa9c 100644 --- a/src/main/resources/schemas/quiz_response.json +++ b/src/main/resources/schemas/quiz_response.json @@ -12,20 +12,18 @@ "question": { "type": "string" }, - "optionA": { - "type": "string" - }, - "optionB": { - "type": "string" - }, - "optionC": { - "type": "string" - }, - "optionD": { - "type": "string" + "options": { + "type": "array", + "items": { + "type": "string" + }, + "minLength": 4, + "maxLength": 4 }, - "correctAnswer": { - "type": "string" + "correct_index": { + "type": "number", + "minimum": 0, + "maximum": 3 } } } diff --git a/src/main/resources/schemas/quiz_response_v1.json b/src/main/resources/schemas/quiz_response_v1.json new file mode 100644 index 0000000..0d41672 --- /dev/null +++ b/src/main/resources/schemas/quiz_response_v1.json @@ -0,0 +1,46 @@ +{ + "type": "object", + "properties": { + "topic": { + "type": "string" + }, + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "question": { + "type": "string" + }, + "option_a": { + "type": "string" + }, + "option_b": { + "type": "string" + }, + "option_c": { + "type": "string" + }, + "option_d": { + "type": "string" + }, + "correct_option": { + "type": "string" + } + }, + "required": [ + "question", + "option_a", + "option_b", + "option_c", + "option_d", + "correct_option" + ] + } + } + }, + "required": [ + "topic", + "questions" + ] +} \ No newline at end of file From 8da89682e99b1a1ea08106df4b41339f300dd65d Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 30 Oct 2025 16:28:15 +1100 Subject: [PATCH 19/63] feat(note): add proper error handling and ownership verification --- .../controller/DocumentController.java | 9 ++- .../controller/NoteController.java | 26 ++++---- .../dto/request/NoteUpsertRequest.java | 14 ++-- .../dto/response/NoteResponse.java | 21 ++++++ .../be08/smart_notes/exception/ErrorCode.java | 5 ++ .../smart_notes/mapper/DocumentMapper.java | 10 +++ .../com/be08/smart_notes/model/Document.java | 4 +- .../repository/DocumentRepository.java | 10 ++- .../service/AuthorizationService.java | 29 +++++++++ .../smart_notes/service/DocumentService.java | 32 ++++++++-- .../be08/smart_notes/service/NoteService.java | 64 +++++++++++++++---- .../service/ai/QuizGenerationService.java | 3 +- target/classes/META-INF/MANIFEST.MF | 6 -- 13 files changed, 176 insertions(+), 57 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/dto/response/NoteResponse.java create mode 100644 src/main/java/com/be08/smart_notes/mapper/DocumentMapper.java create mode 100644 src/main/java/com/be08/smart_notes/service/AuthorizationService.java delete mode 100644 target/classes/META-INF/MANIFEST.MF diff --git a/src/main/java/com/be08/smart_notes/controller/DocumentController.java b/src/main/java/com/be08/smart_notes/controller/DocumentController.java index d97a857..1a51550 100644 --- a/src/main/java/com/be08/smart_notes/controller/DocumentController.java +++ b/src/main/java/com/be08/smart_notes/controller/DocumentController.java @@ -2,7 +2,9 @@ import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -17,9 +19,10 @@ @RestController @RequestMapping("/api/document") +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class DocumentController { - @Autowired - private DocumentService documentService; + DocumentService documentService; @GetMapping public ResponseEntity getAllDocuments() { diff --git a/src/main/java/com/be08/smart_notes/controller/NoteController.java b/src/main/java/com/be08/smart_notes/controller/NoteController.java index 8fcbc52..108eec1 100644 --- a/src/main/java/com/be08/smart_notes/controller/NoteController.java +++ b/src/main/java/com/be08/smart_notes/controller/NoteController.java @@ -1,9 +1,12 @@ package com.be08.smart_notes.controller; -import org.springframework.beans.factory.annotation.Autowired; +import com.be08.smart_notes.dto.response.NoteResponse; +import jakarta.validation.Valid; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -15,20 +18,18 @@ import com.be08.smart_notes.dto.request.NoteUpsertRequest; import com.be08.smart_notes.dto.response.ApiResponse; -import com.be08.smart_notes.model.Document; import com.be08.smart_notes.service.NoteService; -import com.be08.smart_notes.validation.group.OnCreate; -import com.be08.smart_notes.validation.group.OnUpdate; @RestController @RequestMapping("/api/document/note") +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class NoteController { - @Autowired - private NoteService noteService; + NoteService noteService; @PostMapping - public ResponseEntity createNote(@Validated(OnCreate.class) @RequestBody NoteUpsertRequest noteCreationRequest) { - Document createdNote = noteService.createNote(noteCreationRequest); + public ResponseEntity createNote(@RequestBody @Valid NoteUpsertRequest noteCreationRequest) { + NoteResponse createdNote = noteService.createNote(noteCreationRequest); ApiResponse apiResponse = ApiResponse.builder() .message("Note created successfully") .data(createdNote) @@ -38,7 +39,7 @@ public ResponseEntity createNote(@Validated(OnCreate.class) @RequestBody @GetMapping("/{id}") public ResponseEntity getNote(@PathVariable int id) { - Document note = noteService.getNote(id); + NoteResponse note = noteService.getNote(id); ApiResponse apiResponse = ApiResponse.builder() .message("Note fetched successfully") .data(note) @@ -47,10 +48,11 @@ public ResponseEntity getNote(@PathVariable int id) { } @PutMapping("/{id}") - public ResponseEntity updateNote(@PathVariable int id, @Validated(OnUpdate.class) @RequestBody NoteUpsertRequest noteUpdateRequest) { - noteService.updateNote(id, noteUpdateRequest); + public ResponseEntity updateNote(@PathVariable int id, @RequestBody @Valid NoteUpsertRequest noteUpdateRequest) { + NoteResponse note = noteService.updateNote(id, noteUpdateRequest); ApiResponse apiResponse = ApiResponse.builder() .message("Note updated successfully") + .data(note) .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } diff --git a/src/main/java/com/be08/smart_notes/dto/request/NoteUpsertRequest.java b/src/main/java/com/be08/smart_notes/dto/request/NoteUpsertRequest.java index 3e32eed..ac66847 100644 --- a/src/main/java/com/be08/smart_notes/dto/request/NoteUpsertRequest.java +++ b/src/main/java/com/be08/smart_notes/dto/request/NoteUpsertRequest.java @@ -1,8 +1,6 @@ package com.be08.smart_notes.dto.request; -import com.be08.smart_notes.validation.group.OnCreate; -import com.be08.smart_notes.validation.group.OnUpdate; - +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; @@ -14,11 +12,9 @@ @AllArgsConstructor @Builder public class NoteUpsertRequest { - @NotNull(groups = OnCreate.class) - private int userId; - - @NotNull(groups = {OnCreate.class, OnUpdate.class}) - private String title; - @NotNull(groups = {OnCreate.class, OnUpdate.class}) + @NotNull + private String title = "Untitled Note"; + + @NotBlank(message = "NOTE_CONTENT_EMPTY") private String content; } diff --git a/src/main/java/com/be08/smart_notes/dto/response/NoteResponse.java b/src/main/java/com/be08/smart_notes/dto/response/NoteResponse.java new file mode 100644 index 0000000..dbaa9d0 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/response/NoteResponse.java @@ -0,0 +1,21 @@ +package com.be08.smart_notes.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NoteResponse { + private Integer id; + private String title; + private String content; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java index e411e29..c12646c 100644 --- a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -29,9 +29,14 @@ public enum ErrorCode { INVALID_PASSWORD_PATTERN(2008, "Password must contain at least one uppercase letter, one lowercase letter, and one digit, and no whitespace", HttpStatus.BAD_REQUEST), USER_NOT_FOUND(2009, "User not found", HttpStatus.NOT_FOUND), UNAUTHENTICATED(2010, "Unauthenticated access", HttpStatus.UNAUTHORIZED), + ACCESS_DENIED(2011, "Access denied", HttpStatus.FORBIDDEN), // 21xx - Token-related errors REFRESH_TOKEN_EMPTY(2102, "Refresh token cannot be empty", HttpStatus.BAD_REQUEST), + + // 22xx - Document-related features + DOCUMENT_NOT_FOUND(2201, "Document not found", HttpStatus.NOT_FOUND), + NOTE_CONTENT_EMPTY(2202, "Note content cannot be empty", HttpStatus.BAD_REQUEST), ; int code; diff --git a/src/main/java/com/be08/smart_notes/mapper/DocumentMapper.java b/src/main/java/com/be08/smart_notes/mapper/DocumentMapper.java new file mode 100644 index 0000000..d41e501 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/mapper/DocumentMapper.java @@ -0,0 +1,10 @@ +package com.be08.smart_notes.mapper; + +import com.be08.smart_notes.dto.response.NoteResponse; +import com.be08.smart_notes.model.Document; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface DocumentMapper { + NoteResponse toNoteResponse(Document note); +} diff --git a/src/main/java/com/be08/smart_notes/model/Document.java b/src/main/java/com/be08/smart_notes/model/Document.java index bfeadf6..57e374c 100644 --- a/src/main/java/com/be08/smart_notes/model/Document.java +++ b/src/main/java/com/be08/smart_notes/model/Document.java @@ -28,10 +28,10 @@ public class Document { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private int id; + private Integer id; @Column(nullable = false) - private int userId; + private Integer userId; @Column(nullable = false) private String title; diff --git a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java index f2c9c20..73e1ca4 100644 --- a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java @@ -1,15 +1,13 @@ package com.be08.smart_notes.repository; -import java.time.LocalDateTime; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import com.be08.smart_notes.model.Document; +import org.springframework.stereotype.Repository; +@Repository public interface DocumentRepository extends JpaRepository { - @Query("UPDATE Document d SET d.title = :title, d.updatedAt = :updatedAt WHERE d.id = :id") - Document updateTitle(@Param(value = "id") int id, @Param(value = "title") String title, - @Param(value = "updatedAt") LocalDateTime updatedAt); + List findAllByUserId(Integer userId); } diff --git a/src/main/java/com/be08/smart_notes/service/AuthorizationService.java b/src/main/java/com/be08/smart_notes/service/AuthorizationService.java new file mode 100644 index 0000000..e534663 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/AuthorizationService.java @@ -0,0 +1,29 @@ +package com.be08.smart_notes.service; + +import com.be08.smart_notes.exception.AppException; +import com.be08.smart_notes.exception.ErrorCode; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) +@Slf4j +public class AuthorizationService { + public int getCurrentUserId() { + var context = SecurityContextHolder.getContext(); + return Integer.parseInt(context.getAuthentication().getName()); + } + + public void validateOwnership(int resourceOwnerId) { + int currentUserId = getCurrentUserId(); + if (resourceOwnerId != currentUserId) { + log.error("User does not own the target resource"); + throw new AppException(ErrorCode.ACCESS_DENIED); + } + } +} diff --git a/src/main/java/com/be08/smart_notes/service/DocumentService.java b/src/main/java/com/be08/smart_notes/service/DocumentService.java index e36f062..01fd6d0 100644 --- a/src/main/java/com/be08/smart_notes/service/DocumentService.java +++ b/src/main/java/com/be08/smart_notes/service/DocumentService.java @@ -2,23 +2,43 @@ import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; +import com.be08.smart_notes.exception.AppException; +import com.be08.smart_notes.exception.ErrorCode; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import com.be08.smart_notes.model.Document; import com.be08.smart_notes.repository.DocumentRepository; @Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@Slf4j public class DocumentService { - @Autowired - private DocumentRepository documentRepository; + AuthorizationService authorizationService; + DocumentRepository documentRepository; public List getAllDocuments() { - List documentList = documentRepository.findAll(); - return documentList; + // Get current user + int currentUserId = authorizationService.getCurrentUserId(); + + // Get document + return documentRepository.findAllByUserId(currentUserId); } public void deleteDocument(int id) { - documentRepository.deleteById(id); + // Get document + Document document = documentRepository.findById(id).orElseThrow(() -> { + log.error("Document with id {} not found", id); + throw new AppException(ErrorCode.DOCUMENT_NOT_FOUND); + }); + + // Check ownership + authorizationService.validateOwnership(document.getUserId()); + + documentRepository.delete(document); } } diff --git a/src/main/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index fa9e9fe..7fbe4a0 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -2,7 +2,14 @@ import java.time.LocalDateTime; -import org.springframework.beans.factory.annotation.Autowired; +import com.be08.smart_notes.dto.response.NoteResponse; +import com.be08.smart_notes.exception.AppException; +import com.be08.smart_notes.exception.ErrorCode; +import com.be08.smart_notes.mapper.DocumentMapper; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import com.be08.smart_notes.dto.request.NoteUpsertRequest; @@ -11,18 +18,34 @@ import com.be08.smart_notes.repository.DocumentRepository; @Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@Slf4j public class NoteService { - @Autowired - private DocumentRepository documentRepository; + AuthorizationService authorizationService; + DocumentRepository documentRepository; + DocumentMapper documentMapper; - public Document getNote(int noteId) { - Document note = documentRepository.findById(noteId).orElse(null); - return note; + public NoteResponse getNote(int noteId) { + // Get note + Document note = documentRepository.findById(noteId).orElseThrow(() -> { + log.error("Note with id {} not found", noteId); + return new AppException(ErrorCode.DOCUMENT_NOT_FOUND); + }); + + // Check ownership + authorizationService.validateOwnership(note.getUserId()); + + return documentMapper.toNoteResponse(note); } - public Document createNote(NoteUpsertRequest newData) { + public NoteResponse createNote(NoteUpsertRequest newData) { + // Get current user id + int currentUserId = authorizationService.getCurrentUserId(); + + // Create new note Document newNote = Document.builder() - .userId(newData.getUserId()) + .userId(currentUserId) .title(newData.getTitle()) .content(newData.getContent()) .type(DocumentType.NOTE) @@ -31,19 +54,36 @@ public Document createNote(NoteUpsertRequest newData) { .build(); documentRepository.save(newNote); - return newNote; + return documentMapper.toNoteResponse(newNote); } - public void updateNote(int noteId, NoteUpsertRequest updateData) { - Document note = documentRepository.findById(noteId).orElse(null); + public NoteResponse updateNote(int noteId, NoteUpsertRequest updateData) { + // Get note + Document note = documentRepository.findById(noteId).orElseThrow(() -> { + log.error("Note with id {} not found", noteId); + return new AppException(ErrorCode.DOCUMENT_NOT_FOUND); + }); + + // Check ownership + authorizationService.validateOwnership(note.getUserId()); note.setTitle(updateData.getTitle()); note.setUpdatedAt(LocalDateTime.now());; note.setContent(updateData.getContent()); - documentRepository.save(note); + Document updatedNote = documentRepository.save(note); + return documentMapper.toNoteResponse(updatedNote); } public void deleteNote(int noteId) { + // Get document + Document note = documentRepository.findById(noteId).orElseThrow(() -> { + log.error("Note with id {} not found", noteId); + throw new AppException(ErrorCode.DOCUMENT_NOT_FOUND); + }); + + // Check ownership + authorizationService.validateOwnership(note.getUserId()); + documentRepository.deleteById(noteId); } } diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index c9f7282..ea45fce 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -5,6 +5,7 @@ import java.nio.file.Files; import java.nio.file.Path; +import com.be08.smart_notes.dto.response.NoteResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -48,7 +49,7 @@ public QuizResponse generateSampleQuiz() { public QuizResponse generateQuizFromNote(int noteId) { checkPermission(); - Document selectedNote = noteService.getNote(noteId); + NoteResponse selectedNote = noteService.getNote(noteId); if (selectedNote == null) return null; diff --git a/target/classes/META-INF/MANIFEST.MF b/target/classes/META-INF/MANIFEST.MF deleted file mode 100644 index e1086ce..0000000 --- a/target/classes/META-INF/MANIFEST.MF +++ /dev/null @@ -1,6 +0,0 @@ -Manifest-Version: 1.0 -Build-Jdk-Spec: 21 -Implementation-Title: smart-notes -Implementation-Version: 0.0.1-SNAPSHOT -Created-By: Maven Integration for Eclipse - From e50b94ee3643b271bcb89808fca910c9bf6c64ca Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 30 Oct 2025 16:34:21 +1100 Subject: [PATCH 20/63] db: add dump-v3.sql with up-to-date database schema --- src/main/resources/db/dump-v3.sql | 320 ++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 src/main/resources/db/dump-v3.sql diff --git a/src/main/resources/db/dump-v3.sql b/src/main/resources/db/dump-v3.sql new file mode 100644 index 0000000..a58873d --- /dev/null +++ b/src/main/resources/db/dump-v3.sql @@ -0,0 +1,320 @@ +CREATE DATABASE IF NOT EXISTS `smartnotes` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci */ /*!80016 DEFAULT ENCRYPTION='N' */; +USE `smartnotes`; +-- MySQL dump 10.13 Distrib 8.0.43, for Win64 (x86_64) +-- +-- Host: localhost Database: smartnotes +-- ------------------------------------------------------ +-- Server version 8.0.43 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `attempt` +-- + +DROP TABLE IF EXISTS `attempt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `attempt` ( + `id` int NOT NULL AUTO_INCREMENT, + `quiz_id` int NOT NULL, + `attempt_at` datetime NOT NULL, + `total_question` int NOT NULL, + `score` int NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_attempts_quiz_id` (`quiz_id`), + CONSTRAINT `fk_attempts_quiz_id` FOREIGN KEY (`quiz_id`) REFERENCES `quiz` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `attempt` +-- + +LOCK TABLES `attempt` WRITE; +/*!40000 ALTER TABLE `attempt` DISABLE KEYS */; +/*!40000 ALTER TABLE `attempt` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `attempt_detail` +-- + +DROP TABLE IF EXISTS `attempt_detail`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `attempt_detail` ( + `id` int NOT NULL AUTO_INCREMENT, + `attempt_id` int NOT NULL, + `user_answer` char(1) NOT NULL, + `is_correct` tinyint(1) NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_attempt_details_attempt_id` (`attempt_id`), + CONSTRAINT `fk_attempt_details_attempt_id` FOREIGN KEY (`attempt_id`) REFERENCES `attempt` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `attempt_detail` +-- + +LOCK TABLES `attempt_detail` WRITE; +/*!40000 ALTER TABLE `attempt_detail` DISABLE KEYS */; +/*!40000 ALTER TABLE `attempt_detail` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `document` +-- + +DROP TABLE IF EXISTS `document`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `document` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `title` varchar(128) NOT NULL, + `type` enum('NOTE','PDF') NOT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime DEFAULT NULL, + `content` text, + `file_url` tinytext, + `file_size` int DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_documents_user_id` (`user_id`), + CONSTRAINT `fk_documents_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`), + CONSTRAINT `chk_document_type_content_file` CHECK ((((`type` = _utf8mb4'NOTE') and (`content` is not null)) or ((`type` = _utf8mb4'PDF') and (`file_url` is not null) and (`file_size` is not null)))) +) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `document` +-- + +LOCK TABLES `document` WRITE; +/*!40000 ALTER TABLE `document` DISABLE KEYS */; +INSERT INTO `document` VALUES (1,1,'Time and Space Complexity','NOTE','2025-10-13 18:00:00',NULL,'# Time and Space Complexity - Sample study note generated by AI ## What is Time Complexity? Time complexity measures how the runtime of an algorithm grows as the input size increases. We use Big O notation to describe the worst-case scenario. It helps us compare algorithms and predict performance with large datasets. ## What is Space Complexity? Space complexity measures how much memory an algorithm uses relative to input size. This includes both the space for input data and any extra space the algorithm needs to work. ## Big O Notation Basics We focus on the dominant term and ignore constants. If an algorithm takes 5n² + 3n + 7 steps, we say it\'s O(n²) because n² grows much faster than n or constant terms when n gets large. ## Common Time Complexities **O(1) - Constant Time** Takes same time regardless of input size. Examples: accessing array element by index, basic math operations, hash table lookup in best case. **O(log n) - Logarithmic Time** Very efficient, grows slowly. Examples: binary search, finding element in balanced binary search tree. If you double input size, you only add one more step. **O(n) - Linear Time** Time grows directly with input size. Examples: linear search through array, printing all elements, finding maximum value in unsorted array. **O(n log n) - Linearithmic Time** Common in good sorting algorithms. Examples: merge sort, heap sort, quick sort average case. Much better than O(n²) but slower than O(n). **O(n²) - Quadratic Time** Time grows with square of input size. Examples: bubble sort, selection sort, nested loops checking all pairs. Gets slow quickly with large inputs. **O(2ⁿ) - Exponential Time** Extremely slow for large inputs. Examples: recursive fibonacci without memoization, trying all subsets of a set. Avoid if possible. ## Space Complexity Examples **O(1) Space** - Algorithm uses same amount of extra memory regardless of input size. Examples: swapping two variables, iterative algorithms that only use a few variables. **O(n) Space** - Memory usage grows with input size. Examples: creating copy of array, recursive algorithms (call stack), merge sort temporary arrays. **O(log n) Space** - Usually from recursion depth. Examples: binary search recursive implementation, balanced tree operations. ## Analyzing Algorithms For loops: if loop runs n times, that\'s O(n). Nested loops multiply complexities together. Recursion: look at how many times function calls itself and how much work each call does. Tree-like recursion can be exponential. ## Trade-offs Sometimes we can trade time for space or vice versa. Dynamic programming uses more memory to avoid recalculating same values. Hash tables use extra space to get faster lookups. ## Practical Tips - O(1) and O(log n) are excellent for any input size - O(n) and O(n log n) are good for most practical purposes - O(n²) starts getting slow around 10,000 elements - O(2ⁿ) is only practical for very small inputs (maybe 20-30 elements) ## Appendix **Common Mistakes:** Confusing best case with worst case. Hash tables are O(1) average but O(n) worst case. Always consider worst case for Big O analysis. **Important Questions:** How does choice of data structure affect complexity? When might we prefer a slower algorithm? What\'s the relationship between recursion depth and space complexity? **Examples to Remember:** Binary search: O(log n) time, O(1) space iterative or O(log n) space recursive Merge sort: O(n log n) time, O(n) space Bubble sort: O(n²) time, O(1) space Fibonacci recursive: O(2ⁿ) time, O(n) space',NULL,NULL),(2,2,'Object-Oriented Programming Concepts','NOTE','2025-10-14 22:12:25',NULL,'# Object-Oriented Programming Concepts - Sample study note generated by AI ## What is Object-Oriented Programming? OOP is a programming approach that organizes code around objects rather than functions. An object contains both data (attributes) and methods (functions) that work with that data. Think of it like a blueprint for creating things. ## Four Main Principles - **Encapsulation**: Bundling data and methods together in a class. We hide internal details and only expose what\'s necessary. Like a car - you use the steering wheel and pedals, but don\'t need to know how the engine works internally. - **Inheritance**: Creating new classes based on existing ones. The new class gets all features of the parent class and can add its own. Example: Animal class has eat() method, Dog class inherits from Animal and adds bark() method. - **Polymorphism**: Same method name can behave differently in different classes. A draw() method works differently for Circle, Rectangle, and Triangle classes, but they all draw shapes. - **Abstraction**: Focusing on essential features while hiding complex implementation details. We know what a method does without caring about how it does it. ## Classes vs Objects Class is like a blueprint or template. Object is an actual instance created from that class. One Person class can create many person objects like john, mary, alex. ## Benefits of OOP - Code reusability through inheritance - Easier to maintain and modify - Better organization of complex programs - Matches real-world thinking patterns - Team development becomes easier ## Common Mistakes to Avoid - Making everything public instead of using proper access modifiers - Creating classes that try to do too many things - Not using inheritance when it would be helpful - Overcomplicating simple problems with unnecessary objects ## Key Terms - **Constructor** - special method that runs when object is created - **Method overriding** - child class changes parent\'s method behavior - **Interface** - contract that classes must follow - **Static methods** - belong to class, not individual objects ## Example Structure ```java class Student { private String name; private int age; public Student(String name, int age) { this.name = name; this.age = age; } public void study() { System.out.println(name + \" is studying\"); } } ```',NULL,NULL); +/*!40000 ALTER TABLE `document` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `document_tag` +-- + +DROP TABLE IF EXISTS `document_tag`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `document_tag` ( + `id` int NOT NULL AUTO_INCREMENT, + `document_id` int NOT NULL, + `tag_id` int DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_document_tags_document_id` (`document_id`), + KEY `fk_document_tags_tag_id` (`tag_id`), + CONSTRAINT `fk_document_tags_document_id` FOREIGN KEY (`document_id`) REFERENCES `document` (`id`), + CONSTRAINT `fk_document_tags_tag_id` FOREIGN KEY (`tag_id`) REFERENCES `tag` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `document_tag` +-- + +LOCK TABLES `document_tag` WRITE; +/*!40000 ALTER TABLE `document_tag` DISABLE KEYS */; +/*!40000 ALTER TABLE `document_tag` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `flashcard` +-- + +DROP TABLE IF EXISTS `flashcard`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flashcard` ( + `id` int NOT NULL AUTO_INCREMENT, + `flashcard_set_id` int NOT NULL, + `front_content` text NOT NULL, + `back_content` text NOT NULL, + `source_document_id` int NOT NULL, + `created_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_flashcards_flashcard_set_id` (`flashcard_set_id`), + KEY `fk_flashcards_source_document_id` (`source_document_id`), + CONSTRAINT `fk_flashcards_flashcard_set_id` FOREIGN KEY (`flashcard_set_id`) REFERENCES `flashcard_set` (`id`), + CONSTRAINT `fk_flashcards_source_document_id` FOREIGN KEY (`source_document_id`) REFERENCES `document` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `flashcard` +-- + +LOCK TABLES `flashcard` WRITE; +/*!40000 ALTER TABLE `flashcard` DISABLE KEYS */; +/*!40000 ALTER TABLE `flashcard` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `flashcard_set` +-- + +DROP TABLE IF EXISTS `flashcard_set`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flashcard_set` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `title` varchar(128) NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_flashcard_sets_user_id` (`user_id`), + CONSTRAINT `fk_flashcard_sets_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `flashcard_set` +-- + +LOCK TABLES `flashcard_set` WRITE; +/*!40000 ALTER TABLE `flashcard_set` DISABLE KEYS */; +/*!40000 ALTER TABLE `flashcard_set` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `question` +-- + +DROP TABLE IF EXISTS `question`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `question` ( + `id` int NOT NULL AUTO_INCREMENT, + `quiz_id` int NOT NULL, + `question_text` text NOT NULL, + `option_a` text NOT NULL, + `option_b` text NOT NULL, + `option_c` text NOT NULL, + `option_d` text NOT NULL, + `correct_answer` char(1) NOT NULL, + `source_document_id` int NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_questions_quiz_id` (`quiz_id`), + KEY `fk_questions_source_document_id` (`source_document_id`), + CONSTRAINT `fk_questions_quiz_id` FOREIGN KEY (`quiz_id`) REFERENCES `quiz` (`id`), + CONSTRAINT `fk_questions_source_document_id` FOREIGN KEY (`source_document_id`) REFERENCES `document` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=48 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `question` +-- + +LOCK TABLES `question` WRITE; +/*!40000 ALTER TABLE `question` DISABLE KEYS */; +/*!40000 ALTER TABLE `question` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `quiz` +-- + +DROP TABLE IF EXISTS `quiz`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `quiz` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `title` varchar(128) NOT NULL, + `created_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_quizzes_user_id` (`user_id`), + CONSTRAINT `fk_quizzes_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=19 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `quiz` +-- + +LOCK TABLES `quiz` WRITE; +/*!40000 ALTER TABLE `quiz` DISABLE KEYS */; +/*!40000 ALTER TABLE `quiz` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `tag` +-- + +DROP TABLE IF EXISTS `tag`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `tag` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `name` varchar(50) NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_tags_user_id` (`user_id`), + CONSTRAINT `fk_tags_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `tag` +-- + +LOCK TABLES `tag` WRITE; +/*!40000 ALTER TABLE `tag` DISABLE KEYS */; +/*!40000 ALTER TABLE `tag` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `user` +-- + +DROP TABLE IF EXISTS `user`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user` ( + `id` int NOT NULL AUTO_INCREMENT, + `email` varchar(50) NOT NULL, + `password` varchar(60) NOT NULL, + `name` varchar(50) NOT NULL, + `avatar_url` tinytext, + `created_at` datetime NOT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `user` +-- + +LOCK TABLES `user` WRITE; +/*!40000 ALTER TABLE `user` DISABLE KEYS */; +INSERT INTO `user` VALUES (1,'john@gmail.com','john','John',NULL,'2025-10-13 18:00:00',NULL),(2,'mary@gmail.com','mary','Mary',NULL,'2025-10-22 18:00:00',NULL),(3,'tcook08@gmail.com','$2a$10$p8G3W8wbrax1Si3443fWz.OD.qRQ3Q8UZ1tl7Bo3WxJoObhDB.O.6','Tim Cook8',NULL,'2025-10-29 23:08:13',NULL); +/*!40000 ALTER TABLE `user` ENABLE KEYS */; +UNLOCK TABLES; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2025-10-30 16:32:04 From e16fee03155d0ac171eb3e873cd9bccd9ab6a1d1 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 30 Oct 2025 17:00:58 +1100 Subject: [PATCH 21/63] docs: update README with new note endpoints --- README.md | 14 +++++++++++--- .../smart_notes/controller/DocumentController.java | 2 +- .../smart_notes/controller/NoteController.java | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 52f80f6..2ddb51b 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,17 @@ Personal project for smart note taking application ## Supported Features | Module | Method | API | Description | | -------- | -------- | -------- | ------- | -| Document | GET | `/api/document/all` | List all available documents | -| Note | GET | `/api/document/note/{id}` | Get note content using its id | -| | POST | `/api/document/note/create` | Create new note | +| Authentication | POST | `/api/auth/register` | Register new user | +| | POST | `/api/auth/login` | Login with your account | +| | POST | `/api/auth/logout` | Logout of current session | +| | POST | `/api/auth/refresh` | Refresh your authentication token | +| | GET | `/api/users/me` | Get your information (logged in) | +| Document | GET | `/api/documents` | List all available documents | +| | DELETE | `/api/documents/{id}` | Delete document using its id | +| Note | POST | `/api/documents/notes` | Create new note | +| | GET | `/api/documents/notes/{id}` | Get note content using its id | +| | PUT | `/api/documents/notes/{id}` | Update note content using its id | +| | DELETE | `/api/documents/notes/{id}` | Delete note content using its id | | AI | GET | `/api/ai/generateQuiz/sample` | Get a sample quiz from sample response, this API does not require API TOKEN | | | GET | `/api/ai/generateQuiz/{noteId}` | Generate relevant quizzes based on 1 note, this feature **requires HuggingFace API TOKEN** to run | diff --git a/src/main/java/com/be08/smart_notes/controller/DocumentController.java b/src/main/java/com/be08/smart_notes/controller/DocumentController.java index 1a51550..6e269d9 100644 --- a/src/main/java/com/be08/smart_notes/controller/DocumentController.java +++ b/src/main/java/com/be08/smart_notes/controller/DocumentController.java @@ -18,7 +18,7 @@ import com.be08.smart_notes.service.DocumentService; @RestController -@RequestMapping("/api/document") +@RequestMapping("/api/documents") @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class DocumentController { diff --git a/src/main/java/com/be08/smart_notes/controller/NoteController.java b/src/main/java/com/be08/smart_notes/controller/NoteController.java index 108eec1..23de57f 100644 --- a/src/main/java/com/be08/smart_notes/controller/NoteController.java +++ b/src/main/java/com/be08/smart_notes/controller/NoteController.java @@ -21,7 +21,7 @@ import com.be08.smart_notes.service.NoteService; @RestController -@RequestMapping("/api/document/note") +@RequestMapping("/api/documents/notes") @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class NoteController { From 6c69852caa1c8cd905c000fcdc3ba894fb0c63ed Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Sun, 2 Nov 2025 22:30:33 +1100 Subject: [PATCH 22/63] feat(ai-quiz): add AI properties configuration and error handling with exception --- pom.xml | 2 - .../config/AIInferenceProperties.java | 30 ++++++ .../smart_notes/config/RestClientConfig.java | 23 +++-- .../smart_notes/controller/AIController.java | 17 ++-- .../controller/QuizController.java | 13 ++- .../QuizGenerationRequest.java | 2 +- .../dto/{ai => response}/QuizResponse.java | 2 +- .../be08/smart_notes/exception/ErrorCode.java | 6 ++ .../be08/smart_notes/mapper/QuizMapper.java | 2 +- .../repository/DocumentRepository.java | 2 + .../be08/smart_notes/service/QuizService.java | 50 +++++++--- .../smart_notes/service/ai/AIService.java | 96 ++++++++---------- .../service/ai/QuizGenerationService.java | 98 ++++++++++--------- src/main/resources/application.properties | 10 ++ 14 files changed, 216 insertions(+), 137 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/config/AIInferenceProperties.java rename src/main/java/com/be08/smart_notes/dto/{ai => request}/QuizGenerationRequest.java (84%) rename src/main/java/com/be08/smart_notes/dto/{ai => response}/QuizResponse.java (95%) diff --git a/pom.xml b/pom.xml index f7b4b85..6649e4b 100644 --- a/pom.xml +++ b/pom.xml @@ -152,8 +152,6 @@ jackson-datatype-jsr310 - - diff --git a/src/main/java/com/be08/smart_notes/config/AIInferenceProperties.java b/src/main/java/com/be08/smart_notes/config/AIInferenceProperties.java new file mode 100644 index 0000000..dfc9295 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/config/AIInferenceProperties.java @@ -0,0 +1,30 @@ +package com.be08.smart_notes.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Data +@Configuration +@ConfigurationProperties(prefix = "ai.api") +public class AIInferenceProperties { + // Authentication + private String url; + private String token; + + // Inference + private String model; + private String userRole; + private String systemRole; + private Double temperature; + private Double topP; + + public boolean isMissingCredentials() { + return url.isEmpty() || token.isEmpty(); + } + + public boolean isMissingModelConfig() { + return model.isEmpty() || userRole.isEmpty() || systemRole.isEmpty() + || temperature == null || topP == null; + } +} diff --git a/src/main/java/com/be08/smart_notes/config/RestClientConfig.java b/src/main/java/com/be08/smart_notes/config/RestClientConfig.java index c92d8ba..3dfbaa3 100644 --- a/src/main/java/com/be08/smart_notes/config/RestClientConfig.java +++ b/src/main/java/com/be08/smart_notes/config/RestClientConfig.java @@ -1,5 +1,9 @@ package com.be08.smart_notes.config; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -7,19 +11,24 @@ import org.springframework.web.client.RestClient; @Configuration +@RequiredArgsConstructor +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class RestClientConfig { - @Value("${API_TOKEN}") - private String AI_API_TOKEN; - - @Value("${API_URL}") - private String AI_API_URL; + AIInferenceProperties properties; @Bean public RestClient nebiusRestClient() { + if (properties.isMissingCredentials()) { + log.warn("Could not create Rest Client for AI Inference, credentials are not configured"); + return RestClient.builder().build(); + } + + log.info("Successfully loaded credential configuration for AI Inference"); return RestClient.builder() - .baseUrl(AI_API_URL) + .baseUrl(properties.getUrl()) .defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) - .defaultHeader("Authorization", "Bearer " + AI_API_TOKEN) + .defaultHeader("Authorization", "Bearer " + properties.getToken()) .build(); } } diff --git a/src/main/java/com/be08/smart_notes/controller/AIController.java b/src/main/java/com/be08/smart_notes/controller/AIController.java index 996a9d1..d48fab5 100644 --- a/src/main/java/com/be08/smart_notes/controller/AIController.java +++ b/src/main/java/com/be08/smart_notes/controller/AIController.java @@ -1,8 +1,10 @@ package com.be08.smart_notes.controller; -import com.be08.smart_notes.dto.ai.QuizResponse; +import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.response.ApiResponse; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -11,14 +13,14 @@ @RestController @RequestMapping("/api/ai/quiz/") +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class AIController { - @Autowired - private QuizGenerationService quizGenerationService; + QuizGenerationService quizGenerationService; @GetMapping("/generate/sample") public ResponseEntity generateSampleQuiz() { - int userId = 1; - QuizResponse quizResponse = quizGenerationService.generateSampleQuiz(userId); + QuizResponse quizResponse = quizGenerationService.generateSampleQuiz(); ApiResponse apiResponse = ApiResponse.builder() .message("Sample quiz generated created successfully") .data(quizResponse) @@ -28,8 +30,7 @@ public ResponseEntity generateSampleQuiz() { @GetMapping("/generate/{noteId}") public ResponseEntity generateQuiz(@PathVariable int noteId) { - int userId = 1; - QuizResponse quizResponse = quizGenerationService.generateQuizFromSingleNote(userId, noteId); + QuizResponse quizResponse = quizGenerationService.generateQuizFromSingleNote(noteId); ApiResponse apiResponse = ApiResponse.builder() .message("Quiz generated created successfully") .data(quizResponse) diff --git a/src/main/java/com/be08/smart_notes/controller/QuizController.java b/src/main/java/com/be08/smart_notes/controller/QuizController.java index 35773a0..1cff60c 100644 --- a/src/main/java/com/be08/smart_notes/controller/QuizController.java +++ b/src/main/java/com/be08/smart_notes/controller/QuizController.java @@ -1,9 +1,12 @@ package com.be08.smart_notes.controller; import com.be08.smart_notes.dto.ai.AIQuizResponse; -import com.be08.smart_notes.dto.ai.QuizResponse; +import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.response.ApiResponse; import com.be08.smart_notes.service.QuizService; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -11,14 +14,14 @@ @RestController @RequestMapping("/api/quiz") +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class QuizController { - @Autowired - private QuizService quizService; + QuizService quizService; @PostMapping public ResponseEntity createQuiz(@RequestBody AIQuizResponse aiQuizResponse) { - int userId = 1; - QuizResponse quizResponse = quizService.saveQuizFromAIResponse(userId, 1, aiQuizResponse); + QuizResponse quizResponse = quizService.saveQuizFromAIResponse(1, aiQuizResponse); ApiResponse apiResponse = ApiResponse.builder() .message("Quiz created successfully") .data(quizResponse) diff --git a/src/main/java/com/be08/smart_notes/dto/ai/QuizGenerationRequest.java b/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java similarity index 84% rename from src/main/java/com/be08/smart_notes/dto/ai/QuizGenerationRequest.java rename to src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java index 2d4581a..2c35e0a 100644 --- a/src/main/java/com/be08/smart_notes/dto/ai/QuizGenerationRequest.java +++ b/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java @@ -1,4 +1,4 @@ -package com.be08.smart_notes.dto.ai; +package com.be08.smart_notes.dto.request; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java similarity index 95% rename from src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java rename to src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java index 6c8665e..ff278ab 100644 --- a/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java @@ -1,4 +1,4 @@ -package com.be08.smart_notes.dto.ai; +package com.be08.smart_notes.dto.response; import java.time.LocalDateTime; import java.util.List; diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java index c12646c..0abbdd0 100644 --- a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -37,6 +37,12 @@ public enum ErrorCode { // 22xx - Document-related features DOCUMENT_NOT_FOUND(2201, "Document not found", HttpStatus.NOT_FOUND), NOTE_CONTENT_EMPTY(2202, "Note content cannot be empty", HttpStatus.BAD_REQUEST), + + // 23xx - AI-related features + FAILED_INFERENCE_REQUEST(2301, "AI inference request failed", HttpStatus.INTERNAL_SERVER_ERROR), + + // 24xx - Quiz-related features + QUIZ_NOT_FOUND(2401, "Quiz not found", HttpStatus.NOT_FOUND), ; int code; diff --git a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java index a34ef52..c887f92 100644 --- a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java @@ -1,7 +1,7 @@ package com.be08.smart_notes.mapper; import com.be08.smart_notes.dto.ai.AIQuizResponse; -import com.be08.smart_notes.dto.ai.QuizResponse; +import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.model.Question; import com.be08.smart_notes.model.Quiz; import org.mapstruct.Mapper; diff --git a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java index dff0fa2..85b93fe 100644 --- a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java @@ -5,6 +5,8 @@ import org.springframework.data.jpa.repository.JpaRepository; import com.be08.smart_notes.model.Document; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository diff --git a/src/main/java/com/be08/smart_notes/service/QuizService.java b/src/main/java/com/be08/smart_notes/service/QuizService.java index 3249d84..a55cd16 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizService.java @@ -1,43 +1,71 @@ package com.be08.smart_notes.service; import com.be08.smart_notes.dto.ai.AIQuizResponse; -import com.be08.smart_notes.dto.ai.QuizResponse; +import com.be08.smart_notes.dto.response.QuizResponse; +import com.be08.smart_notes.exception.AppException; +import com.be08.smart_notes.exception.ErrorCode; import com.be08.smart_notes.mapper.QuizMapper; import com.be08.smart_notes.model.Question; import com.be08.smart_notes.model.Quiz; import com.be08.smart_notes.repository.QuizRepository; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@Slf4j public class QuizService { - @Autowired - private QuizRepository quizRepository; - @Autowired - private QuizMapper quizMapper; + AuthorizationService authorizationService; + QuizRepository quizRepository; + QuizMapper quizMapper; - public QuizResponse saveQuizFromAIResponse(int userId, int sourceDocumentId, AIQuizResponse aiQuizResponse) { + public QuizResponse saveQuizFromAIResponse(int sourceDocumentId, AIQuizResponse aiQuizResponse) { + // Get current user id + int currentUserId = authorizationService.getCurrentUserId(); + + // Map DTO to entity Quiz quiz = quizMapper.fromAIQuizResponseToQuiz(aiQuizResponse); quiz.setCreatedAt(LocalDateTime.now()); - quiz.setUserId(userId); + quiz.setUserId(currentUserId); for (Question question : quiz.getQuestions()) { question.setSourceDocumentId(sourceDocumentId); question.setQuiz(quiz); } + // Create and return saved quiz Quiz savedQuiz = quizRepository.save(quiz); return quizMapper.fromQuizToQuizResponse(savedQuiz); } - public QuizResponse getQuizById(int id) { - Quiz quiz = quizRepository.findById(id).orElse(null); + public QuizResponse getQuizById(int quizId) { + Quiz quiz = quizRepository.findById(quizId).orElseThrow(() -> { + log.error("Quiz with id {} not found", quizId); + return new AppException(ErrorCode.QUIZ_NOT_FOUND); + }); + + // Check ownership + authorizationService.validateOwnership(quiz.getUserId()); + return quizMapper.fromQuizToQuizResponse(quiz); } - public void deleteQuizById(int id) { - quizRepository.deleteById(id); + public void deleteQuizById(int quizId) { + Quiz quiz = quizRepository.findById(quizId).orElseThrow(() -> { + log.error("Quiz with id {} not found", quizId); + return new AppException(ErrorCode.QUIZ_NOT_FOUND); + }); + + // Check ownership + authorizationService.validateOwnership(quiz.getUserId()); + + quizRepository.deleteById(quizId); } } diff --git a/src/main/java/com/be08/smart_notes/service/ai/AIService.java b/src/main/java/com/be08/smart_notes/service/ai/AIService.java index 706c561..b4ba660 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/AIService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/AIService.java @@ -1,52 +1,43 @@ package com.be08.smart_notes.service.ai; +import com.be08.smart_notes.config.AIInferenceProperties; import com.be08.smart_notes.dto.ai.AIInferenceRequest; import com.be08.smart_notes.dto.ai.AIInferenceResponse; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.github.victools.jsonschema.generator.*; -import com.github.victools.jsonschema.module.jackson.JacksonModule; -import com.github.victools.jsonschema.module.jakarta.validation.JakartaValidationModule; -import com.github.victools.jsonschema.module.jakarta.validation.JakartaValidationOption; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; +import com.be08.smart_notes.exception.AppException; +import com.be08.smart_notes.exception.ErrorCode; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; @Service +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@Slf4j public class AIService { - @Autowired - private RestClient restClient; + AIInferenceProperties properties; + RestClient restClient; - @Value("${MODEL}") - protected String AI_API_MODEL; - - @Value("${AI_API_USER_ROLE}") - protected String AI_API_USER_ROLE; - - @Value("${AI_API_SYSTEM_ROLE}") - protected String AI_API_SYSTEM_ROLE; - - @Value("${AI_API_TEMPERATURE}") - protected double AI_API_TEMPERATURE; - - @Value("${AI_API_TOP_P}") - protected double AI_API_TOP_P; + public AIService(AIInferenceProperties properties, RestClient restClient) { + this.properties = properties; + this.restClient = restClient; + } public String generateContent(String systemPrompt, String noteContent, String guidedSchema) { - // Check environment setup - if (!checkConfiguration()) { - return null; + if (properties.isMissingCredentials() || properties.isMissingModelConfig()) { + log.error("AI Inference Configuration is missing or invalid. Please check your .env file and try again"); + throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } // Create inference JSON body AIInferenceRequest info = AIInferenceRequest.builder() - .model(AI_API_MODEL) - .temperature(AI_API_TEMPERATURE) - .topP(AI_API_TOP_P) + .model(properties.getModel()) + .temperature(properties.getTemperature()) + .topP(properties.getTopP()) .guidedJson(guidedSchema) .messages(new AIInferenceRequest.RequestMessage[] { - new AIInferenceRequest.RequestMessage(AI_API_SYSTEM_ROLE, systemPrompt), - new AIInferenceRequest.RequestMessage(AI_API_USER_ROLE, noteContent) + new AIInferenceRequest.RequestMessage(properties.getSystemRole(), systemPrompt), + new AIInferenceRequest.RequestMessage(properties.getUserRole(), noteContent) }) .build(); @@ -55,15 +46,6 @@ public String generateContent(String systemPrompt, String noteContent, String gu return inferenceResponse.getChoices()[0].getMessage().getContent(); } - private boolean checkConfiguration() { - if (AI_API_MODEL == null || AI_API_USER_ROLE == null || AI_API_SYSTEM_ROLE == null || AI_API_TEMPERATURE <= 0 || AI_API_TOP_P <= 0) { - System.out.println("Missing AI Inference Configuration."); - System.out.println("Please check your .env file and try again."); - return false; - } - return true; - } - private AIInferenceResponse fetchResponseFromInferenceProvider(AIInferenceRequest info) { AIInferenceResponse response = restClient.post() .body(info) @@ -73,21 +55,21 @@ private AIInferenceResponse fetchResponseFromInferenceProvider(AIInferenceReques } // Unused methods, will debug later - private String generateGuidedSchema(Class ClassType) { - SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON); - - JakartaValidationModule validationModule = new JakartaValidationModule( - JakartaValidationOption.NOT_NULLABLE_FIELD_IS_REQUIRED, - JakartaValidationOption.INCLUDE_PATTERN_EXPRESSIONS - ); - configBuilder.with(new JacksonModule()); - configBuilder.with(validationModule); - - SchemaGeneratorConfig config = configBuilder.build(); - SchemaGenerator generator = new SchemaGenerator(config); - ObjectNode jsonSchema = generator.generateSchema(ClassType); - jsonSchema.remove("$schema"); - - return jsonSchema.toString(); - } +// private String generateGuidedSchema(Class ClassType) { +// SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON); +// +// JakartaValidationModule validationModule = new JakartaValidationModule( +// JakartaValidationOption.NOT_NULLABLE_FIELD_IS_REQUIRED, +// JakartaValidationOption.INCLUDE_PATTERN_EXPRESSIONS +// ); +// configBuilder.with(new JacksonModule()); +// configBuilder.with(validationModule); +// +// SchemaGeneratorConfig config = configBuilder.build(); +// SchemaGenerator generator = new SchemaGenerator(config); +// ObjectNode jsonSchema = generator.generateSchema(ClassType); +// jsonSchema.remove("$schema"); +// +// return jsonSchema.toString(); +// } } \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index a690dfc..8069fff 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -7,53 +7,62 @@ import java.util.List; import com.be08.smart_notes.dto.ai.AIQuizResponse; -import com.be08.smart_notes.dto.ai.QuizResponse; +import com.be08.smart_notes.dto.response.QuizResponse; +import com.be08.smart_notes.dto.response.NoteResponse; +import com.be08.smart_notes.exception.AppException; +import com.be08.smart_notes.exception.ErrorCode; import com.be08.smart_notes.mapper.QuizMapper; import com.be08.smart_notes.model.Quiz; import com.be08.smart_notes.service.QuizService; import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.be08.smart_notes.common.AppConstants; import com.be08.smart_notes.service.NoteService; -import com.be08.smart_notes.model.Document; @Service +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) @Slf4j public class QuizGenerationService { - private String systemPrompt; - private String quizResponseSchema; - private static int DEFAULT_TOTAL_QUESTIONS = 10; - - @Autowired - private AIService aiService; - @Autowired - private NoteService noteService; - @Autowired - private QuizService quizService; - @Autowired - private QuizMapper quizMapper; - - public QuizGenerationService() { + static int DEFAULT_TOTAL_QUESTIONS = 10; + String systemPrompt; + String quizResponseSchema; + + AIService aiService; + NoteService noteService; + QuizService quizService; + QuizMapper quizMapper; + + public QuizGenerationService(AIService aiService, NoteService noteService, QuizService quizService, QuizMapper quizMapper) { + this.aiService = aiService; + this.noteService = noteService; + this.quizService = quizService; + this.quizMapper = quizMapper; + + String prompt = null; + String schema = null; try { - systemPrompt = Files.readString( + prompt = Files.readString( Path.of(AppConstants.SYSTEM_PROMPT_TEMPLATE_PATH), StandardCharsets.UTF_8 ); - quizResponseSchema = Files.readString( + schema = Files.readString( Path.of(AppConstants.QUIZ_RESPONSE_SCHEMA_PATH), StandardCharsets.UTF_8 ); } catch (IOException e) { - System.out.println("An error occurred while loading AI configuration."); - e.printStackTrace(); + log.error("Load resource for quiz generation failed with error: {}", e.getMessage()); } + + systemPrompt = prompt; + quizResponseSchema = schema; } - public QuizResponse generateSampleQuiz(int userId) { - // Below is sample generated content + public QuizResponse generateSampleQuiz() { + // Below is sample generated content String generatedContent = "{\"topic\":\"Object-Oriented Programming Concepts\",\"questions\":[{\"question\":\"What is the main purpose of Object-Oriented Programming (OOP)?\",\"options\":[\"A. To simplify data structures\",\"B. To organize code around objects\",\"C. To create complex algorithms\",\"D. To optimize code execution speed\"], \"correct_index\": 1}, {\"question\":\"Which of the following best describes encapsulation in OOP?\",\"options\":[\"A. Hiding internal data and exposing only necessary information\",\"B. Creating multiple objects from a single class\",\"C. Passing data between different classes\",\"D. Defining the structure of a class\",\"\"], \"correct_index\": 1}, {\"question\":\"What is the primary function of a constructor in OOP?\",\"options\":[\"A. To delete an object from memory\",\"B. To store data for an object\",\"C. To initialize an object when it is created\",\"D. To define the behavior of an object\",\"\"], \"correct_index\": 3}, {\"question\":\"How does inheritance work in OOP?\",\"options\":[\"A. It allows objects to inherit properties and methods from other objects\",\"B. It creates a new class based on an existing one and adds new features\",\"C. It allows objects to access private members of other objects\",\"D. It enables objects to communicate with each other through messages\",\"\"], \"correct_index\": 1}, {\"question\":\"What does polymorphism refer to in OOP?\",\"options\":[\"A. The ability of an object to be accessed from multiple classes\",\"B. The ability of an object to behave differently based on its context\",\"C. The ability of an object to be used in different programming languages\",\"D. The ability of an object to be inherited from other objects\",\"\"], \"correct_index\": 1}, {\"question\":\"Which of the following is NOT a benefit of OOP?\",\"options\":[\"A. Improved code reusability\",\"B. Easier code maintenance\",\"C. Increased program complexity\",\"D. Enhanced code readability\",\"\"], \"correct_index\": 3}, {\"question\":\"What is the primary difference between a class and an object?\",\"options\":[\"A. A class is a blueprint for creating objects, while an object is an instance of that blueprint\",\"B. A class is a data structure, while an object is a programming language\",\"C. A class is a variable, while an object is a function\",\"D. A class is a method, while an object is a program\",\"\"], \"correct_index\": 1}, {\"question\":\"What is the main purpose of a static method?\",\"options\":[\"A. To define a method that is specific to a particular object\",\"B. To define a method that belongs to a class and not to individual objects\",\"C. To define a method that is called when an object is created\",\"D. To define a method that is called when an object is destroyed\",\"\"], \"correct_index\": 1}, {\"question\":\"Which of the following is an example of a common mistake to avoid in OOP?\",\"options\":[\"A. Using inheritance when it is not needed\",\"B. Using public access modifiers for every method\",\"C. Using static methods for every method\",\"D. Creating complex objects that are not needed\",\"\"], \"correct_index\": 1}, {\"question\":\"What is the purpose of an interface in OOP?\",\"options\":[\"A. To define the behavior of a class\",\"B. To create a contract that classes must follow\",\"C. To define the structure of a class\",\"D. To create a blueprint for creating objects\",\"\"], \"correct_index\": 1}, {\"question\":\"What is the purpose of a method overriding?\",\"options\":[\"A. To create a new class that is based on an existing one\",\"B. To define a new method with a different implementation in a child class\",\"C. To create a new method that overrides the behavior of a parent class\",\"D. To create a new class that inherits from a different class\",\"\"], \"correct_index\": 3}]}\n"; // Extract raw message content from response string @@ -65,23 +74,19 @@ public QuizResponse generateSampleQuiz(int userId) { Quiz sampleQuizEntity = quizMapper.fromAIQuizResponseToQuiz(aiQuizResponse); return quizMapper.fromQuizToQuizResponse(sampleQuizEntity); } catch (Exception e) { - System.out.println(e.toString()); - e.printStackTrace(); + log.error("An error occurred when mapping objects, could not create sample quiz."); + throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } - - return null; } - public QuizResponse generateQuizFromSingleNote(int userId, int noteId) { - if (this.systemPrompt == null) { - return null; + public QuizResponse generateQuizFromSingleNote(int noteId) { + if (this.systemPrompt == null || this.quizResponseSchema == null) { + log.error("Could not generate quiz because of invalid system prompt and/or guided schema."); + throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } // Get note - Document selectedNote = noteService.getNote(noteId); - if (selectedNote == null) { - return null; - } + NoteResponse selectedNote = noteService.getNote(noteId); // Generate content String generatedContent = aiService.generateContent( @@ -90,24 +95,29 @@ public QuizResponse generateQuizFromSingleNote(int userId, int noteId) { quizResponseSchema ); if (generatedContent == null || generatedContent.isEmpty()) { - return null; + log.error("Could not create quiz because generated content is empty."); + throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } - System.out.println("generatedContent: \n" + generatedContent); + // Map generated content (JSON String) to Object ObjectMapper objectMapper = new ObjectMapper(); + AIQuizResponse aiQuizResponse = null; try { - AIQuizResponse aiQuizResponse = objectMapper.readValue(generatedContent, AIQuizResponse.class); - System.out.println("\naiQuizResponse: \n" + aiQuizResponse); - - QuizResponse quizResponse = quizService.saveQuizFromAIResponse(userId, noteId, aiQuizResponse); - System.out.println("\nsavedQuiz: \n" + quizResponse); - return quizResponse; + aiQuizResponse = objectMapper.readValue(generatedContent, AIQuizResponse.class); } catch (Exception e) { - throw new RuntimeException(e); + log.error("Could not map generated content to AIQuizResponse."); + throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } + if (aiQuizResponse == null) { + log.error("Could not generate quiz because of invalid object mapping result."); + throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); + } + + // Save and return quiz from mapped object + return quizService.saveQuizFromAIResponse(noteId, aiQuizResponse); } - public QuizResponse generateQuizFromListOfNotes(int userId, List noteIds) { + public QuizResponse generateQuizFromListOfNotes(List noteIds) { if (this.systemPrompt == null) { return null; } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 17f3e34..7998768 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -13,6 +13,16 @@ spring.datasource.diver-class-name =com.mysql.cj.jdbc.Driver spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true +# AI Inference Configuration +ai.api.url=${API_URL} +ai.api.token=${API_TOKEN} +ai.api.model=${MODEL} + +ai.api.user-role=${AI_API_USER_ROLE:user} +ai.api.system-role=${AI_API_SYSTEM_ROLE:system} +ai.api.temperature=${AI_API_TEMPERATURE:0.7} +ai.api.top-p=${AI_API_TOP_P:0.9} + # JWT Configuration jwt.access-token.expiration-minutes=15 jwt.refresh-token.expiration-days=7 From c7d85000ca94a51b7d5c8c054c21d307d7f04172 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 4 Nov 2025 16:23:08 +1100 Subject: [PATCH 23/63] feat(quiz): add quiz set entity to group quizzes --- ...oller.java => AIGenerationController.java} | 21 +++-- ...Controller.java => QuizSetController.java} | 31 ++++--- .../smart_notes/dto/ai/AIQuizResponse.java | 1 + .../dto/request/QuizGenerationRequest.java | 11 +++ .../dto/response/QuizResponse.java | 9 +- .../dto/response/QuizSetResponse.java | 19 ++++ .../be08/smart_notes/exception/ErrorCode.java | 2 + .../be08/smart_notes/mapper/QuizMapper.java | 31 +++++-- .../com/be08/smart_notes/model/Question.java | 3 - .../java/com/be08/smart_notes/model/Quiz.java | 35 +++++-- .../com/be08/smart_notes/model/QuizSet.java | 56 +++++++++++ .../repository/DocumentRepository.java | 6 +- .../repository/QuizSetRepository.java | 10 ++ .../be08/smart_notes/service/NoteService.java | 11 ++- .../be08/smart_notes/service/QuizService.java | 30 +----- .../smart_notes/service/QuizSetService.java | 92 +++++++++++++++++++ .../service/ai/QuizGenerationService.java | 86 +++++++++++------ 17 files changed, 346 insertions(+), 108 deletions(-) rename src/main/java/com/be08/smart_notes/controller/{AIController.java => AIGenerationController.java} (69%) rename src/main/java/com/be08/smart_notes/controller/{QuizController.java => QuizSetController.java} (56%) create mode 100644 src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java create mode 100644 src/main/java/com/be08/smart_notes/model/QuizSet.java create mode 100644 src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java create mode 100644 src/main/java/com/be08/smart_notes/service/QuizSetService.java diff --git a/src/main/java/com/be08/smart_notes/controller/AIController.java b/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java similarity index 69% rename from src/main/java/com/be08/smart_notes/controller/AIController.java rename to src/main/java/com/be08/smart_notes/controller/AIGenerationController.java index d48fab5..36e0aa7 100644 --- a/src/main/java/com/be08/smart_notes/controller/AIController.java +++ b/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java @@ -2,6 +2,7 @@ import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.response.ApiResponse; +import com.be08.smart_notes.dto.response.QuizSetResponse; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; @@ -12,28 +13,28 @@ import com.be08.smart_notes.service.ai.QuizGenerationService; @RestController -@RequestMapping("/api/ai/quiz/") +@RequestMapping("/api/ai/generation") @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) -public class AIController { +public class AIGenerationController { QuizGenerationService quizGenerationService; - @GetMapping("/generate/sample") - public ResponseEntity generateSampleQuiz() { + @GetMapping("/quiz-sets/sample") + public ResponseEntity generateSampleQuizSet() { QuizResponse quizResponse = quizGenerationService.generateSampleQuiz(); ApiResponse apiResponse = ApiResponse.builder() - .message("Sample quiz generated created successfully") + .message("Sample quiz set generated created successfully") .data(quizResponse) .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } - @GetMapping("/generate/{noteId}") - public ResponseEntity generateQuiz(@PathVariable int noteId) { - QuizResponse quizResponse = quizGenerationService.generateQuizFromSingleNote(noteId); + @PostMapping("/quiz-sets/{noteId}") + public ResponseEntity generateQuizSet(@PathVariable int noteId) { + QuizSetResponse quizSetResponse = quizGenerationService.generateQuiz(noteId); ApiResponse apiResponse = ApiResponse.builder() - .message("Quiz generated created successfully") - .data(quizResponse) + .message("Quiz set generated created successfully") + .data(quizSetResponse) .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } diff --git a/src/main/java/com/be08/smart_notes/controller/QuizController.java b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java similarity index 56% rename from src/main/java/com/be08/smart_notes/controller/QuizController.java rename to src/main/java/com/be08/smart_notes/controller/QuizSetController.java index 1cff60c..2b5ff20 100644 --- a/src/main/java/com/be08/smart_notes/controller/QuizController.java +++ b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java @@ -3,47 +3,48 @@ import com.be08.smart_notes.dto.ai.AIQuizResponse; import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.response.ApiResponse; -import com.be08.smart_notes.service.QuizService; +import com.be08.smart_notes.dto.response.QuizSetResponse; +import com.be08.smart_notes.service.QuizSetService; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/api/quiz") +@RequestMapping("/api/quiz-sets") @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) -public class QuizController { - QuizService quizService; +public class QuizSetController { + QuizSetService quizSetService; + // --- Operations on QuizSets --- // @PostMapping - public ResponseEntity createQuiz(@RequestBody AIQuizResponse aiQuizResponse) { - QuizResponse quizResponse = quizService.saveQuizFromAIResponse(1, aiQuizResponse); + public ResponseEntity createQuizSet(@RequestBody AIQuizResponse aiQuizResponse) { + QuizSetResponse quizSetResponse = quizSetService.saveQuizSetFromAIResponse(null, aiQuizResponse); ApiResponse apiResponse = ApiResponse.builder() - .message("Quiz created successfully") - .data(quizResponse) + .message("Quiz set created successfully") + .data(quizSetResponse) .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } @DeleteMapping("/{id}") - public ResponseEntity deleteQuiz(@PathVariable int id) { - quizService.deleteQuizById(id); + public ResponseEntity deleteQuizSet(@PathVariable int id) { + quizSetService.deleteQuizSetById(id); ApiResponse apiResponse = ApiResponse.builder() - .message("Quiz deleted successfully") + .message("Quiz set deleted successfully") .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } @GetMapping("/{id}") - public ResponseEntity getQuiz(@PathVariable int id) { - QuizResponse quizResponse = quizService.getQuizById(id); + public ResponseEntity getQuizSet(@PathVariable int id) { + QuizSetResponse quizSetResponse = quizSetService.getQuizSetById(id); ApiResponse apiResponse = ApiResponse.builder() .message("Quiz fetched successfully") - .data(quizResponse) + .data(quizSetResponse) .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } diff --git a/src/main/java/com/be08/smart_notes/dto/ai/AIQuizResponse.java b/src/main/java/com/be08/smart_notes/dto/ai/AIQuizResponse.java index 3283270..f00df9d 100644 --- a/src/main/java/com/be08/smart_notes/dto/ai/AIQuizResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/ai/AIQuizResponse.java @@ -1,5 +1,6 @@ package com.be08.smart_notes.dto.ai; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; diff --git a/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java b/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java index 2c35e0a..11bcb44 100644 --- a/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java +++ b/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java @@ -1,6 +1,8 @@ package com.be08.smart_notes.dto.request; +import jakarta.validation.constraints.*; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @@ -9,6 +11,15 @@ @Data @NoArgsConstructor @AllArgsConstructor +@Builder public class QuizGenerationRequest { + @NotEmpty + @Size(min = 1, max = 5, message = "QUIZ_DOCUMENT_SIZE_EXCEED") private List ids; + + @NotNull + @Builder.Default + @Min(value = 1, message = "INVALID_QUIZ_SIZE") + @Max(value = 50, message = "INVALID_QUIZ_SIZE") + private Integer totalQuestions = 10; } diff --git a/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java index ff278ab..bc87ef2 100644 --- a/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java @@ -18,9 +18,15 @@ public class QuizResponse { @NotNull private LocalDateTime createdAt; + @NotNull + private LocalDateTime updatedAt; + @NotNull private String title; + @NotNull + private Integer sourceDocumentId; + @NotNull private List questions; @@ -49,8 +55,5 @@ public static class Question { @NotNull private String correctAnswer; - - @NotNull - private Integer sourceDocumentId; } } diff --git a/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java b/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java new file mode 100644 index 0000000..361b5e9 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java @@ -0,0 +1,19 @@ +package com.be08.smart_notes.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class QuizSetResponse { + private Integer id; + private String title; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private List quizzes; +} diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java index 0abbdd0..f9ba543 100644 --- a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -43,6 +43,8 @@ public enum ErrorCode { // 24xx - Quiz-related features QUIZ_NOT_FOUND(2401, "Quiz not found", HttpStatus.NOT_FOUND), + QUIZ_DOCUMENT_SIZE_EXCEED(2402, "Number of quiz IDs must be between 1 and 5", HttpStatus.BAD_REQUEST), + INVALID_QUIZ_SIZE(2403, "Total number of questions must between 1 and 50", HttpStatus.BAD_REQUEST), ; int code; diff --git a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java index c887f92..edcb3b8 100644 --- a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java @@ -2,30 +2,47 @@ import com.be08.smart_notes.dto.ai.AIQuizResponse; import com.be08.smart_notes.dto.response.QuizResponse; +import com.be08.smart_notes.dto.response.QuizSetResponse; import com.be08.smart_notes.model.Question; import com.be08.smart_notes.model.Quiz; +import com.be08.smart_notes.model.QuizSet; +import org.mapstruct.AfterMapping; import org.mapstruct.Mapper; import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; + +import java.util.List; @Mapper(componentModel = "spring") public interface QuizMapper { - // Quiz & Question entity <--> QuizResponse DTO - QuizResponse fromQuizToQuizResponse(Quiz entity); - Quiz fromQuizToQuizResponse(QuizResponse dto); + // QuizSet entity <--> QuizSetResponse dto + QuizSetResponse toQuizSetResponse(QuizSet quizSet); + + // Quiz Entity <--> QuizResponse dto + QuizResponse toQuizResponse(Quiz entity); + Quiz toQuiz(QuizResponse dto); - // AIQuizResponse DTO --> Quiz & Question entity - Quiz fromAIQuizResponseToQuiz(AIQuizResponse dto); + // Quiz Entity <--> AIQuizResponse dto + Quiz toQuiz(AIQuizResponse dto); + // Question Entity <--> AIQuizResponse.Question dto @Mapping(target = "id", ignore = true) - @Mapping(target = "sourceDocumentId", ignore = true) @Mapping(target = "optionA", expression = "java(dto.getOptions()[0])") @Mapping(target = "optionB", expression = "java(dto.getOptions()[1])") @Mapping(target = "optionC", expression = "java(dto.getOptions()[2])") @Mapping(target = "optionD", expression = "java(dto.getOptions()[3])") @Mapping(target = "correctAnswer", expression = "java(indexToLetter(dto.getCorrectIndex()))") - Question fromAIQuizResponseQuestionToQuestion(AIQuizResponse.Question dto); + Question toQuestionEntity(AIQuizResponse.Question dto); default Character indexToLetter(Integer index) { return (char) ('A' + index); } + + @AfterMapping + default void linkQuizToQuestions(@MappingTarget Quiz quiz) { + List questions = quiz.getQuestions(); + for (Question question : questions) { + question.setQuiz(quiz); + } + } } diff --git a/src/main/java/com/be08/smart_notes/model/Question.java b/src/main/java/com/be08/smart_notes/model/Question.java index 92692a0..b4cffb0 100644 --- a/src/main/java/com/be08/smart_notes/model/Question.java +++ b/src/main/java/com/be08/smart_notes/model/Question.java @@ -36,9 +36,6 @@ public class Question { @Column(nullable = false, name = "correct_answer") private Character correctAnswer; - @Column(nullable = false, name = "source_document_id") - private Integer sourceDocumentId; - // Relationship @JsonIgnore @ManyToOne diff --git a/src/main/java/com/be08/smart_notes/model/Quiz.java b/src/main/java/com/be08/smart_notes/model/Quiz.java index d05f3db..d073eb3 100644 --- a/src/main/java/com/be08/smart_notes/model/Quiz.java +++ b/src/main/java/com/be08/smart_notes/model/Quiz.java @@ -1,5 +1,6 @@ package com.be08.smart_notes.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; @@ -20,16 +21,38 @@ public class Quiz { @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; - @Column(nullable = false, name = "user_id") - private Integer userId; - - @Column(nullable = false) - private String title; +// @Column(nullable = false, name = "user_id") +// private Integer userId; @Column(nullable = false, name = "created_at") private LocalDateTime createdAt; - // Relationship + @Column(nullable = false, name = "updated_at") + private LocalDateTime updatedAt; + + @Column(nullable = false) + private String title; + + // Relationship: quiz - question @OneToMany(mappedBy = "quiz", cascade = CascadeType.ALL, orphanRemoval = true) private List questions; + + // Relationship: quiz - quiz set + @JsonIgnore + @ManyToOne + @JoinColumn(name = "quiz_set_id", referencedColumnName = "id") + private QuizSet quizSet; + + @Column(nullable = false, name = "source_document_id") + private Integer sourceDocumentId; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/be08/smart_notes/model/QuizSet.java b/src/main/java/com/be08/smart_notes/model/QuizSet.java new file mode 100644 index 0000000..47300f7 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/model/QuizSet.java @@ -0,0 +1,56 @@ +package com.be08.smart_notes.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity(name = "quiz_set") +public class QuizSet { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, name = "user_id") + private Integer userId; + + @Column(nullable = false, name = "title") + private String title; + + @Column(nullable = false, name = "created_at") + private LocalDateTime createdAt; + + @Column(nullable = false, name = "updated_at") + private LocalDateTime updatedAt; + + // Relationship + @OneToMany(mappedBy = "quizSet", cascade = CascadeType.ALL, orphanRemoval = true) + private List quizzes; + + public void addQuiz(Quiz quiz){ + if (this.quizzes == null) { + this.quizzes = new ArrayList<>(); + } + quizzes.add(quiz); + quiz.setQuizSet(this); + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java index 85b93fe..e5dc472 100644 --- a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java @@ -12,8 +12,8 @@ @Repository public interface DocumentRepository extends JpaRepository { List findAllByUserId(Integer userId); - List findAllByIdIn(List ids); + List findAllByUserIdAndIdIn(Integer userId, List ids); - @Query("SELECT d.content FROM Document d WHERE d.id IN :ids") - List findAllContentFromIdIn(@Param(value="ids") List ids); +// @Query("SELECT d.content FROM Document d WHERE d.id IN :ids") +// List findAllContentFromIdIn(@Param(value="ids") List ids); } diff --git a/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java b/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java new file mode 100644 index 0000000..982a6ed --- /dev/null +++ b/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java @@ -0,0 +1,10 @@ +package com.be08.smart_notes.repository; + +import com.be08.smart_notes.model.QuizSet; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface QuizSetRepository extends JpaRepository { + Optional findByTitleAndUserId(String title, int userId); +} diff --git a/src/main/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index 34a8094..efd4189 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -41,6 +41,13 @@ public NoteResponse getNote(int noteId) { return documentMapper.toNoteResponse(note); } + public List getNotesByIds(List noteIds) { + // Get current user id + int currentUserId = authorizationService.getCurrentUserId(); + + return documentRepository.findAllByUserIdAndIdIn(currentUserId, noteIds); + } + public NoteResponse createNote(NoteUpsertRequest newData) { // Get current user id int currentUserId = authorizationService.getCurrentUserId(); @@ -88,8 +95,4 @@ public void deleteNote(int noteId) { documentRepository.deleteById(noteId); } - - public List getAllNotesByIds(List noteIds) { - return documentRepository.findAllByIdIn(noteIds); - } } diff --git a/src/main/java/com/be08/smart_notes/service/QuizService.java b/src/main/java/com/be08/smart_notes/service/QuizService.java index a55cd16..72f877e 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizService.java @@ -1,22 +1,17 @@ package com.be08.smart_notes.service; -import com.be08.smart_notes.dto.ai.AIQuizResponse; import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.exception.AppException; import com.be08.smart_notes.exception.ErrorCode; import com.be08.smart_notes.mapper.QuizMapper; -import com.be08.smart_notes.model.Question; import com.be08.smart_notes.model.Quiz; import com.be08.smart_notes.repository.QuizRepository; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import java.time.LocalDateTime; - @Service @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) @@ -26,25 +21,6 @@ public class QuizService { QuizRepository quizRepository; QuizMapper quizMapper; - public QuizResponse saveQuizFromAIResponse(int sourceDocumentId, AIQuizResponse aiQuizResponse) { - // Get current user id - int currentUserId = authorizationService.getCurrentUserId(); - - // Map DTO to entity - Quiz quiz = quizMapper.fromAIQuizResponseToQuiz(aiQuizResponse); - quiz.setCreatedAt(LocalDateTime.now()); - quiz.setUserId(currentUserId); - - for (Question question : quiz.getQuestions()) { - question.setSourceDocumentId(sourceDocumentId); - question.setQuiz(quiz); - } - - // Create and return saved quiz - Quiz savedQuiz = quizRepository.save(quiz); - return quizMapper.fromQuizToQuizResponse(savedQuiz); - } - public QuizResponse getQuizById(int quizId) { Quiz quiz = quizRepository.findById(quizId).orElseThrow(() -> { log.error("Quiz with id {} not found", quizId); @@ -52,9 +28,9 @@ public QuizResponse getQuizById(int quizId) { }); // Check ownership - authorizationService.validateOwnership(quiz.getUserId()); + authorizationService.validateOwnership(quiz.getQuizSet().getUserId()); - return quizMapper.fromQuizToQuizResponse(quiz); + return quizMapper.toQuizResponse(quiz); } public void deleteQuizById(int quizId) { @@ -64,7 +40,7 @@ public void deleteQuizById(int quizId) { }); // Check ownership - authorizationService.validateOwnership(quiz.getUserId()); + authorizationService.validateOwnership(quiz.getQuizSet().getUserId()); quizRepository.deleteById(quizId); } diff --git a/src/main/java/com/be08/smart_notes/service/QuizSetService.java b/src/main/java/com/be08/smart_notes/service/QuizSetService.java new file mode 100644 index 0000000..fbf53a2 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/QuizSetService.java @@ -0,0 +1,92 @@ +package com.be08.smart_notes.service; + +import com.be08.smart_notes.dto.ai.AIQuizResponse; +import com.be08.smart_notes.dto.response.QuizSetResponse; +import com.be08.smart_notes.exception.AppException; +import com.be08.smart_notes.exception.ErrorCode; +import com.be08.smart_notes.mapper.QuizMapper; +import com.be08.smart_notes.model.Quiz; +import com.be08.smart_notes.model.QuizSet; +import com.be08.smart_notes.repository.QuizSetRepository; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@Slf4j +public class QuizSetService { + static String DEFAULT_SET_TITLE = "Unsorted Quizzes"; + + AuthorizationService authorizationService; + QuizSetRepository quizSetRepository; + QuizMapper quizMapper; + + /** + * Save QuizSet including single Quiz with associated questions from AI Quiz Response + * @param aiQuizResponse AIQuizResponse + * @return QuizSetResponse + */ + public QuizSetResponse saveQuizSetFromAIResponse(Integer sourceDocumentId, AIQuizResponse aiQuizResponse) { + // Get current user id + int currentUserId = authorizationService.getCurrentUserId(); + + // Map DTO to Quiz entity + Quiz quiz = quizMapper.toQuiz(aiQuizResponse); + quiz.setCreatedAt(LocalDateTime.now()); + quiz.setSourceDocumentId(sourceDocumentId); + + // Create new QuizSet and add new quiz + QuizSet quizSet = QuizSet.builder() + .userId(currentUserId) + .title(aiQuizResponse.getTitle()) + .build(); + quizSet.addQuiz(quiz); + + // Create and return saved quiz + QuizSet savedQuizSet = quizSetRepository.save(quizSet); + return quizMapper.toQuizSetResponse(savedQuizSet); + } + +// public QuizSet getOrCreateDefaultSet() { +// // Get current user id +// int currentUserId = authorizationService.getCurrentUserId(); +// +// return quizSetRepository.findByTitleAndUserId(DEFAULT_SET_TITLE, currentUserId).orElseGet(() -> { +// QuizSet quizSet = QuizSet.builder() +// .userId(currentUserId) +// .title(DEFAULT_SET_TITLE) +// .build(); +// return quizSetRepository.save(quizSet); +// }); +// } + + public QuizSetResponse getQuizSetById(int quizSetId) { + QuizSet quiz = quizSetRepository.findById(quizSetId).orElseThrow(() -> { + log.error("Quiz set with id {} not found", quizSetId); + return new AppException(ErrorCode.QUIZ_NOT_FOUND); + }); + + // Check ownership + authorizationService.validateOwnership(quiz.getUserId()); + + return quizMapper.toQuizSetResponse(quiz); + } + + public void deleteQuizSetById(int quizSetId) { + QuizSet quiz = quizSetRepository.findById(quizSetId).orElseThrow(() -> { + log.error("Quiz set with id {} not found", quizSetId); + return new AppException(ErrorCode.QUIZ_NOT_FOUND); + }); + + // Check ownership + authorizationService.validateOwnership(quiz.getUserId()); + + quizSetRepository.deleteById(quizSetId); + } +} diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index 8069fff..d682d88 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -4,16 +4,21 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import com.be08.smart_notes.dto.ai.AIQuizResponse; +import com.be08.smart_notes.dto.request.QuizGenerationRequest; import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.response.NoteResponse; +import com.be08.smart_notes.dto.response.QuizSetResponse; import com.be08.smart_notes.exception.AppException; import com.be08.smart_notes.exception.ErrorCode; import com.be08.smart_notes.mapper.QuizMapper; +import com.be08.smart_notes.model.Document; import com.be08.smart_notes.model.Quiz; -import com.be08.smart_notes.service.QuizService; +import com.be08.smart_notes.model.QuizSet; +import com.be08.smart_notes.service.QuizSetService; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AccessLevel; import lombok.experimental.FieldDefaults; @@ -27,19 +32,18 @@ @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) @Slf4j public class QuizGenerationService { - static int DEFAULT_TOTAL_QUESTIONS = 10; String systemPrompt; String quizResponseSchema; AIService aiService; NoteService noteService; - QuizService quizService; + QuizSetService quizSetService; QuizMapper quizMapper; - public QuizGenerationService(AIService aiService, NoteService noteService, QuizService quizService, QuizMapper quizMapper) { + public QuizGenerationService(AIService aiService, NoteService noteService, QuizSetService quizSetService, QuizMapper quizMapper) { this.aiService = aiService; this.noteService = noteService; - this.quizService = quizService; + this.quizSetService = quizSetService; this.quizMapper = quizMapper; String prompt = null; @@ -71,15 +75,15 @@ public QuizResponse generateSampleQuiz() { // Extract raw message content from response string AIQuizResponse aiQuizResponse = objectMapper.readValue(generatedContent, AIQuizResponse.class); - Quiz sampleQuizEntity = quizMapper.fromAIQuizResponseToQuiz(aiQuizResponse); - return quizMapper.fromQuizToQuizResponse(sampleQuizEntity); + Quiz sampleQuizEntity = quizMapper.toQuiz(aiQuizResponse); + return quizMapper.toQuizResponse(sampleQuizEntity); } catch (Exception e) { log.error("An error occurred when mapping objects, could not create sample quiz."); throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } } - public QuizResponse generateQuizFromSingleNote(int noteId) { + public QuizSetResponse generateQuiz(int noteId) { if (this.systemPrompt == null || this.quizResponseSchema == null) { log.error("Could not generate quiz because of invalid system prompt and/or guided schema."); throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); @@ -90,12 +94,12 @@ public QuizResponse generateQuizFromSingleNote(int noteId) { // Generate content String generatedContent = aiService.generateContent( - String.format(this.systemPrompt, DEFAULT_TOTAL_QUESTIONS), + String.format(this.systemPrompt, 10), selectedNote.getContent(), quizResponseSchema ); if (generatedContent == null || generatedContent.isEmpty()) { - log.error("Could not create quiz because generated content is empty."); + log.error("Could not process quiz because generated content is empty."); throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } @@ -114,42 +118,64 @@ public QuizResponse generateQuizFromSingleNote(int noteId) { } // Save and return quiz from mapped object - return quizService.saveQuizFromAIResponse(noteId, aiQuizResponse); + return quizSetService.saveQuizSetFromAIResponse(noteId, aiQuizResponse); } - public QuizResponse generateQuizFromListOfNotes(List noteIds) { - if (this.systemPrompt == null) { - return null; - } - - return null; - // Get note -// List noteList = noteService.getAllNotesByIds(noteIds); -// if (noteList.isEmpty()) { -// return null; +// public QuizSetResponse generateQuiz(QuizGenerationRequest quizGenerationRequest) { +// if (this.systemPrompt == null || this.quizResponseSchema == null) { +// log.error("Could not generate quiz because of invalid system prompt and/or guided schema."); +// throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); // } // -// String systemPrompt = String.format(this.systemPrompt, DEFAULT_TOTAL_QUESTIONS); -// ObjectMapper objectMapper = new ObjectMapper(); +// // Extract required data from request +// int totalQuestions = quizGenerationRequest.getTotalQuestions(); +// String systemPrompt = String.format(this.systemPrompt, totalQuestions); +// +// // Get list of notes +// List noteList = noteService.getNotesByIds(quizGenerationRequest.getIds()); +// +// // Generate quiz from notes (one by one) +// List aiQuizResponseList = new ArrayList<>(); // for (Document note : noteList) { // String generatedContent = aiService.generateContent( // systemPrompt, // note.getContent(), // quizResponseSchema // ); +// +// // Generate content // if (generatedContent == null || generatedContent.isEmpty()) { -// return null; +// log.error("Error when generating quiz for note {}: Generated content is empty.", note.getId()); +// continue; // } // +// // Map generated content (JSON String) to Object +// ObjectMapper objectMapper = new ObjectMapper(); +// AIQuizResponse aiQuizResponse = null; // try { -// AIQuizResponse quizResponse = objectMapper.readValue(generatedContent, AIQuizResponse.class); -// quizService.createQuiz(userId, note.getId(), quizResponse); +// aiQuizResponse = objectMapper.readValue(generatedContent, AIQuizResponse.class); // } catch (Exception e) { -// throw new RuntimeException(e); +// log.error("Error when generating quiz for note {}: Could not map generated content to AIQuizResponse.", note.getId()); +// continue; +// } +// if (aiQuizResponse == null) { +// log.error("Error when generating quiz for note {}: Invalid object mapping result.", note.getId()); +// continue; // } +// +// // Add to combined quiz resposne object +// aiQuizResponse.setSourceDocumentId(note.getId()); +// aiQuizResponseList.add(aiQuizResponse); // } // -// AIQuizResponse quizResponse = -// return noteList; - } +// +// QuizResponse quizResponse = quizSetService.saveQuizFromAIResponse(aiQuizResponse); +// +// +// +// +// +// // Save and return quiz from mapped object +// return quizSetService.saveQuizFromAIResponse(noteId, aiQuizResponse); +// } } From 17234941f2d60c9e4e14c4bd3d4780132aed02e5 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 4 Nov 2025 22:00:53 +1100 Subject: [PATCH 24/63] feat(ai-quiz): add quiz generation with multiple notes --- .../controller/AIGenerationController.java | 19 ++- .../controller/QuizSetController.java | 17 ++- .../AIQuizResponse.java => QuizQuestion.java} | 7 +- .../be08/smart_notes/mapper/QuizMapper.java | 11 +- .../java/com/be08/smart_notes/model/Quiz.java | 2 +- .../com/be08/smart_notes/model/QuizSet.java | 11 +- .../repository/DocumentRepository.java | 1 + .../repository/QuizSetRepository.java | 2 + .../be08/smart_notes/service/NoteService.java | 6 +- .../smart_notes/service/QuizSetService.java | 77 +++++++----- .../service/ai/QuizGenerationService.java | 117 ++++++------------ 11 files changed, 134 insertions(+), 136 deletions(-) rename src/main/java/com/be08/smart_notes/dto/{ai/AIQuizResponse.java => QuizQuestion.java} (89%) diff --git a/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java b/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java index 36e0aa7..5bcb6ed 100644 --- a/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java +++ b/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java @@ -1,5 +1,6 @@ package com.be08.smart_notes.controller; +import com.be08.smart_notes.dto.request.QuizGenerationRequest; import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.response.ApiResponse; import com.be08.smart_notes.dto.response.QuizSetResponse; @@ -12,6 +13,8 @@ import com.be08.smart_notes.service.ai.QuizGenerationService; +import java.util.List; + @RestController @RequestMapping("/api/ai/generation") @RequiredArgsConstructor @@ -29,19 +32,13 @@ public ResponseEntity generateSampleQuizSet() { return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } - @PostMapping("/quiz-sets/{noteId}") - public ResponseEntity generateQuizSet(@PathVariable int noteId) { - QuizSetResponse quizSetResponse = quizGenerationService.generateQuiz(noteId); + @PostMapping("/quiz-sets") + public ResponseEntity generateQuizSet(@RequestBody QuizGenerationRequest quizGenerationRequest) { + QuizSetResponse quizSetResponse = quizGenerationService.generateQuiz(quizGenerationRequest); ApiResponse apiResponse = ApiResponse.builder() .message("Quiz set generated created successfully") .data(quizSetResponse) .build(); - return ResponseEntity.status(HttpStatus.OK).body(apiResponse); - } - -// @PostMapping("/generate") -// public ResponseEntity generateQuizFromNotes(@RequestBody QuizGenerationRequest quizGenerationRequest) { -// List quizList = quizGenerationService.generateQuizFromListOfNotes(quizGenerationRequest.getIds()); -// return ResponseEntity.status(HttpStatus.OK).body(quizList); -// } + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } } diff --git a/src/main/java/com/be08/smart_notes/controller/QuizSetController.java b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java index 2b5ff20..bbfac30 100644 --- a/src/main/java/com/be08/smart_notes/controller/QuizSetController.java +++ b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java @@ -1,7 +1,6 @@ package com.be08.smart_notes.controller; -import com.be08.smart_notes.dto.ai.AIQuizResponse; -import com.be08.smart_notes.dto.response.QuizResponse; +import com.be08.smart_notes.dto.QuizQuestion; import com.be08.smart_notes.dto.response.ApiResponse; import com.be08.smart_notes.dto.response.QuizSetResponse; import com.be08.smart_notes.service.QuizSetService; @@ -19,10 +18,9 @@ public class QuizSetController { QuizSetService quizSetService; - // --- Operations on QuizSets --- // @PostMapping - public ResponseEntity createQuizSet(@RequestBody AIQuizResponse aiQuizResponse) { - QuizSetResponse quizSetResponse = quizSetService.saveQuizSetFromAIResponse(null, aiQuizResponse); + public ResponseEntity createQuizSet(@RequestBody QuizQuestion quizQuestion) { + QuizSetResponse quizSetResponse = quizSetService.saveQuizSet(quizQuestion); ApiResponse apiResponse = ApiResponse.builder() .message("Quiz set created successfully") .data(quizSetResponse) @@ -30,6 +28,15 @@ public ResponseEntity createQuizSet(@RequestBody AIQuizResponse aiQuizRe return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } + @DeleteMapping + public ResponseEntity deleteAllQuizSet() { + quizSetService.deleteAllQuizSet(); + ApiResponse apiResponse = ApiResponse.builder() + .message("All quiz set deleted successfully") + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + @DeleteMapping("/{id}") public ResponseEntity deleteQuizSet(@PathVariable int id) { quizSetService.deleteQuizSetById(id); diff --git a/src/main/java/com/be08/smart_notes/dto/ai/AIQuizResponse.java b/src/main/java/com/be08/smart_notes/dto/QuizQuestion.java similarity index 89% rename from src/main/java/com/be08/smart_notes/dto/ai/AIQuizResponse.java rename to src/main/java/com/be08/smart_notes/dto/QuizQuestion.java index f00df9d..c14c29f 100644 --- a/src/main/java/com/be08/smart_notes/dto/ai/AIQuizResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/QuizQuestion.java @@ -1,6 +1,5 @@ -package com.be08.smart_notes.dto.ai; +package com.be08.smart_notes.dto; -import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -15,7 +14,9 @@ @Data @NoArgsConstructor @AllArgsConstructor -public class AIQuizResponse { +public class QuizQuestion { + private Integer sourceDocumentId; + @NotNull @JsonProperty(value = "topic") private String title; diff --git a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java index edcb3b8..e2464e8 100644 --- a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java @@ -1,6 +1,6 @@ package com.be08.smart_notes.mapper; -import com.be08.smart_notes.dto.ai.AIQuizResponse; +import com.be08.smart_notes.dto.QuizQuestion; import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.response.QuizSetResponse; import com.be08.smart_notes.model.Question; @@ -22,17 +22,18 @@ public interface QuizMapper { QuizResponse toQuizResponse(Quiz entity); Quiz toQuiz(QuizResponse dto); - // Quiz Entity <--> AIQuizResponse dto - Quiz toQuiz(AIQuizResponse dto); + // Quiz Entity <--> QuizQuestion + List toQuizList(List dtoList); + Quiz toQuiz(QuizQuestion dto); - // Question Entity <--> AIQuizResponse.Question dto + // Question Entity <--> QuizQuestion.Question dto @Mapping(target = "id", ignore = true) @Mapping(target = "optionA", expression = "java(dto.getOptions()[0])") @Mapping(target = "optionB", expression = "java(dto.getOptions()[1])") @Mapping(target = "optionC", expression = "java(dto.getOptions()[2])") @Mapping(target = "optionD", expression = "java(dto.getOptions()[3])") @Mapping(target = "correctAnswer", expression = "java(indexToLetter(dto.getCorrectIndex()))") - Question toQuestionEntity(AIQuizResponse.Question dto); + Question toQuestionEntity(QuizQuestion.Question dto); default Character indexToLetter(Integer index) { return (char) ('A' + index); diff --git a/src/main/java/com/be08/smart_notes/model/Quiz.java b/src/main/java/com/be08/smart_notes/model/Quiz.java index d073eb3..07340e5 100644 --- a/src/main/java/com/be08/smart_notes/model/Quiz.java +++ b/src/main/java/com/be08/smart_notes/model/Quiz.java @@ -43,7 +43,7 @@ public class Quiz { @JoinColumn(name = "quiz_set_id", referencedColumnName = "id") private QuizSet quizSet; - @Column(nullable = false, name = "source_document_id") + @Column(name = "source_document_id") private Integer sourceDocumentId; @PrePersist diff --git a/src/main/java/com/be08/smart_notes/model/QuizSet.java b/src/main/java/com/be08/smart_notes/model/QuizSet.java index 47300f7..ca7ec66 100644 --- a/src/main/java/com/be08/smart_notes/model/QuizSet.java +++ b/src/main/java/com/be08/smart_notes/model/QuizSet.java @@ -44,13 +44,22 @@ public void addQuiz(Quiz quiz){ quiz.setQuizSet(this); } + public void addQuizzes(List newQuizzes){ + if (this.quizzes == null) { + this.quizzes = new ArrayList<>(); + } + newQuizzes.forEach(quiz -> quiz.setQuizSet(this)); + quizzes.addAll(newQuizzes); + } + @PrePersist protected void onCreate() { this.createdAt = LocalDateTime.now(); this.updatedAt = LocalDateTime.now(); } - @PreUpdate void onUpdate() { + @PreUpdate + protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } } diff --git a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java index e5dc472..c6bbeb9 100644 --- a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java @@ -12,6 +12,7 @@ @Repository public interface DocumentRepository extends JpaRepository { List findAllByUserId(Integer userId); + List findAllByIdIn(List ids); List findAllByUserIdAndIdIn(Integer userId, List ids); // @Query("SELECT d.content FROM Document d WHERE d.id IN :ids") diff --git a/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java b/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java index 982a6ed..0952c3b 100644 --- a/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java @@ -7,4 +7,6 @@ public interface QuizSetRepository extends JpaRepository { Optional findByTitleAndUserId(String title, int userId); + + void deleteAllByUserId(int userId); } diff --git a/src/main/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index efd4189..0a89d9e 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -41,13 +41,17 @@ public NoteResponse getNote(int noteId) { return documentMapper.toNoteResponse(note); } - public List getNotesByIds(List noteIds) { + public List getAllNotesByUserIdAndIds(List noteIds) { // Get current user id int currentUserId = authorizationService.getCurrentUserId(); return documentRepository.findAllByUserIdAndIdIn(currentUserId, noteIds); } + public List getAllNotesByIds(List noteIds) { + return documentRepository.findAllByIdIn(noteIds); + } + public NoteResponse createNote(NoteUpsertRequest newData) { // Get current user id int currentUserId = authorizationService.getCurrentUserId(); diff --git a/src/main/java/com/be08/smart_notes/service/QuizSetService.java b/src/main/java/com/be08/smart_notes/service/QuizSetService.java index fbf53a2..b28bb7f 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizSetService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizSetService.java @@ -1,6 +1,6 @@ package com.be08.smart_notes.service; -import com.be08.smart_notes.dto.ai.AIQuizResponse; +import com.be08.smart_notes.dto.QuizQuestion; import com.be08.smart_notes.dto.response.QuizSetResponse; import com.be08.smart_notes.exception.AppException; import com.be08.smart_notes.exception.ErrorCode; @@ -13,15 +13,17 @@ import lombok.experimental.FieldDefaults; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Service @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) @Slf4j public class QuizSetService { - static String DEFAULT_SET_TITLE = "Unsorted Quizzes"; + public static String DEFAULT_QUIZ_SET_TITLE = "Untitled Quiz Set"; AuthorizationService authorizationService; QuizSetRepository quizSetRepository; @@ -29,50 +31,29 @@ public class QuizSetService { /** * Save QuizSet including single Quiz with associated questions from AI Quiz Response - * @param aiQuizResponse AIQuizResponse + * @param quizQuestion QuizQuestion * @return QuizSetResponse */ - public QuizSetResponse saveQuizSetFromAIResponse(Integer sourceDocumentId, AIQuizResponse aiQuizResponse) { - // Get current user id + public QuizSetResponse saveQuizSet(QuizQuestion quizQuestion) { int currentUserId = authorizationService.getCurrentUserId(); - // Map DTO to Quiz entity - Quiz quiz = quizMapper.toQuiz(aiQuizResponse); - quiz.setCreatedAt(LocalDateTime.now()); - quiz.setSourceDocumentId(sourceDocumentId); + QuizSet savedQuizSet = saveQuizSetEntity(currentUserId, quizQuestion); + return quizMapper.toQuizSetResponse(savedQuizSet); + } - // Create new QuizSet and add new quiz - QuizSet quizSet = QuizSet.builder() - .userId(currentUserId) - .title(aiQuizResponse.getTitle()) - .build(); - quizSet.addQuiz(quiz); + public QuizSetResponse saveQuizSet(String quizSetTitle, List quizQuestionList) { + int currentUserId = authorizationService.getCurrentUserId(); - // Create and return saved quiz - QuizSet savedQuizSet = quizSetRepository.save(quizSet); + QuizSet savedQuizSet = saveQuizSetEntity(currentUserId, quizSetTitle, quizQuestionList); return quizMapper.toQuizSetResponse(savedQuizSet); } -// public QuizSet getOrCreateDefaultSet() { -// // Get current user id -// int currentUserId = authorizationService.getCurrentUserId(); -// -// return quizSetRepository.findByTitleAndUserId(DEFAULT_SET_TITLE, currentUserId).orElseGet(() -> { -// QuizSet quizSet = QuizSet.builder() -// .userId(currentUserId) -// .title(DEFAULT_SET_TITLE) -// .build(); -// return quizSetRepository.save(quizSet); -// }); -// } - public QuizSetResponse getQuizSetById(int quizSetId) { QuizSet quiz = quizSetRepository.findById(quizSetId).orElseThrow(() -> { log.error("Quiz set with id {} not found", quizSetId); return new AppException(ErrorCode.QUIZ_NOT_FOUND); }); - // Check ownership authorizationService.validateOwnership(quiz.getUserId()); return quizMapper.toQuizSetResponse(quiz); @@ -84,9 +65,39 @@ public void deleteQuizSetById(int quizSetId) { return new AppException(ErrorCode.QUIZ_NOT_FOUND); }); - // Check ownership authorizationService.validateOwnership(quiz.getUserId()); quizSetRepository.deleteById(quizSetId); } + + @Transactional + public void deleteAllQuizSet() { + int currentUserId = authorizationService.getCurrentUserId(); + quizSetRepository.deleteAllByUserId(currentUserId); + } + + // --- Internal methods --- // + private QuizSet saveQuizSetEntity(int userId, QuizQuestion quizQuestion) { + Quiz quiz = quizMapper.toQuiz(quizQuestion); + + QuizSet quizSet = QuizSet.builder() + .userId(userId) + .title(quizQuestion.getTitle()) + .build(); + quizSet.addQuiz(quiz); + + return quizSetRepository.save(quizSet); + } + + private QuizSet saveQuizSetEntity(int userId, String quizSetTitle, List quizQuestionList) { + List newQuizzes = quizMapper.toQuizList(quizQuestionList); + + QuizSet quizSet = QuizSet.builder() + .userId(userId) + .title(quizSetTitle) + .build(); + quizSet.addQuizzes(newQuizzes); + + return quizSetRepository.save(quizSet); + } } diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index d682d88..209f9d3 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -7,7 +7,7 @@ import java.util.ArrayList; import java.util.List; -import com.be08.smart_notes.dto.ai.AIQuizResponse; +import com.be08.smart_notes.dto.QuizQuestion; import com.be08.smart_notes.dto.request.QuizGenerationRequest; import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.response.NoteResponse; @@ -17,7 +17,6 @@ import com.be08.smart_notes.mapper.QuizMapper; import com.be08.smart_notes.model.Document; import com.be08.smart_notes.model.Quiz; -import com.be08.smart_notes.model.QuizSet; import com.be08.smart_notes.service.QuizSetService; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AccessLevel; @@ -73,9 +72,9 @@ public QuizResponse generateSampleQuiz() { ObjectMapper objectMapper = new ObjectMapper(); try { // Extract raw message content from response string - AIQuizResponse aiQuizResponse = objectMapper.readValue(generatedContent, AIQuizResponse.class); + QuizQuestion quizQuestion = objectMapper.readValue(generatedContent, QuizQuestion.class); - Quiz sampleQuizEntity = quizMapper.toQuiz(aiQuizResponse); + Quiz sampleQuizEntity = quizMapper.toQuiz(quizQuestion); return quizMapper.toQuizResponse(sampleQuizEntity); } catch (Exception e) { log.error("An error occurred when mapping objects, could not create sample quiz."); @@ -83,99 +82,65 @@ public QuizResponse generateSampleQuiz() { } } - public QuizSetResponse generateQuiz(int noteId) { + public QuizSetResponse generateQuiz(QuizGenerationRequest quizGenerationRequest) { if (this.systemPrompt == null || this.quizResponseSchema == null) { log.error("Could not generate quiz because of invalid system prompt and/or guided schema."); throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } - // Get note - NoteResponse selectedNote = noteService.getNote(noteId); + // Extract required data from request + int totalQuestions = quizGenerationRequest.getTotalQuestions(); + String prompt = String.format(this.systemPrompt, totalQuestions); + List noteIds = quizGenerationRequest.getIds(); - // Generate content + // Save single quiz from single note + if (noteIds.size() == 1) { + int noteId = noteIds.get(0); + NoteResponse selectedNote = noteService.getNote(noteId); + QuizQuestion quizQuestion = generateQuizFromNote(selectedNote.getContent(), String.format(this.systemPrompt, 10)); + + quizQuestion.setSourceDocumentId(noteId); + return quizSetService.saveQuizSet(quizQuestion); + } + + // Save quizzes from list of notes (generate one by one) + List noteList = noteService.getAllNotesByIds(noteIds); + + List quizQuestionList = new ArrayList<>(); + for (Document note : noteList) { + QuizQuestion quizQuestion = generateQuizFromNote(note.getContent(), prompt); + + quizQuestion.setSourceDocumentId(note.getId()); + quizQuestionList.add(quizQuestion); + } + return quizSetService.saveQuizSet(QuizSetService.DEFAULT_QUIZ_SET_TITLE, quizQuestionList); + } + + // --- Internal methods --- // + private QuizQuestion generateQuizFromNote(String noteContent, String prompt) { String generatedContent = aiService.generateContent( - String.format(this.systemPrompt, 10), - selectedNote.getContent(), + prompt, + noteContent, quizResponseSchema ); - if (generatedContent == null || generatedContent.isEmpty()) { + if (generatedContent == null || generatedContent.isEmpty()) { log.error("Could not process quiz because generated content is empty."); throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } // Map generated content (JSON String) to Object ObjectMapper objectMapper = new ObjectMapper(); - AIQuizResponse aiQuizResponse = null; + QuizQuestion quizQuestion = null; try { - aiQuizResponse = objectMapper.readValue(generatedContent, AIQuizResponse.class); + quizQuestion = objectMapper.readValue(generatedContent, QuizQuestion.class); } catch (Exception e) { - log.error("Could not map generated content to AIQuizResponse."); + log.error("Could not map generated content to QuizQuestion."); throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } - if (aiQuizResponse == null) { + if (quizQuestion == null) { log.error("Could not generate quiz because of invalid object mapping result."); throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } - - // Save and return quiz from mapped object - return quizSetService.saveQuizSetFromAIResponse(noteId, aiQuizResponse); + return quizQuestion; } - -// public QuizSetResponse generateQuiz(QuizGenerationRequest quizGenerationRequest) { -// if (this.systemPrompt == null || this.quizResponseSchema == null) { -// log.error("Could not generate quiz because of invalid system prompt and/or guided schema."); -// throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); -// } -// -// // Extract required data from request -// int totalQuestions = quizGenerationRequest.getTotalQuestions(); -// String systemPrompt = String.format(this.systemPrompt, totalQuestions); -// -// // Get list of notes -// List noteList = noteService.getNotesByIds(quizGenerationRequest.getIds()); -// -// // Generate quiz from notes (one by one) -// List aiQuizResponseList = new ArrayList<>(); -// for (Document note : noteList) { -// String generatedContent = aiService.generateContent( -// systemPrompt, -// note.getContent(), -// quizResponseSchema -// ); -// -// // Generate content -// if (generatedContent == null || generatedContent.isEmpty()) { -// log.error("Error when generating quiz for note {}: Generated content is empty.", note.getId()); -// continue; -// } -// -// // Map generated content (JSON String) to Object -// ObjectMapper objectMapper = new ObjectMapper(); -// AIQuizResponse aiQuizResponse = null; -// try { -// aiQuizResponse = objectMapper.readValue(generatedContent, AIQuizResponse.class); -// } catch (Exception e) { -// log.error("Error when generating quiz for note {}: Could not map generated content to AIQuizResponse.", note.getId()); -// continue; -// } -// if (aiQuizResponse == null) { -// log.error("Error when generating quiz for note {}: Invalid object mapping result.", note.getId()); -// continue; -// } -// -// // Add to combined quiz resposne object -// aiQuizResponse.setSourceDocumentId(note.getId()); -// aiQuizResponseList.add(aiQuizResponse); -// } -// -// -// QuizResponse quizResponse = quizSetService.saveQuizFromAIResponse(aiQuizResponse); -// -// -// -// -// -// // Save and return quiz from mapped object -// return quizSetService.saveQuizFromAIResponse(noteId, aiQuizResponse); -// } } From a58768f65f9fd757c813079b5ac7782ce72becdc Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 5 Nov 2025 23:05:15 +1100 Subject: [PATCH 25/63] feat(quiz): handle unsorted quizzes using default set --- .../be08/smart_notes/common/AppConstants.java | 2 + .../controller/AIGenerationController.java | 23 +++-- .../controller/QuizSetController.java | 22 ++--- .../dto/ai/AIInferenceRequest.java | 3 - .../dto/request/QuizGenerationRequest.java | 14 +-- .../dto/response/QuizResponse.java | 28 +----- .../be08/smart_notes/enums/OriginType.java | 5 ++ .../be08/smart_notes/exception/ErrorCode.java | 8 +- .../java/com/be08/smart_notes/model/Quiz.java | 14 ++- .../com/be08/smart_notes/model/QuizSet.java | 4 + .../repository/QuizSetRepository.java | 3 +- .../be08/smart_notes/service/QuizService.java | 19 ++++ .../smart_notes/service/QuizSetService.java | 88 ++++++++++++++++--- .../service/ai/QuizGenerationService.java | 38 +++++--- .../validation/group/MultipleDocument.java | 4 + .../validation/group/SingleDocument.java | 4 + 16 files changed, 188 insertions(+), 91 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/enums/OriginType.java create mode 100644 src/main/java/com/be08/smart_notes/validation/group/MultipleDocument.java create mode 100644 src/main/java/com/be08/smart_notes/validation/group/SingleDocument.java diff --git a/src/main/java/com/be08/smart_notes/common/AppConstants.java b/src/main/java/com/be08/smart_notes/common/AppConstants.java index 0128c35..1ae2779 100644 --- a/src/main/java/com/be08/smart_notes/common/AppConstants.java +++ b/src/main/java/com/be08/smart_notes/common/AppConstants.java @@ -5,4 +5,6 @@ public class AppConstants { public static final String SYSTEM_PROMPT_TEMPLATE_PATH = RESOURCE_PATH + "/prompts/system_prompt.txt"; public static final String QUIZ_RESPONSE_SCHEMA_PATH = RESOURCE_PATH + "/schemas/quiz_response.json"; public static final String SAMPLE_JSON_RESPONSE_PATH = RESOURCE_PATH + "/schemas/sample_response.json"; + + public static final String DEFAULT_QUIZ_SET_TITLE = "Untitled Quiz Set"; } diff --git a/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java b/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java index 5bcb6ed..afdb4de 100644 --- a/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java +++ b/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java @@ -4,17 +4,18 @@ import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.response.ApiResponse; import com.be08.smart_notes.dto.response.QuizSetResponse; +import com.be08.smart_notes.validation.group.MultipleDocument; +import com.be08.smart_notes.validation.group.SingleDocument; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import com.be08.smart_notes.service.ai.QuizGenerationService; -import java.util.List; - @RestController @RequestMapping("/api/ai/generation") @RequiredArgsConstructor @@ -26,17 +27,27 @@ public class AIGenerationController { public ResponseEntity generateSampleQuizSet() { QuizResponse quizResponse = quizGenerationService.generateSampleQuiz(); ApiResponse apiResponse = ApiResponse.builder() - .message("Sample quiz set generated created successfully") + .message("Sample quiz set successfully generated") .data(quizResponse) .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } + @PostMapping("/quiz-sets/default") + public ResponseEntity generateQuiz(@Validated(SingleDocument.class) @RequestBody QuizGenerationRequest quizGenerationRequest) { + QuizResponse quizSetResponse = quizGenerationService.generateQuiz(quizGenerationRequest); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz successfully generated and added to default set") + .data(quizSetResponse) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + @PostMapping("/quiz-sets") - public ResponseEntity generateQuizSet(@RequestBody QuizGenerationRequest quizGenerationRequest) { - QuizSetResponse quizSetResponse = quizGenerationService.generateQuiz(quizGenerationRequest); + public ResponseEntity generateQuizSet(@Validated(MultipleDocument.class) @RequestBody QuizGenerationRequest quizGenerationRequest) { + QuizSetResponse quizSetResponse = quizGenerationService.generateQuizSet(quizGenerationRequest); ApiResponse apiResponse = ApiResponse.builder() - .message("Quiz set generated created successfully") + .message("Quiz set successfully generated") .data(quizSetResponse) .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); diff --git a/src/main/java/com/be08/smart_notes/controller/QuizSetController.java b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java index bbfac30..9070437 100644 --- a/src/main/java/com/be08/smart_notes/controller/QuizSetController.java +++ b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java @@ -20,7 +20,7 @@ public class QuizSetController { @PostMapping public ResponseEntity createQuizSet(@RequestBody QuizQuestion quizQuestion) { - QuizSetResponse quizSetResponse = quizSetService.saveQuizSet(quizQuestion); + QuizSetResponse quizSetResponse = quizSetService.saveQuizSet(null, quizQuestion); ApiResponse apiResponse = ApiResponse.builder() .message("Quiz set created successfully") .data(quizSetResponse) @@ -28,6 +28,16 @@ public ResponseEntity createQuizSet(@RequestBody QuizQuestion quizQuesti return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } + @GetMapping("/{id}") + public ResponseEntity getQuizSet(@PathVariable int id) { + QuizSetResponse quizSetResponse = quizSetService.getQuizSetById(id); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz fetched successfully") + .data(quizSetResponse) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + @DeleteMapping public ResponseEntity deleteAllQuizSet() { quizSetService.deleteAllQuizSet(); @@ -45,14 +55,4 @@ public ResponseEntity deleteQuizSet(@PathVariable int id) { .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } - - @GetMapping("/{id}") - public ResponseEntity getQuizSet(@PathVariable int id) { - QuizSetResponse quizSetResponse = quizSetService.getQuizSetById(id); - ApiResponse apiResponse = ApiResponse.builder() - .message("Quiz fetched successfully") - .data(quizSetResponse) - .build(); - return ResponseEntity.status(HttpStatus.OK).body(apiResponse); - } } diff --git a/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java b/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java index 96ace1f..7234855 100644 --- a/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java +++ b/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java @@ -10,11 +10,8 @@ @AllArgsConstructor @Builder public class AIInferenceRequest { - // Required private String model; private RequestMessage[] messages; - - // Optional private double temperature; @JsonProperty("top_p") diff --git a/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java b/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java index 11bcb44..810285c 100644 --- a/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java +++ b/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java @@ -1,5 +1,7 @@ package com.be08.smart_notes.dto.request; +import com.be08.smart_notes.validation.group.MultipleDocument; +import com.be08.smart_notes.validation.group.SingleDocument; import jakarta.validation.constraints.*; import lombok.AllArgsConstructor; import lombok.Builder; @@ -13,13 +15,15 @@ @AllArgsConstructor @Builder public class QuizGenerationRequest { - @NotEmpty + @NotNull(groups = SingleDocument.class, message = "DOCUMENT_ID_REQUIRED") + private Integer docId; + + @NotNull(groups = MultipleDocument.class, message = "DOCUMENT_IDS_REQUIRED") @Size(min = 1, max = 5, message = "QUIZ_DOCUMENT_SIZE_EXCEED") - private List ids; + private List docIds; - @NotNull @Builder.Default @Min(value = 1, message = "INVALID_QUIZ_SIZE") - @Max(value = 50, message = "INVALID_QUIZ_SIZE") - private Integer totalQuestions = 10; + @Max(value = 20, message = "INVALID_QUIZ_SIZE") + private Integer sizeOfEachQuiz = 10; } diff --git a/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java index bc87ef2..de00149 100644 --- a/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java @@ -12,22 +12,11 @@ @NoArgsConstructor @AllArgsConstructor public class QuizResponse { - @NotNull private Integer id; - - @NotNull + private String title; + private Integer sourceDocumentId; private LocalDateTime createdAt; - - @NotNull private LocalDateTime updatedAt; - - @NotNull - private String title; - - @NotNull - private Integer sourceDocumentId; - - @NotNull private List questions; // Static nested class @@ -35,25 +24,12 @@ public class QuizResponse { @NoArgsConstructor @AllArgsConstructor public static class Question { - @NotNull private Integer id; - - @NotNull private String questionText; - - @NotNull private String optionA; - - @NotNull private String optionB; - - @NotNull private String optionC; - - @NotNull private String optionD; - - @NotNull private String correctAnswer; } } diff --git a/src/main/java/com/be08/smart_notes/enums/OriginType.java b/src/main/java/com/be08/smart_notes/enums/OriginType.java new file mode 100644 index 0000000..c734f5f --- /dev/null +++ b/src/main/java/com/be08/smart_notes/enums/OriginType.java @@ -0,0 +1,5 @@ +package com.be08.smart_notes.enums; + +public enum OriginType { + AI, USER, DEFAULT; +} diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java index f9ba543..dfb72cd 100644 --- a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -36,15 +36,17 @@ public enum ErrorCode { // 22xx - Document-related features DOCUMENT_NOT_FOUND(2201, "Document not found", HttpStatus.NOT_FOUND), - NOTE_CONTENT_EMPTY(2202, "Note content cannot be empty", HttpStatus.BAD_REQUEST), + DOCUMENT_ID_REQUIRED(2202, "Single document ID required", HttpStatus.BAD_REQUEST), + DOCUMENT_IDS_REQUIRED(2202, "List of document IDs required", HttpStatus.BAD_REQUEST), + NOTE_CONTENT_EMPTY(2203, "Note content cannot be empty", HttpStatus.BAD_REQUEST), // 23xx - AI-related features - FAILED_INFERENCE_REQUEST(2301, "AI inference request failed", HttpStatus.INTERNAL_SERVER_ERROR), + FAILED_INFERENCE_REQUEST(2301, "AI inference request failed", HttpStatus.BAD_GATEWAY), // 24xx - Quiz-related features QUIZ_NOT_FOUND(2401, "Quiz not found", HttpStatus.NOT_FOUND), QUIZ_DOCUMENT_SIZE_EXCEED(2402, "Number of quiz IDs must be between 1 and 5", HttpStatus.BAD_REQUEST), - INVALID_QUIZ_SIZE(2403, "Total number of questions must between 1 and 50", HttpStatus.BAD_REQUEST), + INVALID_QUIZ_SIZE(2403, "Total number of questions must between 1 and 20", HttpStatus.BAD_REQUEST), ; int code; diff --git a/src/main/java/com/be08/smart_notes/model/Quiz.java b/src/main/java/com/be08/smart_notes/model/Quiz.java index 07340e5..66ee2be 100644 --- a/src/main/java/com/be08/smart_notes/model/Quiz.java +++ b/src/main/java/com/be08/smart_notes/model/Quiz.java @@ -8,6 +8,7 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Data @@ -21,9 +22,6 @@ public class Quiz { @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; -// @Column(nullable = false, name = "user_id") -// private Integer userId; - @Column(nullable = false, name = "created_at") private LocalDateTime createdAt; @@ -33,9 +31,8 @@ public class Quiz { @Column(nullable = false) private String title; - // Relationship: quiz - question - @OneToMany(mappedBy = "quiz", cascade = CascadeType.ALL, orphanRemoval = true) - private List questions; + @Column(name = "source_document_id") + private Integer sourceDocumentId; // Relationship: quiz - quiz set @JsonIgnore @@ -43,8 +40,9 @@ public class Quiz { @JoinColumn(name = "quiz_set_id", referencedColumnName = "id") private QuizSet quizSet; - @Column(name = "source_document_id") - private Integer sourceDocumentId; + // Relationship: quiz - question + @OneToMany(mappedBy = "quiz", cascade = CascadeType.ALL, orphanRemoval = true) + private List questions; @PrePersist protected void onCreate() { diff --git a/src/main/java/com/be08/smart_notes/model/QuizSet.java b/src/main/java/com/be08/smart_notes/model/QuizSet.java index ca7ec66..82def24 100644 --- a/src/main/java/com/be08/smart_notes/model/QuizSet.java +++ b/src/main/java/com/be08/smart_notes/model/QuizSet.java @@ -1,5 +1,6 @@ package com.be08.smart_notes.model; +import com.be08.smart_notes.enums.OriginType; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; @@ -26,6 +27,9 @@ public class QuizSet { @Column(nullable = false, name = "title") private String title; + @Column(nullable = false, name = "origin_type") + private OriginType originType; + @Column(nullable = false, name = "created_at") private LocalDateTime createdAt; diff --git a/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java b/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java index 0952c3b..6c1f15c 100644 --- a/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java @@ -1,12 +1,13 @@ package com.be08.smart_notes.repository; +import com.be08.smart_notes.enums.OriginType; import com.be08.smart_notes.model.QuizSet; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface QuizSetRepository extends JpaRepository { - Optional findByTitleAndUserId(String title, int userId); + Optional findByUserIDAndOriginType(int userID, OriginType originType); void deleteAllByUserId(int userId); } diff --git a/src/main/java/com/be08/smart_notes/service/QuizService.java b/src/main/java/com/be08/smart_notes/service/QuizService.java index 72f877e..5c90fe1 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizService.java @@ -1,10 +1,12 @@ package com.be08.smart_notes.service; +import com.be08.smart_notes.dto.QuizQuestion; import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.exception.AppException; import com.be08.smart_notes.exception.ErrorCode; import com.be08.smart_notes.mapper.QuizMapper; import com.be08.smart_notes.model.Quiz; +import com.be08.smart_notes.model.QuizSet; import com.be08.smart_notes.repository.QuizRepository; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; @@ -18,9 +20,17 @@ @Slf4j public class QuizService { AuthorizationService authorizationService; + QuizSetService quizSetService; QuizRepository quizRepository; QuizMapper quizMapper; + public QuizResponse saveQuiz(QuizQuestion quizQuestion) { + int currentUserId = authorizationService.getCurrentUserId(); + + Quiz savedQuiz = saveAsNewQuizEntity(currentUserId, quizQuestion); + return quizMapper.toQuizResponse(savedQuiz); + } + public QuizResponse getQuizById(int quizId) { Quiz quiz = quizRepository.findById(quizId).orElseThrow(() -> { log.error("Quiz with id {} not found", quizId); @@ -44,4 +54,13 @@ public void deleteQuizById(int quizId) { quizRepository.deleteById(quizId); } + + // ------ Methods that returns entities ------ // + public Quiz saveAsNewQuizEntity(int userId, QuizQuestion quizQuestion) { + Quiz newQuiz = quizMapper.toQuiz(quizQuestion); + + QuizSet defaultSet = quizSetService.saveNewQuizToDefaultSet(userId, newQuiz); + + return newQuiz; + } } diff --git a/src/main/java/com/be08/smart_notes/service/QuizSetService.java b/src/main/java/com/be08/smart_notes/service/QuizSetService.java index b28bb7f..339b391 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizSetService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizSetService.java @@ -1,7 +1,9 @@ package com.be08.smart_notes.service; +import com.be08.smart_notes.common.AppConstants; import com.be08.smart_notes.dto.QuizQuestion; import com.be08.smart_notes.dto.response.QuizSetResponse; +import com.be08.smart_notes.enums.OriginType; import com.be08.smart_notes.exception.AppException; import com.be08.smart_notes.exception.ErrorCode; import com.be08.smart_notes.mapper.QuizMapper; @@ -15,7 +17,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; import java.util.List; @Service @@ -23,31 +24,41 @@ @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) @Slf4j public class QuizSetService { - public static String DEFAULT_QUIZ_SET_TITLE = "Untitled Quiz Set"; - AuthorizationService authorizationService; QuizSetRepository quizSetRepository; QuizMapper quizMapper; /** - * Save QuizSet including single Quiz with associated questions from AI Quiz Response - * @param quizQuestion QuizQuestion - * @return QuizSetResponse + * Save new QuizSet from a single quiz with associated questions + * @param quizSetTitle the title for new quiz set, use quiz's title if null + * @param quizQuestion a quiz with its questions + * @return response dto for saved quiz set */ - public QuizSetResponse saveQuizSet(QuizQuestion quizQuestion) { + public QuizSetResponse saveQuizSet(String quizSetTitle, QuizQuestion quizQuestion) { int currentUserId = authorizationService.getCurrentUserId(); - QuizSet savedQuizSet = saveQuizSetEntity(currentUserId, quizQuestion); + QuizSet savedQuizSet = saveAsNewQuizSetEntity(currentUserId, quizSetTitle, quizQuestion); return quizMapper.toQuizSetResponse(savedQuizSet); } + /** + * Save new QuizSet from multiple quizzes with their associated questions + * @param quizSetTitle a title for new quiz set, use default name if null + * @param quizQuestionList list of quizzes to be added together with new set + * @return response dto for saved quiz set + */ public QuizSetResponse saveQuizSet(String quizSetTitle, List quizQuestionList) { int currentUserId = authorizationService.getCurrentUserId(); - QuizSet savedQuizSet = saveQuizSetEntity(currentUserId, quizSetTitle, quizQuestionList); + QuizSet savedQuizSet = saveAsNewQuizSetEntity(currentUserId, quizSetTitle, quizQuestionList); return quizMapper.toQuizSetResponse(savedQuizSet); } + /** + * Get a QuizSet with associated quizzes using given id + * @param quizSetId id of target quiz set + * @return response dto for quiz set + */ public QuizSetResponse getQuizSetById(int quizSetId) { QuizSet quiz = quizSetRepository.findById(quizSetId).orElseThrow(() -> { log.error("Quiz set with id {} not found", quizSetId); @@ -59,6 +70,10 @@ public QuizSetResponse getQuizSetById(int quizSetId) { return quizMapper.toQuizSetResponse(quiz); } + /** + * Delete a QuizSet using given id + * @param quizSetId id of target quiz set + */ public void deleteQuizSetById(int quizSetId) { QuizSet quiz = quizSetRepository.findById(quizSetId).orElseThrow(() -> { log.error("Quiz set with id {} not found", quizSetId); @@ -70,31 +85,76 @@ public void deleteQuizSetById(int quizSetId) { quizSetRepository.deleteById(quizSetId); } + /** + * Delete all QuizSet own by current user, together with its quizzes and questions + */ @Transactional public void deleteAllQuizSet() { int currentUserId = authorizationService.getCurrentUserId(); quizSetRepository.deleteAllByUserId(currentUserId); } - // --- Internal methods --- // - private QuizSet saveQuizSetEntity(int userId, QuizQuestion quizQuestion) { + // ------ Methods that returns entities ------ // + /** + * Get or create a default set if not exist for current user. Default set should only unique for each user + * @param userId id of current user + * @return default quiz set + */ + public QuizSet getOrCreateDefaultSet(int userId) { + return quizSetRepository.findByUserIDAndOriginType(userId, OriginType.DEFAULT).orElseGet(() -> { + QuizSet defaultSet = QuizSet.builder() + .userId(userId) + .title(AppConstants.DEFAULT_QUIZ_SET_TITLE) + .originType(OriginType.DEFAULT).build(); + return quizSetRepository.save(defaultSet); + }); + } + + /** + * Save new quiz to default quiz set (uncategorized) + * @param userId id of current user + * @param quiz new quiz to be added + * @return default quiz set with new quiz added + */ + public QuizSet saveNewQuizToDefaultSet(int userId, Quiz quiz) { + QuizSet defaultSet = getOrCreateDefaultSet(userId); + + defaultSet.addQuiz(quiz); + return quizSetRepository.save(defaultSet); + } + + /** + * Create and save new QuizSet from a quiz with associated questions + * @param userId id of current user + * @param quizSetTitle title for new quiz set, use quiz title as alternative when null + * @param quizQuestion the quiz with questions to be added in new quiz set + * @return the saved QuizSet + */ + public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, QuizQuestion quizQuestion) { Quiz quiz = quizMapper.toQuiz(quizQuestion); QuizSet quizSet = QuizSet.builder() .userId(userId) - .title(quizQuestion.getTitle()) + .title(quizSetTitle != null ? quizSetTitle : quizQuestion.getTitle()) .build(); quizSet.addQuiz(quiz); return quizSetRepository.save(quizSet); } - private QuizSet saveQuizSetEntity(int userId, String quizSetTitle, List quizQuestionList) { + /** + * Create and save new QuizSet from a list of quizzes with their associated questions + * @param userId id of current user + * @param quizSetTitle title for new quiz set, use default title as alternative when null + * @param quizQuestionList list of quizzes with questions to be added in new quiz set + * @return the saved QuizSet + */ + public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, List quizQuestionList) { List newQuizzes = quizMapper.toQuizList(quizQuestionList); QuizSet quizSet = QuizSet.builder() .userId(userId) - .title(quizSetTitle) + .title(quizSetTitle != null ? quizSetTitle : AppConstants.DEFAULT_QUIZ_SET_TITLE) .build(); quizSet.addQuizzes(newQuizzes); diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index 209f9d3..52b472c 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -17,6 +17,7 @@ import com.be08.smart_notes.mapper.QuizMapper; import com.be08.smart_notes.model.Document; import com.be08.smart_notes.model.Quiz; +import com.be08.smart_notes.service.QuizService; import com.be08.smart_notes.service.QuizSetService; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AccessLevel; @@ -36,12 +37,14 @@ public class QuizGenerationService { AIService aiService; NoteService noteService; + QuizService quizService; QuizSetService quizSetService; QuizMapper quizMapper; - public QuizGenerationService(AIService aiService, NoteService noteService, QuizSetService quizSetService, QuizMapper quizMapper) { + public QuizGenerationService(AIService aiService, NoteService noteService, QuizService quizService, QuizSetService quizSetService, QuizMapper quizMapper) { this.aiService = aiService; this.noteService = noteService; + this.quizService = quizService; this.quizSetService = quizSetService; this.quizMapper = quizMapper; @@ -82,30 +85,37 @@ public QuizResponse generateSampleQuiz() { } } - public QuizSetResponse generateQuiz(QuizGenerationRequest quizGenerationRequest) { + public QuizResponse generateQuiz(QuizGenerationRequest quizGenerationRequest) { if (this.systemPrompt == null || this.quizResponseSchema == null) { log.error("Could not generate quiz because of invalid system prompt and/or guided schema."); throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } // Extract required data from request - int totalQuestions = quizGenerationRequest.getTotalQuestions(); + int totalQuestions = quizGenerationRequest.getSizeOfEachQuiz(); String prompt = String.format(this.systemPrompt, totalQuestions); - List noteIds = quizGenerationRequest.getIds(); + int noteId = quizGenerationRequest.getDocId(); - // Save single quiz from single note - if (noteIds.size() == 1) { - int noteId = noteIds.get(0); - NoteResponse selectedNote = noteService.getNote(noteId); - QuizQuestion quizQuestion = generateQuizFromNote(selectedNote.getContent(), String.format(this.systemPrompt, 10)); + NoteResponse selectedNote = noteService.getNote(noteId); + QuizQuestion quizQuestion = generateQuizFromNote(selectedNote.getContent(), prompt); - quizQuestion.setSourceDocumentId(noteId); - return quizSetService.saveQuizSet(quizQuestion); + quizQuestion.setSourceDocumentId(noteId); + return quizService.saveQuiz(quizQuestion); + } + + public QuizSetResponse generateQuizSet(QuizGenerationRequest quizGenerationRequest) { + if (this.systemPrompt == null || this.quizResponseSchema == null) { + log.error("Could not generate quiz because of invalid system prompt and/or guided schema."); + throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } + // Extract required data from request + int totalQuestions = quizGenerationRequest.getSizeOfEachQuiz(); + String prompt = String.format(this.systemPrompt, totalQuestions); + List noteIds = quizGenerationRequest.getDocIds(); + // Save quizzes from list of notes (generate one by one) List noteList = noteService.getAllNotesByIds(noteIds); - List quizQuestionList = new ArrayList<>(); for (Document note : noteList) { QuizQuestion quizQuestion = generateQuizFromNote(note.getContent(), prompt); @@ -113,10 +123,10 @@ public QuizSetResponse generateQuiz(QuizGenerationRequest quizGenerationRequest) quizQuestion.setSourceDocumentId(note.getId()); quizQuestionList.add(quizQuestion); } - return quizSetService.saveQuizSet(QuizSetService.DEFAULT_QUIZ_SET_TITLE, quizQuestionList); + return quizSetService.saveQuizSet(AppConstants.DEFAULT_QUIZ_SET_TITLE, quizQuestionList); } - // --- Internal methods --- // + // ------ Internal methods ------ // private QuizQuestion generateQuizFromNote(String noteContent, String prompt) { String generatedContent = aiService.generateContent( prompt, diff --git a/src/main/java/com/be08/smart_notes/validation/group/MultipleDocument.java b/src/main/java/com/be08/smart_notes/validation/group/MultipleDocument.java new file mode 100644 index 0000000..ffd8d29 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/validation/group/MultipleDocument.java @@ -0,0 +1,4 @@ +package com.be08.smart_notes.validation.group; + +public interface MultipleDocument { +} diff --git a/src/main/java/com/be08/smart_notes/validation/group/SingleDocument.java b/src/main/java/com/be08/smart_notes/validation/group/SingleDocument.java new file mode 100644 index 0000000..cf917c8 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/validation/group/SingleDocument.java @@ -0,0 +1,4 @@ +package com.be08.smart_notes.validation.group; + +public interface SingleDocument { +} From f5eb9909f2e62fa78c8cb47a867e9fcdca990a6b Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 6 Nov 2025 13:35:47 +1100 Subject: [PATCH 26/63] fix(quiz): resolve syntax error, minor business logic and dto issues --- .../controller/QuizSetController.java | 17 +++++++++-- .../be08/smart_notes/mapper/QuizMapper.java | 3 +- .../com/be08/smart_notes/model/QuizSet.java | 1 + .../repository/QuizSetRepository.java | 4 ++- .../be08/smart_notes/service/NoteService.java | 23 ++++++++------- .../be08/smart_notes/service/QuizService.java | 7 +++-- .../smart_notes/service/QuizSetService.java | 29 +++++++++++++------ .../service/ai/QuizGenerationService.java | 24 +++++++++++---- 8 files changed, 76 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/be08/smart_notes/controller/QuizSetController.java b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java index 9070437..89ba4ba 100644 --- a/src/main/java/com/be08/smart_notes/controller/QuizSetController.java +++ b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java @@ -3,6 +3,7 @@ import com.be08.smart_notes.dto.QuizQuestion; import com.be08.smart_notes.dto.response.ApiResponse; import com.be08.smart_notes.dto.response.QuizSetResponse; +import com.be08.smart_notes.enums.OriginType; import com.be08.smart_notes.service.QuizSetService; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; @@ -11,6 +12,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController @RequestMapping("/api/quiz-sets") @RequiredArgsConstructor @@ -20,7 +23,7 @@ public class QuizSetController { @PostMapping public ResponseEntity createQuizSet(@RequestBody QuizQuestion quizQuestion) { - QuizSetResponse quizSetResponse = quizSetService.saveQuizSet(null, quizQuestion); + QuizSetResponse quizSetResponse = quizSetService.saveQuizSet(null, quizQuestion, OriginType.USER); ApiResponse apiResponse = ApiResponse.builder() .message("Quiz set created successfully") .data(quizSetResponse) @@ -28,11 +31,21 @@ public ResponseEntity createQuizSet(@RequestBody QuizQuestion quizQuesti return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } + @GetMapping + public ResponseEntity getAllQuizSets() { + List quizSetResponseList = quizSetService.getAllQuizSets(); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz set fetched successfully") + .data(quizSetResponseList) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + @GetMapping("/{id}") public ResponseEntity getQuizSet(@PathVariable int id) { QuizSetResponse quizSetResponse = quizSetService.getQuizSetById(id); ApiResponse apiResponse = ApiResponse.builder() - .message("Quiz fetched successfully") + .message("Quiz set fetched successfully") .data(quizSetResponse) .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); diff --git a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java index e2464e8..15fdc46 100644 --- a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java @@ -17,14 +17,15 @@ public interface QuizMapper { // QuizSet entity <--> QuizSetResponse dto QuizSetResponse toQuizSetResponse(QuizSet quizSet); + List toQuizSetResponseList(List quizSet); // Quiz Entity <--> QuizResponse dto QuizResponse toQuizResponse(Quiz entity); Quiz toQuiz(QuizResponse dto); // Quiz Entity <--> QuizQuestion - List toQuizList(List dtoList); Quiz toQuiz(QuizQuestion dto); + List toQuizList(List dtoList); // Question Entity <--> QuizQuestion.Question dto @Mapping(target = "id", ignore = true) diff --git a/src/main/java/com/be08/smart_notes/model/QuizSet.java b/src/main/java/com/be08/smart_notes/model/QuizSet.java index 82def24..0755d14 100644 --- a/src/main/java/com/be08/smart_notes/model/QuizSet.java +++ b/src/main/java/com/be08/smart_notes/model/QuizSet.java @@ -28,6 +28,7 @@ public class QuizSet { private String title; @Column(nullable = false, name = "origin_type") + @Enumerated(EnumType.STRING) private OriginType originType; @Column(nullable = false, name = "created_at") diff --git a/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java b/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java index 6c1f15c..5acbd66 100644 --- a/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java @@ -4,10 +4,12 @@ import com.be08.smart_notes.model.QuizSet; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface QuizSetRepository extends JpaRepository { - Optional findByUserIDAndOriginType(int userID, OriginType originType); + Optional findByUserIdAndOriginType(int userID, OriginType originType); + List findAllByUserId(Integer userId); void deleteAllByUserId(int userId); } diff --git a/src/main/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index 0a89d9e..7a5840b 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -41,17 +41,6 @@ public NoteResponse getNote(int noteId) { return documentMapper.toNoteResponse(note); } - public List getAllNotesByUserIdAndIds(List noteIds) { - // Get current user id - int currentUserId = authorizationService.getCurrentUserId(); - - return documentRepository.findAllByUserIdAndIdIn(currentUserId, noteIds); - } - - public List getAllNotesByIds(List noteIds) { - return documentRepository.findAllByIdIn(noteIds); - } - public NoteResponse createNote(NoteUpsertRequest newData) { // Get current user id int currentUserId = authorizationService.getCurrentUserId(); @@ -99,4 +88,16 @@ public void deleteNote(int noteId) { documentRepository.deleteById(noteId); } + + // ------ Methods that returns entities ------ // + public List getAllNotesByUserIdAndIds(int userId, List noteIds) { + // Get current user id + int currentUserId = authorizationService.getCurrentUserId(); + + return documentRepository.findAllByUserIdAndIdIn(currentUserId, noteIds); + } + + public List getAllNotesByIds(List noteIds) { + return documentRepository.findAllByIdIn(noteIds); + } } diff --git a/src/main/java/com/be08/smart_notes/service/QuizService.java b/src/main/java/com/be08/smart_notes/service/QuizService.java index 5c90fe1..ffc4641 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizService.java @@ -57,10 +57,11 @@ public void deleteQuizById(int quizId) { // ------ Methods that returns entities ------ // public Quiz saveAsNewQuizEntity(int userId, QuizQuestion quizQuestion) { - Quiz newQuiz = quizMapper.toQuiz(quizQuestion); + QuizSet defaultSet = quizSetService.getOrCreateDefaultSet(userId); - QuizSet defaultSet = quizSetService.saveNewQuizToDefaultSet(userId, newQuiz); + Quiz newQuiz = quizMapper.toQuiz(quizQuestion); + newQuiz.setQuizSet(defaultSet); - return newQuiz; + return quizRepository.save(newQuiz); } } diff --git a/src/main/java/com/be08/smart_notes/service/QuizSetService.java b/src/main/java/com/be08/smart_notes/service/QuizSetService.java index 339b391..c3adf0f 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizSetService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizSetService.java @@ -34,10 +34,10 @@ public class QuizSetService { * @param quizQuestion a quiz with its questions * @return response dto for saved quiz set */ - public QuizSetResponse saveQuizSet(String quizSetTitle, QuizQuestion quizQuestion) { + public QuizSetResponse saveQuizSet(String quizSetTitle, QuizQuestion quizQuestion, OriginType originType) { int currentUserId = authorizationService.getCurrentUserId(); - QuizSet savedQuizSet = saveAsNewQuizSetEntity(currentUserId, quizSetTitle, quizQuestion); + QuizSet savedQuizSet = saveAsNewQuizSetEntity(currentUserId, quizSetTitle, quizQuestion, originType); return quizMapper.toQuizSetResponse(savedQuizSet); } @@ -47,10 +47,10 @@ public QuizSetResponse saveQuizSet(String quizSetTitle, QuizQuestion quizQuestio * @param quizQuestionList list of quizzes to be added together with new set * @return response dto for saved quiz set */ - public QuizSetResponse saveQuizSet(String quizSetTitle, List quizQuestionList) { + public QuizSetResponse saveQuizSet(String quizSetTitle, List quizQuestionList, OriginType originType) { int currentUserId = authorizationService.getCurrentUserId(); - QuizSet savedQuizSet = saveAsNewQuizSetEntity(currentUserId, quizSetTitle, quizQuestionList); + QuizSet savedQuizSet = saveAsNewQuizSetEntity(currentUserId, quizSetTitle, quizQuestionList, originType); return quizMapper.toQuizSetResponse(savedQuizSet); } @@ -70,6 +70,17 @@ public QuizSetResponse getQuizSetById(int quizSetId) { return quizMapper.toQuizSetResponse(quiz); } + /** + * Get a QuizSet with associated quizzes using given id + * @return response dto for all quiz set + */ + public List getAllQuizSets() { + int currentUserId = authorizationService.getCurrentUserId(); + + List quizSets = quizSetRepository.findAllByUserId(currentUserId); + return quizMapper.toQuizSetResponseList(quizSets); + } + /** * Delete a QuizSet using given id * @param quizSetId id of target quiz set @@ -101,7 +112,7 @@ public void deleteAllQuizSet() { * @return default quiz set */ public QuizSet getOrCreateDefaultSet(int userId) { - return quizSetRepository.findByUserIDAndOriginType(userId, OriginType.DEFAULT).orElseGet(() -> { + return quizSetRepository.findByUserIdAndOriginType(userId, OriginType.DEFAULT).orElseGet(() -> { QuizSet defaultSet = QuizSet.builder() .userId(userId) .title(AppConstants.DEFAULT_QUIZ_SET_TITLE) @@ -130,13 +141,13 @@ public QuizSet saveNewQuizToDefaultSet(int userId, Quiz quiz) { * @param quizQuestion the quiz with questions to be added in new quiz set * @return the saved QuizSet */ - public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, QuizQuestion quizQuestion) { + public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, QuizQuestion quizQuestion, OriginType originType) { Quiz quiz = quizMapper.toQuiz(quizQuestion); QuizSet quizSet = QuizSet.builder() .userId(userId) .title(quizSetTitle != null ? quizSetTitle : quizQuestion.getTitle()) - .build(); + .originType(originType).build(); quizSet.addQuiz(quiz); return quizSetRepository.save(quizSet); @@ -149,13 +160,13 @@ public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, QuizQuest * @param quizQuestionList list of quizzes with questions to be added in new quiz set * @return the saved QuizSet */ - public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, List quizQuestionList) { + public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, List quizQuestionList, OriginType originType) { List newQuizzes = quizMapper.toQuizList(quizQuestionList); QuizSet quizSet = QuizSet.builder() .userId(userId) .title(quizSetTitle != null ? quizSetTitle : AppConstants.DEFAULT_QUIZ_SET_TITLE) - .build(); + .originType(originType).build(); quizSet.addQuizzes(newQuizzes); return quizSetRepository.save(quizSet); diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index 52b472c..23abf86 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -12,11 +12,13 @@ import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.response.NoteResponse; import com.be08.smart_notes.dto.response.QuizSetResponse; +import com.be08.smart_notes.enums.OriginType; import com.be08.smart_notes.exception.AppException; import com.be08.smart_notes.exception.ErrorCode; import com.be08.smart_notes.mapper.QuizMapper; import com.be08.smart_notes.model.Document; import com.be08.smart_notes.model.Quiz; +import com.be08.smart_notes.service.AuthorizationService; import com.be08.smart_notes.service.QuizService; import com.be08.smart_notes.service.QuizSetService; import com.fasterxml.jackson.databind.ObjectMapper; @@ -39,14 +41,16 @@ public class QuizGenerationService { NoteService noteService; QuizService quizService; QuizSetService quizSetService; + AuthorizationService authorizationService; QuizMapper quizMapper; - public QuizGenerationService(AIService aiService, NoteService noteService, QuizService quizService, QuizSetService quizSetService, QuizMapper quizMapper) { + public QuizGenerationService(AIService aiService, NoteService noteService, QuizService quizService, QuizSetService quizSetService, QuizMapper quizMapper, AuthorizationService authorizationService) { this.aiService = aiService; this.noteService = noteService; this.quizService = quizService; this.quizSetService = quizSetService; this.quizMapper = quizMapper; + this.authorizationService = authorizationService; String prompt = null; String schema = null; @@ -118,12 +122,22 @@ public QuizSetResponse generateQuizSet(QuizGenerationRequest quizGenerationReque List noteList = noteService.getAllNotesByIds(noteIds); List quizQuestionList = new ArrayList<>(); for (Document note : noteList) { - QuizQuestion quizQuestion = generateQuizFromNote(note.getContent(), prompt); + try { + authorizationService.validateOwnership(note.getUserId()); + + QuizQuestion quizQuestion = generateQuizFromNote(note.getContent(), prompt); + quizQuestion.setSourceDocumentId(note.getId()); + quizQuestionList.add(quizQuestion); + } catch (Exception e) { + System.out.println("Error in generating quiz: " + e.getMessage()); + } + } - quizQuestion.setSourceDocumentId(note.getId()); - quizQuestionList.add(quizQuestion); + if (quizQuestionList.isEmpty()) { + log.error("Quiz set was not created because no quizzes are generated."); + throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } - return quizSetService.saveQuizSet(AppConstants.DEFAULT_QUIZ_SET_TITLE, quizQuestionList); + return quizSetService.saveQuizSet(AppConstants.DEFAULT_QUIZ_SET_TITLE, quizQuestionList, OriginType.AI); } // ------ Internal methods ------ // From 2020b7a350fd2a9c1b2e0758e8977e89b53153ab Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 6 Nov 2025 18:06:44 +1100 Subject: [PATCH 27/63] feat(quiz): change authorization handling method, add json view levels for controller response --- .../controller/QuizSetController.java | 23 +++- .../dto/request/QuizSetUpsertRequest.java | 14 ++ .../dto/response/QuizResponse.java | 14 +- .../dto/response/QuizSetResponse.java | 11 ++ .../com/be08/smart_notes/dto/view/Level.java | 6 + .../be08/smart_notes/exception/ErrorCode.java | 6 +- .../be08/smart_notes/mapper/QuizMapper.java | 15 ++- .../repository/QuizSetRepository.java | 3 +- .../smart_notes/service/QuizSetService.java | 124 +++++++++++------- .../service/ai/QuizGenerationService.java | 2 +- 10 files changed, 161 insertions(+), 57 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/dto/request/QuizSetUpsertRequest.java create mode 100644 src/main/java/com/be08/smart_notes/dto/view/Level.java diff --git a/src/main/java/com/be08/smart_notes/controller/QuizSetController.java b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java index 89ba4ba..115bb21 100644 --- a/src/main/java/com/be08/smart_notes/controller/QuizSetController.java +++ b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java @@ -1,10 +1,13 @@ package com.be08.smart_notes.controller; -import com.be08.smart_notes.dto.QuizQuestion; +import com.be08.smart_notes.dto.request.QuizSetUpsertRequest; import com.be08.smart_notes.dto.response.ApiResponse; import com.be08.smart_notes.dto.response.QuizSetResponse; +import com.be08.smart_notes.dto.view.Level; import com.be08.smart_notes.enums.OriginType; import com.be08.smart_notes.service.QuizSetService; +import com.fasterxml.jackson.annotation.JsonView; +import jakarta.validation.Valid; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; @@ -22,8 +25,9 @@ public class QuizSetController { QuizSetService quizSetService; @PostMapping - public ResponseEntity createQuizSet(@RequestBody QuizQuestion quizQuestion) { - QuizSetResponse quizSetResponse = quizSetService.saveQuizSet(null, quizQuestion, OriginType.USER); + @JsonView(Level.Detail.class) + public ResponseEntity createQuizSet(@RequestBody @Valid QuizSetUpsertRequest request) { + QuizSetResponse quizSetResponse = quizSetService.createQuizSet(request, OriginType.USER); ApiResponse apiResponse = ApiResponse.builder() .message("Quiz set created successfully") .data(quizSetResponse) @@ -32,6 +36,7 @@ public ResponseEntity createQuizSet(@RequestBody QuizQuestion quizQuesti } @GetMapping + @JsonView(Level.Basic.class) public ResponseEntity getAllQuizSets() { List quizSetResponseList = quizSetService.getAllQuizSets(); ApiResponse apiResponse = ApiResponse.builder() @@ -42,6 +47,7 @@ public ResponseEntity getAllQuizSets() { } @GetMapping("/{id}") + @JsonView(Level.Detail.class) public ResponseEntity getQuizSet(@PathVariable int id) { QuizSetResponse quizSetResponse = quizSetService.getQuizSetById(id); ApiResponse apiResponse = ApiResponse.builder() @@ -51,6 +57,17 @@ public ResponseEntity getQuizSet(@PathVariable int id) { return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } + @PutMapping("/{id}") + @JsonView(Level.Detail.class) + public ResponseEntity updateQuizSet(@PathVariable int id, @RequestBody QuizSetUpsertRequest request) { + QuizSetResponse quizSetResponse = quizSetService.updateQuizSet(id, request); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz set created successfully") + .data(quizSetResponse) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + @DeleteMapping public ResponseEntity deleteAllQuizSet() { quizSetService.deleteAllQuizSet(); diff --git a/src/main/java/com/be08/smart_notes/dto/request/QuizSetUpsertRequest.java b/src/main/java/com/be08/smart_notes/dto/request/QuizSetUpsertRequest.java new file mode 100644 index 0000000..7a556dd --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/request/QuizSetUpsertRequest.java @@ -0,0 +1,14 @@ +package com.be08.smart_notes.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class QuizSetUpsertRequest { + @NotBlank(message = "QUIZ_SET_TITLE_REQUIRED") + private String title; +} diff --git a/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java index de00149..728e819 100644 --- a/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java @@ -3,7 +3,8 @@ import java.time.LocalDateTime; import java.util.List; -import jakarta.validation.constraints.NotNull; +import com.be08.smart_notes.dto.view.Level; +import com.fasterxml.jackson.annotation.JsonView; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -12,11 +13,22 @@ @NoArgsConstructor @AllArgsConstructor public class QuizResponse { + @JsonView(Level.Basic.class) private Integer id; + + @JsonView(Level.Basic.class) private String title; + + @JsonView(Level.Basic.class) private Integer sourceDocumentId; + + @JsonView(Level.Basic.class) private LocalDateTime createdAt; + + @JsonView(Level.Basic.class) private LocalDateTime updatedAt; + + @JsonView(Level.Detail.class) private List questions; // Static nested class diff --git a/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java b/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java index 361b5e9..f408268 100644 --- a/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java @@ -1,5 +1,7 @@ package com.be08.smart_notes.dto.response; +import com.be08.smart_notes.dto.view.Level; +import com.fasterxml.jackson.annotation.JsonView; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -11,9 +13,18 @@ @NoArgsConstructor @AllArgsConstructor public class QuizSetResponse { + @JsonView(Level.Basic.class) private Integer id; + + @JsonView(Level.Basic.class) private String title; + + @JsonView(Level.Basic.class) private LocalDateTime createdAt; + + @JsonView(Level.Basic.class) private LocalDateTime updatedAt; + + @JsonView(Level.Detail.class) private List quizzes; } diff --git a/src/main/java/com/be08/smart_notes/dto/view/Level.java b/src/main/java/com/be08/smart_notes/dto/view/Level.java new file mode 100644 index 0000000..9d7b660 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/view/Level.java @@ -0,0 +1,6 @@ +package com.be08.smart_notes.dto.view; + +public interface Level { + public interface Basic {} + public interface Detail extends Basic {} +} diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java index dfb72cd..e4b6ecc 100644 --- a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -46,7 +46,11 @@ public enum ErrorCode { // 24xx - Quiz-related features QUIZ_NOT_FOUND(2401, "Quiz not found", HttpStatus.NOT_FOUND), QUIZ_DOCUMENT_SIZE_EXCEED(2402, "Number of quiz IDs must be between 1 and 5", HttpStatus.BAD_REQUEST), - INVALID_QUIZ_SIZE(2403, "Total number of questions must between 1 and 20", HttpStatus.BAD_REQUEST), + INVALID_QUIZ_SIZE(2404, "Total number of questions must between 1 and 20", HttpStatus.BAD_REQUEST), + + // 25xx - Quiz-related features + QUIZ_SET_NOT_FOUND(2501, "Quiz set not found", HttpStatus.NOT_FOUND), + QUIZ_SET_TITLE_REQUIRED(2502, "Quiz set title required", HttpStatus.BAD_REQUEST), ; int code; diff --git a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java index 15fdc46..594c0d8 100644 --- a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java @@ -1,20 +1,27 @@ package com.be08.smart_notes.mapper; import com.be08.smart_notes.dto.QuizQuestion; +import com.be08.smart_notes.dto.request.QuizSetUpsertRequest; import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.response.QuizSetResponse; import com.be08.smart_notes.model.Question; import com.be08.smart_notes.model.Quiz; import com.be08.smart_notes.model.QuizSet; -import org.mapstruct.AfterMapping; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; -import org.mapstruct.MappingTarget; +import org.mapstruct.*; import java.util.List; @Mapper(componentModel = "spring") public interface QuizMapper { + // For update requests + @Mapping(target = "id", ignore = true) + @Mapping(target = "userId", ignore = true) + @Mapping(target = "originType", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "title", source = "title") + void updateQuizSet(@MappingTarget QuizSet quizSet, QuizSetUpsertRequest request); + // QuizSet entity <--> QuizSetResponse dto QuizSetResponse toQuizSetResponse(QuizSet quizSet); List toQuizSetResponseList(List quizSet); diff --git a/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java b/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java index 5acbd66..069fc13 100644 --- a/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java @@ -9,7 +9,8 @@ public interface QuizSetRepository extends JpaRepository { Optional findByUserIdAndOriginType(int userID, OriginType originType); - List findAllByUserId(Integer userId); + Optional findByIdAndUserId(int quizSetId, int userId); + List findAllByUserId(int userId); void deleteAllByUserId(int userId); } diff --git a/src/main/java/com/be08/smart_notes/service/QuizSetService.java b/src/main/java/com/be08/smart_notes/service/QuizSetService.java index c3adf0f..89cd2f8 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizSetService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizSetService.java @@ -2,6 +2,7 @@ import com.be08.smart_notes.common.AppConstants; import com.be08.smart_notes.dto.QuizQuestion; +import com.be08.smart_notes.dto.request.QuizSetUpsertRequest; import com.be08.smart_notes.dto.response.QuizSetResponse; import com.be08.smart_notes.enums.OriginType; import com.be08.smart_notes.exception.AppException; @@ -23,34 +24,47 @@ @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) @Slf4j +@Transactional public class QuizSetService { AuthorizationService authorizationService; QuizSetRepository quizSetRepository; QuizMapper quizMapper; /** - * Save new QuizSet from a single quiz with associated questions - * @param quizSetTitle the title for new quiz set, use quiz's title if null - * @param quizQuestion a quiz with its questions + * Create new empty QuizSet with no quizzes inside + * @param request data packed in client's request * @return response dto for saved quiz set */ - public QuizSetResponse saveQuizSet(String quizSetTitle, QuizQuestion quizQuestion, OriginType originType) { + public QuizSetResponse createQuizSet(QuizSetUpsertRequest request, OriginType originType) { int currentUserId = authorizationService.getCurrentUserId(); - QuizSet savedQuizSet = saveAsNewQuizSetEntity(currentUserId, quizSetTitle, quizQuestion, originType); + String quizSetTitle = request.getTitle(); + QuizSet quizSet = QuizSet.builder() + .userId(currentUserId) + .title(quizSetTitle != null ? quizSetTitle : AppConstants.DEFAULT_QUIZ_SET_TITLE) + .originType(originType).build(); + + QuizSet savedQuizSet = quizSetRepository.save(quizSet); return quizMapper.toQuizSetResponse(savedQuizSet); } /** - * Save new QuizSet from multiple quizzes with their associated questions + * Create new QuizSet from multiple quizzes with their associated questions * @param quizSetTitle a title for new quiz set, use default name if null * @param quizQuestionList list of quizzes to be added together with new set * @return response dto for saved quiz set */ - public QuizSetResponse saveQuizSet(String quizSetTitle, List quizQuestionList, OriginType originType) { + public QuizSetResponse createQuizSet(String quizSetTitle, List quizQuestionList, OriginType originType) { int currentUserId = authorizationService.getCurrentUserId(); - QuizSet savedQuizSet = saveAsNewQuizSetEntity(currentUserId, quizSetTitle, quizQuestionList, originType); + List newQuizzes = quizMapper.toQuizList(quizQuestionList); + QuizSet quizSet = QuizSet.builder() + .userId(currentUserId) + .title(quizSetTitle != null ? quizSetTitle : AppConstants.DEFAULT_QUIZ_SET_TITLE) + .originType(originType).build(); + quizSet.addQuizzes(newQuizzes); + + QuizSet savedQuizSet = quizSetRepository.save(quizSet); return quizMapper.toQuizSetResponse(savedQuizSet); } @@ -60,12 +74,13 @@ public QuizSetResponse saveQuizSet(String quizSetTitle, List quizQ * @return response dto for quiz set */ public QuizSetResponse getQuizSetById(int quizSetId) { - QuizSet quiz = quizSetRepository.findById(quizSetId).orElseThrow(() -> { - log.error("Quiz set with id {} not found", quizSetId); - return new AppException(ErrorCode.QUIZ_NOT_FOUND); + int currentUserId = authorizationService.getCurrentUserId(); + + QuizSet quiz = quizSetRepository.findByIdAndUserId(quizSetId, currentUserId).orElseThrow(() -> { + log.error("Quiz set with id {} not found in user's account", quizSetId); + return new AppException(ErrorCode.QUIZ_SET_NOT_FOUND); }); - authorizationService.validateOwnership(quiz.getUserId()); return quizMapper.toQuizSetResponse(quiz); } @@ -81,17 +96,35 @@ public List getAllQuizSets() { return quizMapper.toQuizSetResponseList(quizSets); } + /** + * Update existing QuizSet based on given request from client + * @param request data packed in client's request + * @return response dto for saved quiz set + */ + public QuizSetResponse updateQuizSet(int quizSetId, QuizSetUpsertRequest request) { + int currentUserId = authorizationService.getCurrentUserId(); + + QuizSet existingQuizSet = quizSetRepository.findByIdAndUserId(quizSetId, currentUserId).orElseThrow(() -> { + log.error("Quiz set with id {} not found in user's account", quizSetId); + return new AppException(ErrorCode.QUIZ_SET_NOT_FOUND); + }); + + quizMapper.updateQuizSet(existingQuizSet, request); + existingQuizSet = quizSetRepository.save(existingQuizSet); + return quizMapper.toQuizSetResponse(existingQuizSet); + } + /** * Delete a QuizSet using given id * @param quizSetId id of target quiz set */ public void deleteQuizSetById(int quizSetId) { - QuizSet quiz = quizSetRepository.findById(quizSetId).orElseThrow(() -> { - log.error("Quiz set with id {} not found", quizSetId); - return new AppException(ErrorCode.QUIZ_NOT_FOUND); - }); + int currentUserId = authorizationService.getCurrentUserId(); - authorizationService.validateOwnership(quiz.getUserId()); + QuizSet quiz = quizSetRepository.findByIdAndUserId(quizSetId, currentUserId).orElseThrow(() -> { + log.error("Quiz set with id {} not found in user's account", quizSetId); + return new AppException(ErrorCode.QUIZ_SET_NOT_FOUND); + }); quizSetRepository.deleteById(quizSetId); } @@ -99,7 +132,6 @@ public void deleteQuizSetById(int quizSetId) { /** * Delete all QuizSet own by current user, together with its quizzes and questions */ - @Transactional public void deleteAllQuizSet() { int currentUserId = authorizationService.getCurrentUserId(); quizSetRepository.deleteAllByUserId(currentUserId); @@ -127,12 +159,12 @@ public QuizSet getOrCreateDefaultSet(int userId) { * @param quiz new quiz to be added * @return default quiz set with new quiz added */ - public QuizSet saveNewQuizToDefaultSet(int userId, Quiz quiz) { - QuizSet defaultSet = getOrCreateDefaultSet(userId); - - defaultSet.addQuiz(quiz); - return quizSetRepository.save(defaultSet); - } +// public QuizSet saveNewQuizToDefaultSet(int userId, Quiz quiz) { +// QuizSet defaultSet = getOrCreateDefaultSet(userId); +// +// defaultSet.addQuiz(quiz); +// return quizSetRepository.save(defaultSet); +// } /** * Create and save new QuizSet from a quiz with associated questions @@ -141,17 +173,17 @@ public QuizSet saveNewQuizToDefaultSet(int userId, Quiz quiz) { * @param quizQuestion the quiz with questions to be added in new quiz set * @return the saved QuizSet */ - public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, QuizQuestion quizQuestion, OriginType originType) { - Quiz quiz = quizMapper.toQuiz(quizQuestion); - - QuizSet quizSet = QuizSet.builder() - .userId(userId) - .title(quizSetTitle != null ? quizSetTitle : quizQuestion.getTitle()) - .originType(originType).build(); - quizSet.addQuiz(quiz); - - return quizSetRepository.save(quizSet); - } +// public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, QuizQuestion quizQuestion, OriginType originType) { +// Quiz quiz = quizMapper.toQuiz(quizQuestion); +// +// QuizSet quizSet = QuizSet.builder() +// .userId(userId) +// .title(quizSetTitle != null ? quizSetTitle : quizQuestion.getTitle()) +// .originType(originType).build(); +// quizSet.addQuiz(quiz); +// +// return quizSetRepository.save(quizSet); +// } /** * Create and save new QuizSet from a list of quizzes with their associated questions @@ -160,15 +192,15 @@ public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, QuizQuest * @param quizQuestionList list of quizzes with questions to be added in new quiz set * @return the saved QuizSet */ - public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, List quizQuestionList, OriginType originType) { - List newQuizzes = quizMapper.toQuizList(quizQuestionList); - - QuizSet quizSet = QuizSet.builder() - .userId(userId) - .title(quizSetTitle != null ? quizSetTitle : AppConstants.DEFAULT_QUIZ_SET_TITLE) - .originType(originType).build(); - quizSet.addQuizzes(newQuizzes); - - return quizSetRepository.save(quizSet); - } +// public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, List quizQuestionList, OriginType originType) { +// List newQuizzes = quizMapper.toQuizList(quizQuestionList); +// +// QuizSet quizSet = QuizSet.builder() +// .userId(userId) +// .title(quizSetTitle != null ? quizSetTitle : AppConstants.DEFAULT_QUIZ_SET_TITLE) +// .originType(originType).build(); +// quizSet.addQuizzes(newQuizzes); +// +// return quizSetRepository.save(quizSet); +// } } diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index 23abf86..8bbab7d 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -137,7 +137,7 @@ public QuizSetResponse generateQuizSet(QuizGenerationRequest quizGenerationReque log.error("Quiz set was not created because no quizzes are generated."); throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } - return quizSetService.saveQuizSet(AppConstants.DEFAULT_QUIZ_SET_TITLE, quizQuestionList, OriginType.AI); + return quizSetService.createQuizSet(AppConstants.DEFAULT_QUIZ_SET_TITLE, quizQuestionList, OriginType.AI); } // ------ Internal methods ------ // From 62d8ed9b21895eb22deb8d6f549d6a83037f90a9 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 6 Nov 2025 23:58:48 +1100 Subject: [PATCH 28/63] feat(quiz): expose crud operation endpoints for quizzes --- .../controller/QuizController.java | 67 +++++++++++++++++++ .../controller/QuizSetController.java | 2 +- .../dto/{QuizQuestion.java => QuizDTO.java} | 19 +++--- .../dto/response/QuizResponse.java | 3 + .../be08/smart_notes/exception/ErrorCode.java | 1 + .../be08/smart_notes/mapper/QuizMapper.java | 23 +++++-- .../repository/QuizRepository.java | 3 + .../be08/smart_notes/service/QuizService.java | 60 +++++++++++------ .../smart_notes/service/QuizSetService.java | 24 +++++-- .../service/ai/QuizGenerationService.java | 36 +++++----- 10 files changed, 178 insertions(+), 60 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/controller/QuizController.java rename src/main/java/com/be08/smart_notes/dto/{QuizQuestion.java => QuizDTO.java} (63%) diff --git a/src/main/java/com/be08/smart_notes/controller/QuizController.java b/src/main/java/com/be08/smart_notes/controller/QuizController.java new file mode 100644 index 0000000..119d838 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/controller/QuizController.java @@ -0,0 +1,67 @@ +package com.be08.smart_notes.controller; + +import com.be08.smart_notes.dto.QuizDTO; +import com.be08.smart_notes.dto.response.ApiResponse; +import com.be08.smart_notes.dto.response.QuizResponse; +import com.be08.smart_notes.dto.view.Level; +import com.be08.smart_notes.service.QuizService; +import com.be08.smart_notes.validation.group.OnCreate; +import com.be08.smart_notes.validation.group.OnUpdate; +import com.fasterxml.jackson.annotation.JsonView; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/quizzes") +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class QuizController { + QuizService quizService; + + @PostMapping + @JsonView(Level.Detail.class) + public ResponseEntity createQuiz(@RequestBody @Validated(OnCreate.class) QuizDTO request) { + QuizResponse quizResponse = quizService.createQuiz(request); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz created successfully in default set") + .data(quizResponse) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + + @GetMapping("/{id}") + @JsonView(Level.Detail.class) + public ResponseEntity getQuiz(@PathVariable int id) { + QuizResponse quizResponse = quizService.getQuizById(id); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz fetched successfully") + .data(quizResponse) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + + @PutMapping("/{id}") + @JsonView(Level.Detail.class) + public ResponseEntity updateQuiz(@PathVariable int id, @Validated(OnUpdate.class) @RequestBody QuizDTO request) { + QuizResponse quizResponse = quizService.updateQuiz(id, request); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz updated successfully") + .data(quizResponse) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteQuiz(@PathVariable int id) { + quizService.deleteQuizById(id); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz set deleted successfully") + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } +} diff --git a/src/main/java/com/be08/smart_notes/controller/QuizSetController.java b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java index 115bb21..f492c1b 100644 --- a/src/main/java/com/be08/smart_notes/controller/QuizSetController.java +++ b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java @@ -62,7 +62,7 @@ public ResponseEntity getQuizSet(@PathVariable int id) { public ResponseEntity updateQuizSet(@PathVariable int id, @RequestBody QuizSetUpsertRequest request) { QuizSetResponse quizSetResponse = quizSetService.updateQuizSet(id, request); ApiResponse apiResponse = ApiResponse.builder() - .message("Quiz set created successfully") + .message("Quiz set updated successfully") .data(quizSetResponse) .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); diff --git a/src/main/java/com/be08/smart_notes/dto/QuizQuestion.java b/src/main/java/com/be08/smart_notes/dto/QuizDTO.java similarity index 63% rename from src/main/java/com/be08/smart_notes/dto/QuizQuestion.java rename to src/main/java/com/be08/smart_notes/dto/QuizDTO.java index c14c29f..8ebfb04 100644 --- a/src/main/java/com/be08/smart_notes/dto/QuizQuestion.java +++ b/src/main/java/com/be08/smart_notes/dto/QuizDTO.java @@ -1,10 +1,9 @@ package com.be08.smart_notes.dto; +import com.be08.smart_notes.validation.group.OnCreate; +import com.be08.smart_notes.validation.group.OnUpdate; import com.fasterxml.jackson.annotation.JsonProperty; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.*; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -14,14 +13,18 @@ @Data @NoArgsConstructor @AllArgsConstructor -public class QuizQuestion { +public class QuizDTO { private Integer sourceDocumentId; - @NotNull + // If null in creation request, new quiz will be categorised in default set + @NotNull(groups = OnUpdate.class, message = "QUIZ_SET_ID_REQUIRED") + private Integer quizSetId; + + @NotEmpty(groups = {OnCreate.class, OnUpdate.class}, message = "QUIZ_TITLE_REQUIRED") @JsonProperty(value = "topic") - private String title; + private String title; - @NotNull + @NotEmpty(groups = OnCreate.class) @JsonProperty(value = "questions") private List questions; diff --git a/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java index 728e819..68731fb 100644 --- a/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java @@ -19,6 +19,9 @@ public class QuizResponse { @JsonView(Level.Basic.class) private String title; + @JsonView(Level.Basic.class) + private Integer quizSetId; + @JsonView(Level.Basic.class) private Integer sourceDocumentId; diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java index e4b6ecc..fb1a70b 100644 --- a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -50,6 +50,7 @@ public enum ErrorCode { // 25xx - Quiz-related features QUIZ_SET_NOT_FOUND(2501, "Quiz set not found", HttpStatus.NOT_FOUND), + QUIZ_SET_ID_REQUIRED(2502, "Quiz set ID required", HttpStatus.BAD_REQUEST), QUIZ_SET_TITLE_REQUIRED(2502, "Quiz set title required", HttpStatus.BAD_REQUEST), ; diff --git a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java index 594c0d8..b098e90 100644 --- a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java @@ -1,6 +1,6 @@ package com.be08.smart_notes.mapper; -import com.be08.smart_notes.dto.QuizQuestion; +import com.be08.smart_notes.dto.QuizDTO; import com.be08.smart_notes.dto.request.QuizSetUpsertRequest; import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.response.QuizSetResponse; @@ -20,28 +20,39 @@ public interface QuizMapper { @Mapping(target = "createdAt", ignore = true) @Mapping(target = "updatedAt", ignore = true) @Mapping(target = "title", source = "title") + @Mapping(target = "quizzes", ignore = true) void updateQuizSet(@MappingTarget QuizSet quizSet, QuizSetUpsertRequest request); + @Mapping(target = "id", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "title", source = "title") + @Mapping(target = "sourceDocumentId", source = "sourceDocumentId") + @Mapping(target = "quizSet", ignore = true) + @Mapping(target = "questions", ignore = true) + void updateQuiz(@MappingTarget Quiz quiz, QuizDTO request); + // QuizSet entity <--> QuizSetResponse dto QuizSetResponse toQuizSetResponse(QuizSet quizSet); List toQuizSetResponseList(List quizSet); // Quiz Entity <--> QuizResponse dto + @Mapping(target = "quizSetId", source = "entity.quizSet.id") QuizResponse toQuizResponse(Quiz entity); Quiz toQuiz(QuizResponse dto); - // Quiz Entity <--> QuizQuestion - Quiz toQuiz(QuizQuestion dto); - List toQuizList(List dtoList); + // Quiz Entity <--> QuizDTO + Quiz toQuiz(QuizDTO dto); + List toQuizList(List dtoList); - // Question Entity <--> QuizQuestion.Question dto + // Question Entity <--> QuizDTO.Question dto @Mapping(target = "id", ignore = true) @Mapping(target = "optionA", expression = "java(dto.getOptions()[0])") @Mapping(target = "optionB", expression = "java(dto.getOptions()[1])") @Mapping(target = "optionC", expression = "java(dto.getOptions()[2])") @Mapping(target = "optionD", expression = "java(dto.getOptions()[3])") @Mapping(target = "correctAnswer", expression = "java(indexToLetter(dto.getCorrectIndex()))") - Question toQuestionEntity(QuizQuestion.Question dto); + Question toQuestionEntity(QuizDTO.Question dto); default Character indexToLetter(Integer index) { return (char) ('A' + index); diff --git a/src/main/java/com/be08/smart_notes/repository/QuizRepository.java b/src/main/java/com/be08/smart_notes/repository/QuizRepository.java index 774d9bc..66fdc12 100644 --- a/src/main/java/com/be08/smart_notes/repository/QuizRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/QuizRepository.java @@ -4,5 +4,8 @@ import com.be08.smart_notes.model.Quiz; +import java.util.Optional; + public interface QuizRepository extends JpaRepository { + Optional findByIdAndQuizSetUserId(int id, int userId); } diff --git a/src/main/java/com/be08/smart_notes/service/QuizService.java b/src/main/java/com/be08/smart_notes/service/QuizService.java index ffc4641..3a30689 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizService.java @@ -1,6 +1,6 @@ package com.be08.smart_notes.service; -import com.be08.smart_notes.dto.QuizQuestion; +import com.be08.smart_notes.dto.QuizDTO; import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.exception.AppException; import com.be08.smart_notes.exception.ErrorCode; @@ -24,44 +24,64 @@ public class QuizService { QuizRepository quizRepository; QuizMapper quizMapper; - public QuizResponse saveQuiz(QuizQuestion quizQuestion) { + public QuizResponse createQuiz(QuizDTO quizDTO) { int currentUserId = authorizationService.getCurrentUserId(); - Quiz savedQuiz = saveAsNewQuizEntity(currentUserId, quizQuestion); + QuizSet defaultSet = quizSetService.getOrCreateDefaultSet(currentUserId); + Quiz newQuiz = quizMapper.toQuiz(quizDTO); + newQuiz.setQuizSet(defaultSet); + + Quiz savedQuiz = quizRepository.save(newQuiz); return quizMapper.toQuizResponse(savedQuiz); } public QuizResponse getQuizById(int quizId) { - Quiz quiz = quizRepository.findById(quizId).orElseThrow(() -> { - log.error("Quiz with id {} not found", quizId); + int currentUserId = authorizationService.getCurrentUserId(); + + Quiz quiz = quizRepository.findByIdAndQuizSetUserId(quizId, currentUserId).orElseThrow(() -> { + log.error("Quiz with id {} not found in user's account", quizId); return new AppException(ErrorCode.QUIZ_NOT_FOUND); }); - // Check ownership - authorizationService.validateOwnership(quiz.getQuizSet().getUserId()); - return quizMapper.toQuizResponse(quiz); } - public void deleteQuizById(int quizId) { - Quiz quiz = quizRepository.findById(quizId).orElseThrow(() -> { - log.error("Quiz with id {} not found", quizId); + public QuizResponse updateQuiz(int quizId, QuizDTO quizDTO) { + int currentUserId = authorizationService.getCurrentUserId(); + + Quiz existingQuiz = quizRepository.findByIdAndQuizSetUserId(quizId, currentUserId).orElseThrow(() -> { + log.error("Quiz with id {} not found in user's account", quizId); return new AppException(ErrorCode.QUIZ_NOT_FOUND); }); - // Check ownership - authorizationService.validateOwnership(quiz.getQuizSet().getUserId()); + if (quizDTO.getQuizSetId() != null) { + QuizSet quizSet = quizSetService.getQuizSetEntityById(quizDTO.getQuizSetId()); + existingQuiz.setQuizSet(quizSet); + } - quizRepository.deleteById(quizId); + quizMapper.updateQuiz(existingQuiz, quizDTO); + existingQuiz = quizRepository.save(existingQuiz); + return quizMapper.toQuizResponse(existingQuiz); } - // ------ Methods that returns entities ------ // - public Quiz saveAsNewQuizEntity(int userId, QuizQuestion quizQuestion) { - QuizSet defaultSet = quizSetService.getOrCreateDefaultSet(userId); + public void deleteQuizById(int quizId) { + int currentUserId = authorizationService.getCurrentUserId(); - Quiz newQuiz = quizMapper.toQuiz(quizQuestion); - newQuiz.setQuizSet(defaultSet); + Quiz quiz = quizRepository.findByIdAndQuizSetUserId(quizId, currentUserId).orElseThrow(() -> { + log.error("Quiz with id {} not found in user's account", quizId); + return new AppException(ErrorCode.QUIZ_NOT_FOUND); + }); - return quizRepository.save(newQuiz); + quizRepository.deleteById(quizId); } + + // ------ Methods that returns entities ------ // +// public Quiz saveAsNewQuizEntity(int userId, QuizDTO quizDTO) { +// QuizSet defaultSet = quizSetService.getOrCreateDefaultSet(userId); +// +// Quiz newQuiz = quizMapper.toQuiz(quizDTO); +// newQuiz.setQuizSet(defaultSet); +// +// return quizRepository.save(newQuiz); +// } } diff --git a/src/main/java/com/be08/smart_notes/service/QuizSetService.java b/src/main/java/com/be08/smart_notes/service/QuizSetService.java index 89cd2f8..96f8c35 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizSetService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizSetService.java @@ -1,7 +1,7 @@ package com.be08.smart_notes.service; import com.be08.smart_notes.common.AppConstants; -import com.be08.smart_notes.dto.QuizQuestion; +import com.be08.smart_notes.dto.QuizDTO; import com.be08.smart_notes.dto.request.QuizSetUpsertRequest; import com.be08.smart_notes.dto.response.QuizSetResponse; import com.be08.smart_notes.enums.OriginType; @@ -51,13 +51,13 @@ public QuizSetResponse createQuizSet(QuizSetUpsertRequest request, OriginType or /** * Create new QuizSet from multiple quizzes with their associated questions * @param quizSetTitle a title for new quiz set, use default name if null - * @param quizQuestionList list of quizzes to be added together with new set + * @param quizDTOList list of quizzes to be added together with new set * @return response dto for saved quiz set */ - public QuizSetResponse createQuizSet(String quizSetTitle, List quizQuestionList, OriginType originType) { + public QuizSetResponse createQuizSet(String quizSetTitle, List quizDTOList, OriginType originType) { int currentUserId = authorizationService.getCurrentUserId(); - List newQuizzes = quizMapper.toQuizList(quizQuestionList); + List newQuizzes = quizMapper.toQuizList(quizDTOList); QuizSet quizSet = QuizSet.builder() .userId(currentUserId) .title(quizSetTitle != null ? quizSetTitle : AppConstants.DEFAULT_QUIZ_SET_TITLE) @@ -81,7 +81,6 @@ public QuizSetResponse getQuizSetById(int quizSetId) { return new AppException(ErrorCode.QUIZ_SET_NOT_FOUND); }); - return quizMapper.toQuizSetResponse(quiz); } @@ -153,6 +152,17 @@ public QuizSet getOrCreateDefaultSet(int userId) { }); } + public QuizSet getQuizSetEntityById(int quizSetId) { + int currentUserId = authorizationService.getCurrentUserId(); + + QuizSet quizSet = quizSetRepository.findByIdAndUserId(quizSetId, currentUserId).orElseThrow(() -> { + log.error("Quiz set with id {} not found in user's account", quizSetId); + return new AppException(ErrorCode.QUIZ_SET_NOT_FOUND); + }); + + return quizSet; + } + /** * Save new quiz to default quiz set (uncategorized) * @param userId id of current user @@ -173,7 +183,7 @@ public QuizSet getOrCreateDefaultSet(int userId) { * @param quizQuestion the quiz with questions to be added in new quiz set * @return the saved QuizSet */ -// public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, QuizQuestion quizQuestion, OriginType originType) { +// public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, QuizDTO quizQuestion, OriginType originType) { // Quiz quiz = quizMapper.toQuiz(quizQuestion); // // QuizSet quizSet = QuizSet.builder() @@ -192,7 +202,7 @@ public QuizSet getOrCreateDefaultSet(int userId) { * @param quizQuestionList list of quizzes with questions to be added in new quiz set * @return the saved QuizSet */ -// public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, List quizQuestionList, OriginType originType) { +// public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, List quizQuestionList, OriginType originType) { // List newQuizzes = quizMapper.toQuizList(quizQuestionList); // // QuizSet quizSet = QuizSet.builder() diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index 8bbab7d..517cccf 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -7,7 +7,7 @@ import java.util.ArrayList; import java.util.List; -import com.be08.smart_notes.dto.QuizQuestion; +import com.be08.smart_notes.dto.QuizDTO; import com.be08.smart_notes.dto.request.QuizGenerationRequest; import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.response.NoteResponse; @@ -79,9 +79,9 @@ public QuizResponse generateSampleQuiz() { ObjectMapper objectMapper = new ObjectMapper(); try { // Extract raw message content from response string - QuizQuestion quizQuestion = objectMapper.readValue(generatedContent, QuizQuestion.class); + QuizDTO quizDTO = objectMapper.readValue(generatedContent, QuizDTO.class); - Quiz sampleQuizEntity = quizMapper.toQuiz(quizQuestion); + Quiz sampleQuizEntity = quizMapper.toQuiz(quizDTO); return quizMapper.toQuizResponse(sampleQuizEntity); } catch (Exception e) { log.error("An error occurred when mapping objects, could not create sample quiz."); @@ -101,10 +101,10 @@ public QuizResponse generateQuiz(QuizGenerationRequest quizGenerationRequest) { int noteId = quizGenerationRequest.getDocId(); NoteResponse selectedNote = noteService.getNote(noteId); - QuizQuestion quizQuestion = generateQuizFromNote(selectedNote.getContent(), prompt); + QuizDTO quizDTO = generateQuizFromNote(selectedNote.getContent(), prompt); - quizQuestion.setSourceDocumentId(noteId); - return quizService.saveQuiz(quizQuestion); + quizDTO.setSourceDocumentId(noteId); + return quizService.createQuiz(quizDTO); } public QuizSetResponse generateQuizSet(QuizGenerationRequest quizGenerationRequest) { @@ -120,28 +120,28 @@ public QuizSetResponse generateQuizSet(QuizGenerationRequest quizGenerationReque // Save quizzes from list of notes (generate one by one) List noteList = noteService.getAllNotesByIds(noteIds); - List quizQuestionList = new ArrayList<>(); + List quizDTOList = new ArrayList<>(); for (Document note : noteList) { try { authorizationService.validateOwnership(note.getUserId()); - QuizQuestion quizQuestion = generateQuizFromNote(note.getContent(), prompt); - quizQuestion.setSourceDocumentId(note.getId()); - quizQuestionList.add(quizQuestion); + QuizDTO quizDTO = generateQuizFromNote(note.getContent(), prompt); + quizDTO.setSourceDocumentId(note.getId()); + quizDTOList.add(quizDTO); } catch (Exception e) { System.out.println("Error in generating quiz: " + e.getMessage()); } } - if (quizQuestionList.isEmpty()) { + if (quizDTOList.isEmpty()) { log.error("Quiz set was not created because no quizzes are generated."); throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } - return quizSetService.createQuizSet(AppConstants.DEFAULT_QUIZ_SET_TITLE, quizQuestionList, OriginType.AI); + return quizSetService.createQuizSet(AppConstants.DEFAULT_QUIZ_SET_TITLE, quizDTOList, OriginType.AI); } // ------ Internal methods ------ // - private QuizQuestion generateQuizFromNote(String noteContent, String prompt) { + private QuizDTO generateQuizFromNote(String noteContent, String prompt) { String generatedContent = aiService.generateContent( prompt, noteContent, @@ -154,17 +154,17 @@ private QuizQuestion generateQuizFromNote(String noteContent, String prompt) { // Map generated content (JSON String) to Object ObjectMapper objectMapper = new ObjectMapper(); - QuizQuestion quizQuestion = null; + QuizDTO quizDTO = null; try { - quizQuestion = objectMapper.readValue(generatedContent, QuizQuestion.class); + quizDTO = objectMapper.readValue(generatedContent, QuizDTO.class); } catch (Exception e) { - log.error("Could not map generated content to QuizQuestion."); + log.error("Could not map generated content to QuizDTO."); throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } - if (quizQuestion == null) { + if (quizDTO == null) { log.error("Could not generate quiz because of invalid object mapping result."); throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } - return quizQuestion; + return quizDTO; } } From 775a385d0803205906a7648981cf89d3ba89876e Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Fri, 7 Nov 2025 15:46:35 +1100 Subject: [PATCH 29/63] refactor(quiz): rename quiz upsert dto and separate quiz mapper --- .../controller/QuizController.java | 6 ++-- .../dto/{QuizDTO.java => QuizUpsertDTO.java} | 2 +- .../be08/smart_notes/mapper/QuizMapper.java | 27 ++++---------- .../smart_notes/mapper/QuizSetMapper.java | 27 ++++++++++++++ .../java/com/be08/smart_notes/model/Quiz.java | 1 - .../be08/smart_notes/service/QuizService.java | 24 ++++--------- .../smart_notes/service/QuizSetService.java | 26 +++++++------- .../service/ai/QuizGenerationService.java | 36 +++++++++---------- 8 files changed, 77 insertions(+), 72 deletions(-) rename src/main/java/com/be08/smart_notes/dto/{QuizDTO.java => QuizUpsertDTO.java} (97%) create mode 100644 src/main/java/com/be08/smart_notes/mapper/QuizSetMapper.java diff --git a/src/main/java/com/be08/smart_notes/controller/QuizController.java b/src/main/java/com/be08/smart_notes/controller/QuizController.java index 119d838..30bc359 100644 --- a/src/main/java/com/be08/smart_notes/controller/QuizController.java +++ b/src/main/java/com/be08/smart_notes/controller/QuizController.java @@ -1,6 +1,6 @@ package com.be08.smart_notes.controller; -import com.be08.smart_notes.dto.QuizDTO; +import com.be08.smart_notes.dto.QuizUpsertDTO; import com.be08.smart_notes.dto.response.ApiResponse; import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.view.Level; @@ -25,7 +25,7 @@ public class QuizController { @PostMapping @JsonView(Level.Detail.class) - public ResponseEntity createQuiz(@RequestBody @Validated(OnCreate.class) QuizDTO request) { + public ResponseEntity createQuiz(@RequestBody @Validated(OnCreate.class) QuizUpsertDTO request) { QuizResponse quizResponse = quizService.createQuiz(request); ApiResponse apiResponse = ApiResponse.builder() .message("Quiz created successfully in default set") @@ -47,7 +47,7 @@ public ResponseEntity getQuiz(@PathVariable int id) { @PutMapping("/{id}") @JsonView(Level.Detail.class) - public ResponseEntity updateQuiz(@PathVariable int id, @Validated(OnUpdate.class) @RequestBody QuizDTO request) { + public ResponseEntity updateQuiz(@PathVariable int id, @Validated(OnUpdate.class) @RequestBody QuizUpsertDTO request) { QuizResponse quizResponse = quizService.updateQuiz(id, request); ApiResponse apiResponse = ApiResponse.builder() .message("Quiz updated successfully") diff --git a/src/main/java/com/be08/smart_notes/dto/QuizDTO.java b/src/main/java/com/be08/smart_notes/dto/QuizUpsertDTO.java similarity index 97% rename from src/main/java/com/be08/smart_notes/dto/QuizDTO.java rename to src/main/java/com/be08/smart_notes/dto/QuizUpsertDTO.java index 8ebfb04..61b2ea7 100644 --- a/src/main/java/com/be08/smart_notes/dto/QuizDTO.java +++ b/src/main/java/com/be08/smart_notes/dto/QuizUpsertDTO.java @@ -13,7 +13,7 @@ @Data @NoArgsConstructor @AllArgsConstructor -public class QuizDTO { +public class QuizUpsertDTO { private Integer sourceDocumentId; // If null in creation request, new quiz will be categorised in default set diff --git a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java index b098e90..adb2660 100644 --- a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java @@ -1,6 +1,6 @@ package com.be08.smart_notes.mapper; -import com.be08.smart_notes.dto.QuizDTO; +import com.be08.smart_notes.dto.QuizUpsertDTO; import com.be08.smart_notes.dto.request.QuizSetUpsertRequest; import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.response.QuizSetResponse; @@ -14,15 +14,6 @@ @Mapper(componentModel = "spring") public interface QuizMapper { // For update requests - @Mapping(target = "id", ignore = true) - @Mapping(target = "userId", ignore = true) - @Mapping(target = "originType", ignore = true) - @Mapping(target = "createdAt", ignore = true) - @Mapping(target = "updatedAt", ignore = true) - @Mapping(target = "title", source = "title") - @Mapping(target = "quizzes", ignore = true) - void updateQuizSet(@MappingTarget QuizSet quizSet, QuizSetUpsertRequest request); - @Mapping(target = "id", ignore = true) @Mapping(target = "createdAt", ignore = true) @Mapping(target = "updatedAt", ignore = true) @@ -30,29 +21,25 @@ public interface QuizMapper { @Mapping(target = "sourceDocumentId", source = "sourceDocumentId") @Mapping(target = "quizSet", ignore = true) @Mapping(target = "questions", ignore = true) - void updateQuiz(@MappingTarget Quiz quiz, QuizDTO request); - - // QuizSet entity <--> QuizSetResponse dto - QuizSetResponse toQuizSetResponse(QuizSet quizSet); - List toQuizSetResponseList(List quizSet); + void updateQuiz(@MappingTarget Quiz quiz, QuizUpsertDTO request); // Quiz Entity <--> QuizResponse dto @Mapping(target = "quizSetId", source = "entity.quizSet.id") QuizResponse toQuizResponse(Quiz entity); Quiz toQuiz(QuizResponse dto); - // Quiz Entity <--> QuizDTO - Quiz toQuiz(QuizDTO dto); - List toQuizList(List dtoList); + // Quiz Entity <--> QuizUpsertDTO + Quiz toQuiz(QuizUpsertDTO dto); + List toQuizList(List dtoList); - // Question Entity <--> QuizDTO.Question dto + // Question Entity <--> QuizUpsertDTO.Question dto @Mapping(target = "id", ignore = true) @Mapping(target = "optionA", expression = "java(dto.getOptions()[0])") @Mapping(target = "optionB", expression = "java(dto.getOptions()[1])") @Mapping(target = "optionC", expression = "java(dto.getOptions()[2])") @Mapping(target = "optionD", expression = "java(dto.getOptions()[3])") @Mapping(target = "correctAnswer", expression = "java(indexToLetter(dto.getCorrectIndex()))") - Question toQuestionEntity(QuizDTO.Question dto); + Question toQuestionEntity(QuizUpsertDTO.Question dto); default Character indexToLetter(Integer index) { return (char) ('A' + index); diff --git a/src/main/java/com/be08/smart_notes/mapper/QuizSetMapper.java b/src/main/java/com/be08/smart_notes/mapper/QuizSetMapper.java new file mode 100644 index 0000000..16f5bf2 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/mapper/QuizSetMapper.java @@ -0,0 +1,27 @@ +package com.be08.smart_notes.mapper; + +import com.be08.smart_notes.dto.request.QuizSetUpsertRequest; +import com.be08.smart_notes.dto.response.QuizSetResponse; +import com.be08.smart_notes.model.QuizSet; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; + +import java.util.List; + +@Mapper(componentModel = "spring", uses = QuizMapper.class) +public interface QuizSetMapper { + // For update request + @Mapping(target = "id", ignore = true) + @Mapping(target = "userId", ignore = true) + @Mapping(target = "originType", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "title", source = "title") + @Mapping(target = "quizzes", ignore = true) + void updateQuizSet(@MappingTarget QuizSet quizSet, QuizSetUpsertRequest request); + + // QuizSet entity <--> QuizSetResponse dto + QuizSetResponse toQuizSetResponse(QuizSet quizSet); + List toQuizSetResponseList(List quizSet); +} diff --git a/src/main/java/com/be08/smart_notes/model/Quiz.java b/src/main/java/com/be08/smart_notes/model/Quiz.java index 66ee2be..2b84a43 100644 --- a/src/main/java/com/be08/smart_notes/model/Quiz.java +++ b/src/main/java/com/be08/smart_notes/model/Quiz.java @@ -8,7 +8,6 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; @Data diff --git a/src/main/java/com/be08/smart_notes/service/QuizService.java b/src/main/java/com/be08/smart_notes/service/QuizService.java index 3a30689..2e78f9e 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizService.java @@ -1,6 +1,6 @@ package com.be08.smart_notes.service; -import com.be08.smart_notes.dto.QuizDTO; +import com.be08.smart_notes.dto.QuizUpsertDTO; import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.exception.AppException; import com.be08.smart_notes.exception.ErrorCode; @@ -24,11 +24,11 @@ public class QuizService { QuizRepository quizRepository; QuizMapper quizMapper; - public QuizResponse createQuiz(QuizDTO quizDTO) { + public QuizResponse createQuiz(QuizUpsertDTO quizUpsertDTO) { int currentUserId = authorizationService.getCurrentUserId(); QuizSet defaultSet = quizSetService.getOrCreateDefaultSet(currentUserId); - Quiz newQuiz = quizMapper.toQuiz(quizDTO); + Quiz newQuiz = quizMapper.toQuiz(quizUpsertDTO); newQuiz.setQuizSet(defaultSet); Quiz savedQuiz = quizRepository.save(newQuiz); @@ -46,7 +46,7 @@ public QuizResponse getQuizById(int quizId) { return quizMapper.toQuizResponse(quiz); } - public QuizResponse updateQuiz(int quizId, QuizDTO quizDTO) { + public QuizResponse updateQuiz(int quizId, QuizUpsertDTO quizUpsertDTO) { int currentUserId = authorizationService.getCurrentUserId(); Quiz existingQuiz = quizRepository.findByIdAndQuizSetUserId(quizId, currentUserId).orElseThrow(() -> { @@ -54,12 +54,12 @@ public QuizResponse updateQuiz(int quizId, QuizDTO quizDTO) { return new AppException(ErrorCode.QUIZ_NOT_FOUND); }); - if (quizDTO.getQuizSetId() != null) { - QuizSet quizSet = quizSetService.getQuizSetEntityById(quizDTO.getQuizSetId()); + if (quizUpsertDTO.getQuizSetId() != null) { + QuizSet quizSet = quizSetService.getQuizSetEntityById(quizUpsertDTO.getQuizSetId()); existingQuiz.setQuizSet(quizSet); } - quizMapper.updateQuiz(existingQuiz, quizDTO); + quizMapper.updateQuiz(existingQuiz, quizUpsertDTO); existingQuiz = quizRepository.save(existingQuiz); return quizMapper.toQuizResponse(existingQuiz); } @@ -74,14 +74,4 @@ public void deleteQuizById(int quizId) { quizRepository.deleteById(quizId); } - - // ------ Methods that returns entities ------ // -// public Quiz saveAsNewQuizEntity(int userId, QuizDTO quizDTO) { -// QuizSet defaultSet = quizSetService.getOrCreateDefaultSet(userId); -// -// Quiz newQuiz = quizMapper.toQuiz(quizDTO); -// newQuiz.setQuizSet(defaultSet); -// -// return quizRepository.save(newQuiz); -// } } diff --git a/src/main/java/com/be08/smart_notes/service/QuizSetService.java b/src/main/java/com/be08/smart_notes/service/QuizSetService.java index 96f8c35..f93af6e 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizSetService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizSetService.java @@ -1,13 +1,14 @@ package com.be08.smart_notes.service; import com.be08.smart_notes.common.AppConstants; -import com.be08.smart_notes.dto.QuizDTO; +import com.be08.smart_notes.dto.QuizUpsertDTO; import com.be08.smart_notes.dto.request.QuizSetUpsertRequest; import com.be08.smart_notes.dto.response.QuizSetResponse; import com.be08.smart_notes.enums.OriginType; import com.be08.smart_notes.exception.AppException; import com.be08.smart_notes.exception.ErrorCode; import com.be08.smart_notes.mapper.QuizMapper; +import com.be08.smart_notes.mapper.QuizSetMapper; import com.be08.smart_notes.model.Quiz; import com.be08.smart_notes.model.QuizSet; import com.be08.smart_notes.repository.QuizSetRepository; @@ -29,6 +30,7 @@ public class QuizSetService { AuthorizationService authorizationService; QuizSetRepository quizSetRepository; QuizMapper quizMapper; + QuizSetMapper quizSetMapper; /** * Create new empty QuizSet with no quizzes inside @@ -45,19 +47,19 @@ public QuizSetResponse createQuizSet(QuizSetUpsertRequest request, OriginType or .originType(originType).build(); QuizSet savedQuizSet = quizSetRepository.save(quizSet); - return quizMapper.toQuizSetResponse(savedQuizSet); + return quizSetMapper.toQuizSetResponse(savedQuizSet); } /** * Create new QuizSet from multiple quizzes with their associated questions * @param quizSetTitle a title for new quiz set, use default name if null - * @param quizDTOList list of quizzes to be added together with new set + * @param quizUpsertDTOList list of quizzes to be added together with new set * @return response dto for saved quiz set */ - public QuizSetResponse createQuizSet(String quizSetTitle, List quizDTOList, OriginType originType) { + public QuizSetResponse createQuizSet(String quizSetTitle, List quizUpsertDTOList, OriginType originType) { int currentUserId = authorizationService.getCurrentUserId(); - List newQuizzes = quizMapper.toQuizList(quizDTOList); + List newQuizzes = quizMapper.toQuizList(quizUpsertDTOList); QuizSet quizSet = QuizSet.builder() .userId(currentUserId) .title(quizSetTitle != null ? quizSetTitle : AppConstants.DEFAULT_QUIZ_SET_TITLE) @@ -65,7 +67,7 @@ public QuizSetResponse createQuizSet(String quizSetTitle, List quizDTOL quizSet.addQuizzes(newQuizzes); QuizSet savedQuizSet = quizSetRepository.save(quizSet); - return quizMapper.toQuizSetResponse(savedQuizSet); + return quizSetMapper.toQuizSetResponse(savedQuizSet); } /** @@ -81,7 +83,7 @@ public QuizSetResponse getQuizSetById(int quizSetId) { return new AppException(ErrorCode.QUIZ_SET_NOT_FOUND); }); - return quizMapper.toQuizSetResponse(quiz); + return quizSetMapper.toQuizSetResponse(quiz); } /** @@ -92,7 +94,7 @@ public List getAllQuizSets() { int currentUserId = authorizationService.getCurrentUserId(); List quizSets = quizSetRepository.findAllByUserId(currentUserId); - return quizMapper.toQuizSetResponseList(quizSets); + return quizSetMapper.toQuizSetResponseList(quizSets); } /** @@ -108,9 +110,9 @@ public QuizSetResponse updateQuizSet(int quizSetId, QuizSetUpsertRequest request return new AppException(ErrorCode.QUIZ_SET_NOT_FOUND); }); - quizMapper.updateQuizSet(existingQuizSet, request); + quizSetMapper.updateQuizSet(existingQuizSet, request); existingQuizSet = quizSetRepository.save(existingQuizSet); - return quizMapper.toQuizSetResponse(existingQuizSet); + return quizSetMapper.toQuizSetResponse(existingQuizSet); } /** @@ -183,7 +185,7 @@ public QuizSet getQuizSetEntityById(int quizSetId) { * @param quizQuestion the quiz with questions to be added in new quiz set * @return the saved QuizSet */ -// public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, QuizDTO quizQuestion, OriginType originType) { +// public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, QuizUpsertDTO quizQuestion, OriginType originType) { // Quiz quiz = quizMapper.toQuiz(quizQuestion); // // QuizSet quizSet = QuizSet.builder() @@ -202,7 +204,7 @@ public QuizSet getQuizSetEntityById(int quizSetId) { * @param quizQuestionList list of quizzes with questions to be added in new quiz set * @return the saved QuizSet */ -// public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, List quizQuestionList, OriginType originType) { +// public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, List quizQuestionList, OriginType originType) { // List newQuizzes = quizMapper.toQuizList(quizQuestionList); // // QuizSet quizSet = QuizSet.builder() diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index 517cccf..fd8c588 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -7,7 +7,7 @@ import java.util.ArrayList; import java.util.List; -import com.be08.smart_notes.dto.QuizDTO; +import com.be08.smart_notes.dto.QuizUpsertDTO; import com.be08.smart_notes.dto.request.QuizGenerationRequest; import com.be08.smart_notes.dto.response.QuizResponse; import com.be08.smart_notes.dto.response.NoteResponse; @@ -79,9 +79,9 @@ public QuizResponse generateSampleQuiz() { ObjectMapper objectMapper = new ObjectMapper(); try { // Extract raw message content from response string - QuizDTO quizDTO = objectMapper.readValue(generatedContent, QuizDTO.class); + QuizUpsertDTO quizUpsertDTO = objectMapper.readValue(generatedContent, QuizUpsertDTO.class); - Quiz sampleQuizEntity = quizMapper.toQuiz(quizDTO); + Quiz sampleQuizEntity = quizMapper.toQuiz(quizUpsertDTO); return quizMapper.toQuizResponse(sampleQuizEntity); } catch (Exception e) { log.error("An error occurred when mapping objects, could not create sample quiz."); @@ -101,10 +101,10 @@ public QuizResponse generateQuiz(QuizGenerationRequest quizGenerationRequest) { int noteId = quizGenerationRequest.getDocId(); NoteResponse selectedNote = noteService.getNote(noteId); - QuizDTO quizDTO = generateQuizFromNote(selectedNote.getContent(), prompt); + QuizUpsertDTO quizUpsertDTO = generateQuizFromNote(selectedNote.getContent(), prompt); - quizDTO.setSourceDocumentId(noteId); - return quizService.createQuiz(quizDTO); + quizUpsertDTO.setSourceDocumentId(noteId); + return quizService.createQuiz(quizUpsertDTO); } public QuizSetResponse generateQuizSet(QuizGenerationRequest quizGenerationRequest) { @@ -120,28 +120,28 @@ public QuizSetResponse generateQuizSet(QuizGenerationRequest quizGenerationReque // Save quizzes from list of notes (generate one by one) List noteList = noteService.getAllNotesByIds(noteIds); - List quizDTOList = new ArrayList<>(); + List quizUpsertDTOList = new ArrayList<>(); for (Document note : noteList) { try { authorizationService.validateOwnership(note.getUserId()); - QuizDTO quizDTO = generateQuizFromNote(note.getContent(), prompt); - quizDTO.setSourceDocumentId(note.getId()); - quizDTOList.add(quizDTO); + QuizUpsertDTO quizUpsertDTO = generateQuizFromNote(note.getContent(), prompt); + quizUpsertDTO.setSourceDocumentId(note.getId()); + quizUpsertDTOList.add(quizUpsertDTO); } catch (Exception e) { System.out.println("Error in generating quiz: " + e.getMessage()); } } - if (quizDTOList.isEmpty()) { + if (quizUpsertDTOList.isEmpty()) { log.error("Quiz set was not created because no quizzes are generated."); throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } - return quizSetService.createQuizSet(AppConstants.DEFAULT_QUIZ_SET_TITLE, quizDTOList, OriginType.AI); + return quizSetService.createQuizSet(AppConstants.DEFAULT_QUIZ_SET_TITLE, quizUpsertDTOList, OriginType.AI); } // ------ Internal methods ------ // - private QuizDTO generateQuizFromNote(String noteContent, String prompt) { + private QuizUpsertDTO generateQuizFromNote(String noteContent, String prompt) { String generatedContent = aiService.generateContent( prompt, noteContent, @@ -154,17 +154,17 @@ private QuizDTO generateQuizFromNote(String noteContent, String prompt) { // Map generated content (JSON String) to Object ObjectMapper objectMapper = new ObjectMapper(); - QuizDTO quizDTO = null; + QuizUpsertDTO quizUpsertDTO = null; try { - quizDTO = objectMapper.readValue(generatedContent, QuizDTO.class); + quizUpsertDTO = objectMapper.readValue(generatedContent, QuizUpsertDTO.class); } catch (Exception e) { - log.error("Could not map generated content to QuizDTO."); + log.error("Could not map generated content to QuizUpsertDTO."); throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } - if (quizDTO == null) { + if (quizUpsertDTO == null) { log.error("Could not generate quiz because of invalid object mapping result."); throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); } - return quizDTO; + return quizUpsertDTO; } } From 461565ac7cb574b4e862d319392cecd564eb269c Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Fri, 7 Nov 2025 16:37:29 +1100 Subject: [PATCH 30/63] docs(ai-quiz): add Javadoc comments and remove redundant code/comments --- .../smart_notes/config/RestClientConfig.java | 1 - .../be08/smart_notes/exception/ErrorCode.java | 12 ++-- .../be08/smart_notes/service/QuizService.java | 30 +++++++- .../smart_notes/service/QuizSetService.java | 69 ++++--------------- .../smart_notes/service/ai/AIService.java | 14 +++- .../service/ai/QuizGenerationService.java | 23 +++++++ 6 files changed, 82 insertions(+), 67 deletions(-) diff --git a/src/main/java/com/be08/smart_notes/config/RestClientConfig.java b/src/main/java/com/be08/smart_notes/config/RestClientConfig.java index 3dfbaa3..6a37a7f 100644 --- a/src/main/java/com/be08/smart_notes/config/RestClientConfig.java +++ b/src/main/java/com/be08/smart_notes/config/RestClientConfig.java @@ -4,7 +4,6 @@ import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.MediaType; diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java index fb1a70b..de7f856 100644 --- a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -46,12 +46,14 @@ public enum ErrorCode { // 24xx - Quiz-related features QUIZ_NOT_FOUND(2401, "Quiz not found", HttpStatus.NOT_FOUND), QUIZ_DOCUMENT_SIZE_EXCEED(2402, "Number of quiz IDs must be between 1 and 5", HttpStatus.BAD_REQUEST), - INVALID_QUIZ_SIZE(2404, "Total number of questions must between 1 and 20", HttpStatus.BAD_REQUEST), + INVALID_QUIZ_SIZE(2403, "Total number of questions must between 1 and 20", HttpStatus.BAD_REQUEST), - // 25xx - Quiz-related features - QUIZ_SET_NOT_FOUND(2501, "Quiz set not found", HttpStatus.NOT_FOUND), - QUIZ_SET_ID_REQUIRED(2502, "Quiz set ID required", HttpStatus.BAD_REQUEST), - QUIZ_SET_TITLE_REQUIRED(2502, "Quiz set title required", HttpStatus.BAD_REQUEST), + + + // 27xx - Quiz-related features + QUIZ_SET_NOT_FOUND(2701, "Quiz set not found", HttpStatus.NOT_FOUND), + QUIZ_SET_ID_REQUIRED(2702, "Quiz set ID required", HttpStatus.BAD_REQUEST), + QUIZ_SET_TITLE_REQUIRED(2703, "Quiz set title required", HttpStatus.BAD_REQUEST), ; int code; diff --git a/src/main/java/com/be08/smart_notes/service/QuizService.java b/src/main/java/com/be08/smart_notes/service/QuizService.java index 2e78f9e..145d6c0 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizService.java @@ -24,17 +24,31 @@ public class QuizService { QuizRepository quizRepository; QuizMapper quizMapper; + /** + * Create new quiz based on given information. + * If the request does not include quiz set id, new quiz will be added to DEFAULT set + * @param quizUpsertDTO a dto with quiz information and its questions + * @return quiz response dto + */ public QuizResponse createQuiz(QuizUpsertDTO quizUpsertDTO) { - int currentUserId = authorizationService.getCurrentUserId(); + int quizSetId = quizUpsertDTO.getQuizSetId(); + QuizSet existingQuizSet = quizSetService.getQuizSetEntityById(quizSetId); + if (existingQuizSet == null) { + existingQuizSet = quizSetService.getOrCreateDefaultSet(); + } - QuizSet defaultSet = quizSetService.getOrCreateDefaultSet(currentUserId); Quiz newQuiz = quizMapper.toQuiz(quizUpsertDTO); - newQuiz.setQuizSet(defaultSet); + newQuiz.setQuizSet(existingQuizSet); Quiz savedQuiz = quizRepository.save(newQuiz); return quizMapper.toQuizResponse(savedQuiz); } + /** + * Get quiz and its questions based on given id + * @param quizId id of target quiz + * @return quiz response dto + */ public QuizResponse getQuizById(int quizId) { int currentUserId = authorizationService.getCurrentUserId(); @@ -46,6 +60,12 @@ public QuizResponse getQuizById(int quizId) { return quizMapper.toQuizResponse(quiz); } + /** + * Update quiz information + * @param quizId id of target quiz + * @param quizUpsertDTO dto that contains required data + * @return quiz response dto + */ public QuizResponse updateQuiz(int quizId, QuizUpsertDTO quizUpsertDTO) { int currentUserId = authorizationService.getCurrentUserId(); @@ -64,6 +84,10 @@ public QuizResponse updateQuiz(int quizId, QuizUpsertDTO quizUpsertDTO) { return quizMapper.toQuizResponse(existingQuiz); } + /** + * Delete quiz based on given id + * @param quizId id of target quiz + */ public void deleteQuizById(int quizId) { int currentUserId = authorizationService.getCurrentUserId(); diff --git a/src/main/java/com/be08/smart_notes/service/QuizSetService.java b/src/main/java/com/be08/smart_notes/service/QuizSetService.java index f93af6e..9ffc3f4 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizSetService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizSetService.java @@ -140,20 +140,26 @@ public void deleteAllQuizSet() { // ------ Methods that returns entities ------ // /** - * Get or create a default set if not exist for current user. Default set should only unique for each user - * @param userId id of current user - * @return default quiz set + * Get or create a default set if not exist for current user. Default set is unique for each user + * @return default QuizSet entity */ - public QuizSet getOrCreateDefaultSet(int userId) { - return quizSetRepository.findByUserIdAndOriginType(userId, OriginType.DEFAULT).orElseGet(() -> { + public QuizSet getOrCreateDefaultSet() { + int currentUserId = authorizationService.getCurrentUserId(); + + return quizSetRepository.findByUserIdAndOriginType(currentUserId, OriginType.DEFAULT).orElseGet(() -> { QuizSet defaultSet = QuizSet.builder() - .userId(userId) + .userId(currentUserId) .title(AppConstants.DEFAULT_QUIZ_SET_TITLE) .originType(OriginType.DEFAULT).build(); return quizSetRepository.save(defaultSet); }); } + /** + * Get quiz set entity by quiz set id + * @param quizSetId id of quiz set + * @return found QuizSet entity + */ public QuizSet getQuizSetEntityById(int quizSetId) { int currentUserId = authorizationService.getCurrentUserId(); @@ -164,55 +170,4 @@ public QuizSet getQuizSetEntityById(int quizSetId) { return quizSet; } - - /** - * Save new quiz to default quiz set (uncategorized) - * @param userId id of current user - * @param quiz new quiz to be added - * @return default quiz set with new quiz added - */ -// public QuizSet saveNewQuizToDefaultSet(int userId, Quiz quiz) { -// QuizSet defaultSet = getOrCreateDefaultSet(userId); -// -// defaultSet.addQuiz(quiz); -// return quizSetRepository.save(defaultSet); -// } - - /** - * Create and save new QuizSet from a quiz with associated questions - * @param userId id of current user - * @param quizSetTitle title for new quiz set, use quiz title as alternative when null - * @param quizQuestion the quiz with questions to be added in new quiz set - * @return the saved QuizSet - */ -// public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, QuizUpsertDTO quizQuestion, OriginType originType) { -// Quiz quiz = quizMapper.toQuiz(quizQuestion); -// -// QuizSet quizSet = QuizSet.builder() -// .userId(userId) -// .title(quizSetTitle != null ? quizSetTitle : quizQuestion.getTitle()) -// .originType(originType).build(); -// quizSet.addQuiz(quiz); -// -// return quizSetRepository.save(quizSet); -// } - - /** - * Create and save new QuizSet from a list of quizzes with their associated questions - * @param userId id of current user - * @param quizSetTitle title for new quiz set, use default title as alternative when null - * @param quizQuestionList list of quizzes with questions to be added in new quiz set - * @return the saved QuizSet - */ -// public QuizSet saveAsNewQuizSetEntity(int userId, String quizSetTitle, List quizQuestionList, OriginType originType) { -// List newQuizzes = quizMapper.toQuizList(quizQuestionList); -// -// QuizSet quizSet = QuizSet.builder() -// .userId(userId) -// .title(quizSetTitle != null ? quizSetTitle : AppConstants.DEFAULT_QUIZ_SET_TITLE) -// .originType(originType).build(); -// quizSet.addQuizzes(newQuizzes); -// -// return quizSetRepository.save(quizSet); -// } } diff --git a/src/main/java/com/be08/smart_notes/service/ai/AIService.java b/src/main/java/com/be08/smart_notes/service/ai/AIService.java index b4ba660..bdb267f 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/AIService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/AIService.java @@ -23,6 +23,13 @@ public AIService(AIInferenceProperties properties, RestClient restClient) { this.restClient = restClient; } + /** + * Generate content using REST API from an AI Inference Provider, using chat completion model + * @param systemPrompt system prompt to guide AI model how to generate data + * @param noteContent content of a given note + * @param guidedSchema expected JSON response schema from inference API + * @return response message from AI assistant, should be a JSON string that follows the given schema structure + */ public String generateContent(String systemPrompt, String noteContent, String guidedSchema) { if (properties.isMissingCredentials() || properties.isMissingModelConfig()) { log.error("AI Inference Configuration is missing or invalid. Please check your .env file and try again"); @@ -46,6 +53,11 @@ public String generateContent(String systemPrompt, String noteContent, String gu return inferenceResponse.getChoices()[0].getMessage().getContent(); } + /** + * Send request inference request to provider using RestClient + * @param info the inference request dto to be included as body + * @return inference response from the provider + */ private AIInferenceResponse fetchResponseFromInferenceProvider(AIInferenceRequest info) { AIInferenceResponse response = restClient.post() .body(info) @@ -54,7 +66,7 @@ private AIInferenceResponse fetchResponseFromInferenceProvider(AIInferenceReques return response; } - // Unused methods, will debug later + // Unused method that cause incorrect response due to incompatible JSON string format, will debug later // private String generateGuidedSchema(Class ClassType) { // SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON); // diff --git a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java index fd8c588..2f12001 100644 --- a/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java +++ b/src/main/java/com/be08/smart_notes/service/ai/QuizGenerationService.java @@ -71,6 +71,10 @@ public QuizGenerationService(AIService aiService, NoteService noteService, QuizS quizResponseSchema = schema; } + /** + * A sample function to retrieve a quiz for testing purposes, it will neither interact with the AI provider nor save data to the database. + * @return quiz response dto + */ public QuizResponse generateSampleQuiz() { // Below is sample generated content String generatedContent = "{\"topic\":\"Object-Oriented Programming Concepts\",\"questions\":[{\"question\":\"What is the main purpose of Object-Oriented Programming (OOP)?\",\"options\":[\"A. To simplify data structures\",\"B. To organize code around objects\",\"C. To create complex algorithms\",\"D. To optimize code execution speed\"], \"correct_index\": 1}, {\"question\":\"Which of the following best describes encapsulation in OOP?\",\"options\":[\"A. Hiding internal data and exposing only necessary information\",\"B. Creating multiple objects from a single class\",\"C. Passing data between different classes\",\"D. Defining the structure of a class\",\"\"], \"correct_index\": 1}, {\"question\":\"What is the primary function of a constructor in OOP?\",\"options\":[\"A. To delete an object from memory\",\"B. To store data for an object\",\"C. To initialize an object when it is created\",\"D. To define the behavior of an object\",\"\"], \"correct_index\": 3}, {\"question\":\"How does inheritance work in OOP?\",\"options\":[\"A. It allows objects to inherit properties and methods from other objects\",\"B. It creates a new class based on an existing one and adds new features\",\"C. It allows objects to access private members of other objects\",\"D. It enables objects to communicate with each other through messages\",\"\"], \"correct_index\": 1}, {\"question\":\"What does polymorphism refer to in OOP?\",\"options\":[\"A. The ability of an object to be accessed from multiple classes\",\"B. The ability of an object to behave differently based on its context\",\"C. The ability of an object to be used in different programming languages\",\"D. The ability of an object to be inherited from other objects\",\"\"], \"correct_index\": 1}, {\"question\":\"Which of the following is NOT a benefit of OOP?\",\"options\":[\"A. Improved code reusability\",\"B. Easier code maintenance\",\"C. Increased program complexity\",\"D. Enhanced code readability\",\"\"], \"correct_index\": 3}, {\"question\":\"What is the primary difference between a class and an object?\",\"options\":[\"A. A class is a blueprint for creating objects, while an object is an instance of that blueprint\",\"B. A class is a data structure, while an object is a programming language\",\"C. A class is a variable, while an object is a function\",\"D. A class is a method, while an object is a program\",\"\"], \"correct_index\": 1}, {\"question\":\"What is the main purpose of a static method?\",\"options\":[\"A. To define a method that is specific to a particular object\",\"B. To define a method that belongs to a class and not to individual objects\",\"C. To define a method that is called when an object is created\",\"D. To define a method that is called when an object is destroyed\",\"\"], \"correct_index\": 1}, {\"question\":\"Which of the following is an example of a common mistake to avoid in OOP?\",\"options\":[\"A. Using inheritance when it is not needed\",\"B. Using public access modifiers for every method\",\"C. Using static methods for every method\",\"D. Creating complex objects that are not needed\",\"\"], \"correct_index\": 1}, {\"question\":\"What is the purpose of an interface in OOP?\",\"options\":[\"A. To define the behavior of a class\",\"B. To create a contract that classes must follow\",\"C. To define the structure of a class\",\"D. To create a blueprint for creating objects\",\"\"], \"correct_index\": 1}, {\"question\":\"What is the purpose of a method overriding?\",\"options\":[\"A. To create a new class that is based on an existing one\",\"B. To define a new method with a different implementation in a child class\",\"C. To create a new method that overrides the behavior of a parent class\",\"D. To create a new class that inherits from a different class\",\"\"], \"correct_index\": 3}]}\n"; @@ -89,6 +93,12 @@ public QuizResponse generateSampleQuiz() { } } + /** + * Generate a quiz based on a single note using AI Inference API. + * The generated quiz will be listed in user's DEFAULT set. + * @param quizGenerationRequest a request dto, should include a document id (docId) inside the request + * @return quiz response dto, generated by AI + */ public QuizResponse generateQuiz(QuizGenerationRequest quizGenerationRequest) { if (this.systemPrompt == null || this.quizResponseSchema == null) { log.error("Could not generate quiz because of invalid system prompt and/or guided schema."); @@ -107,6 +117,13 @@ public QuizResponse generateQuiz(QuizGenerationRequest quizGenerationRequest) { return quizService.createQuiz(quizUpsertDTO); } + /** + * Generate a quiz based on multiple notes using AI Inference API. + * The generated quizzes will be grouped in new quiz set with default title. + * The method process the document(s) one by one, document(s) that are not owned by user will be ignored and skipped. + * @param quizGenerationRequest a request dto, should include list of document ids (docIds) inside the request + * @return quiz response dto, generated by AI + */ public QuizSetResponse generateQuizSet(QuizGenerationRequest quizGenerationRequest) { if (this.systemPrompt == null || this.quizResponseSchema == null) { log.error("Could not generate quiz because of invalid system prompt and/or guided schema."); @@ -141,6 +158,12 @@ public QuizSetResponse generateQuizSet(QuizGenerationRequest quizGenerationReque } // ------ Internal methods ------ // + /** + * Internal method that interact with AIService and process response for quiz generation purpose + * @param noteContent the content to be sent for generating quiz + * @param prompt the system prompt to guide the AI + * @return a quiz dto that can be used to create new quiz + */ private QuizUpsertDTO generateQuizFromNote(String noteContent, String prompt) { String generatedContent = aiService.generateContent( prompt, From de33a7f3bebb823057b15f753878e465b1101ca3 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Fri, 7 Nov 2025 17:08:21 +1100 Subject: [PATCH 31/63] refactor(ai-quiz): add default access level for quiz-related classes --- .../be08/smart_notes/dto/QuizUpsertDTO.java | 18 +++++++---- .../dto/ai/AIInferenceRequest.java | 22 ++++++------- .../dto/ai/AIInferenceResponse.java | 19 +++++++---- .../dto/request/QuizGenerationRequest.java | 13 ++++---- .../dto/request/QuizSetUpsertRequest.java | 5 ++- .../dto/response/QuizResponse.java | 32 +++++++++++-------- .../dto/response/QuizSetResponse.java | 13 +++++--- .../com/be08/smart_notes/model/Question.java | 23 +++++++------ .../java/com/be08/smart_notes/model/Quiz.java | 24 +++++++------- .../com/be08/smart_notes/model/QuizSet.java | 21 ++++++------ .../be08/smart_notes/service/QuizService.java | 7 ++-- 11 files changed, 108 insertions(+), 89 deletions(-) diff --git a/src/main/java/com/be08/smart_notes/dto/QuizUpsertDTO.java b/src/main/java/com/be08/smart_notes/dto/QuizUpsertDTO.java index 61b2ea7..396eac8 100644 --- a/src/main/java/com/be08/smart_notes/dto/QuizUpsertDTO.java +++ b/src/main/java/com/be08/smart_notes/dto/QuizUpsertDTO.java @@ -4,48 +4,52 @@ import com.be08.smart_notes.validation.group.OnUpdate; import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.*; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.experimental.FieldDefaults; import java.util.List; @Data @NoArgsConstructor @AllArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) public class QuizUpsertDTO { - private Integer sourceDocumentId; + Integer sourceDocumentId; // If null in creation request, new quiz will be categorised in default set @NotNull(groups = OnUpdate.class, message = "QUIZ_SET_ID_REQUIRED") - private Integer quizSetId; + Integer quizSetId; @NotEmpty(groups = {OnCreate.class, OnUpdate.class}, message = "QUIZ_TITLE_REQUIRED") @JsonProperty(value = "topic") - private String title; + String title; @NotEmpty(groups = OnCreate.class) @JsonProperty(value = "questions") - private List questions; + List questions; // Static nested class @Data @NoArgsConstructor @AllArgsConstructor + @FieldDefaults(level = AccessLevel.PRIVATE) public static class Question { @NotNull @JsonProperty(value = "question") - private String questionText; + String questionText; @NotNull @Size(min = 4, max = 4) @JsonProperty(value = "options") - private String[] options; + String[] options; @NotNull @Min(0) @Max(3) @JsonProperty(value = "correct_index") - private Integer correctIndex; + Integer correctIndex; } } diff --git a/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java b/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java index 7234855..f18b797 100644 --- a/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java +++ b/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java @@ -1,31 +1,31 @@ package com.be08.smart_notes.dto.ai; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; +import lombok.experimental.FieldDefaults; @Data @AllArgsConstructor @Builder +@FieldDefaults(level = AccessLevel.PRIVATE) public class AIInferenceRequest { - private String model; - private RequestMessage[] messages; - private double temperature; + String model; + RequestMessage[] messages; + double temperature; @JsonProperty("top_p") - private double topP; + double topP; @JsonProperty("guided_json") - private String guidedJson; + String guidedJson; // Static nested class @Data @NoArgsConstructor @AllArgsConstructor + @FieldDefaults(level = AccessLevel.PRIVATE) public static class RequestMessage { - private String role; - private String content; + String role; + String content; } } diff --git a/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceResponse.java b/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceResponse.java index bcd900e..6998121 100644 --- a/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceResponse.java @@ -1,27 +1,32 @@ package com.be08.smart_notes.dto.ai; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.experimental.FieldDefaults; @Data @AllArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) public class AIInferenceResponse { - private String id; - private String model; - private String object; - private ResponseChoice[] choices; + String id; + String model; + String object; + ResponseChoice[] choices; // Static nested class @Data @AllArgsConstructor + @FieldDefaults(level = AccessLevel.PRIVATE) public static class ResponseChoice { - private ResponseMessage message; + ResponseMessage message; @Data @AllArgsConstructor + @FieldDefaults(level = AccessLevel.PRIVATE) public static class ResponseMessage { - private String content; - private String role; + String content; + String role; } } } diff --git a/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java b/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java index 810285c..eb6339b 100644 --- a/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java +++ b/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java @@ -3,10 +3,8 @@ import com.be08.smart_notes.validation.group.MultipleDocument; import com.be08.smart_notes.validation.group.SingleDocument; import jakarta.validation.constraints.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; +import lombok.experimental.FieldDefaults; import java.util.List; @@ -14,16 +12,17 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@FieldDefaults(level = AccessLevel.PRIVATE) public class QuizGenerationRequest { @NotNull(groups = SingleDocument.class, message = "DOCUMENT_ID_REQUIRED") - private Integer docId; + Integer docId; @NotNull(groups = MultipleDocument.class, message = "DOCUMENT_IDS_REQUIRED") @Size(min = 1, max = 5, message = "QUIZ_DOCUMENT_SIZE_EXCEED") - private List docIds; + List docIds; @Builder.Default @Min(value = 1, message = "INVALID_QUIZ_SIZE") @Max(value = 20, message = "INVALID_QUIZ_SIZE") - private Integer sizeOfEachQuiz = 10; + Integer sizeOfEachQuiz = 10; } diff --git a/src/main/java/com/be08/smart_notes/dto/request/QuizSetUpsertRequest.java b/src/main/java/com/be08/smart_notes/dto/request/QuizSetUpsertRequest.java index 7a556dd..98006e6 100644 --- a/src/main/java/com/be08/smart_notes/dto/request/QuizSetUpsertRequest.java +++ b/src/main/java/com/be08/smart_notes/dto/request/QuizSetUpsertRequest.java @@ -1,14 +1,17 @@ package com.be08.smart_notes.dto.request; import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.experimental.FieldDefaults; @Data @NoArgsConstructor @AllArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) public class QuizSetUpsertRequest { @NotBlank(message = "QUIZ_SET_TITLE_REQUIRED") - private String title; + String title; } diff --git a/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java index 68731fb..15628e0 100644 --- a/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java @@ -5,46 +5,50 @@ import com.be08.smart_notes.dto.view.Level; import com.fasterxml.jackson.annotation.JsonView; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.experimental.FieldDefaults; @Data @NoArgsConstructor @AllArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) public class QuizResponse { @JsonView(Level.Basic.class) - private Integer id; + Integer id; @JsonView(Level.Basic.class) - private String title; + String title; @JsonView(Level.Basic.class) - private Integer quizSetId; + Integer quizSetId; @JsonView(Level.Basic.class) - private Integer sourceDocumentId; + Integer sourceDocumentId; @JsonView(Level.Basic.class) - private LocalDateTime createdAt; + LocalDateTime createdAt; @JsonView(Level.Basic.class) - private LocalDateTime updatedAt; + LocalDateTime updatedAt; @JsonView(Level.Detail.class) - private List questions; + List questions; // Static nested class @Data @NoArgsConstructor @AllArgsConstructor + @FieldDefaults(level = AccessLevel.PRIVATE) public static class Question { - private Integer id; - private String questionText; - private String optionA; - private String optionB; - private String optionC; - private String optionD; - private String correctAnswer; + Integer id; + String questionText; + String optionA; + String optionB; + String optionC; + String optionD; + String correctAnswer; } } diff --git a/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java b/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java index f408268..c481e69 100644 --- a/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java @@ -2,9 +2,11 @@ import com.be08.smart_notes.dto.view.Level; import com.fasterxml.jackson.annotation.JsonView; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.experimental.FieldDefaults; import java.time.LocalDateTime; import java.util.List; @@ -12,19 +14,20 @@ @Data @NoArgsConstructor @AllArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) public class QuizSetResponse { @JsonView(Level.Basic.class) - private Integer id; + Integer id; @JsonView(Level.Basic.class) - private String title; + String title; @JsonView(Level.Basic.class) - private LocalDateTime createdAt; + LocalDateTime createdAt; @JsonView(Level.Basic.class) - private LocalDateTime updatedAt; + LocalDateTime updatedAt; @JsonView(Level.Detail.class) - private List quizzes; + List quizzes; } diff --git a/src/main/java/com/be08/smart_notes/model/Question.java b/src/main/java/com/be08/smart_notes/model/Question.java index b4cffb0..f3643b7 100644 --- a/src/main/java/com/be08/smart_notes/model/Question.java +++ b/src/main/java/com/be08/smart_notes/model/Question.java @@ -2,10 +2,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; +import lombok.experimental.FieldDefaults; @Data @NoArgsConstructor @@ -13,32 +11,33 @@ @Builder @Entity @Table(name = "question") +@FieldDefaults(level = AccessLevel.PRIVATE) public class Question { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Integer id; + Integer id; @Column(nullable = false, name = "question_text") - private String questionText; + String questionText; @Column(nullable = false, name = "option_a") - private String optionA; + String optionA; @Column(nullable = false, name = "option_b") - private String optionB; + String optionB; @Column(nullable = false, name = "option_c") - private String optionC; + String optionC; @Column(nullable = false, name = "option_d") - private String optionD; + String optionD; @Column(nullable = false, name = "correct_answer") - private Character correctAnswer; + Character correctAnswer; // Relationship @JsonIgnore @ManyToOne @JoinColumn(name = "quiz_id", referencedColumnName = "id") - private Quiz quiz; + Quiz quiz; } diff --git a/src/main/java/com/be08/smart_notes/model/Quiz.java b/src/main/java/com/be08/smart_notes/model/Quiz.java index 2b84a43..ab71f3b 100644 --- a/src/main/java/com/be08/smart_notes/model/Quiz.java +++ b/src/main/java/com/be08/smart_notes/model/Quiz.java @@ -2,10 +2,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; +import lombok.experimental.FieldDefaults; import java.time.LocalDateTime; import java.util.List; @@ -16,32 +14,33 @@ @Builder @Entity @Table(name = "quiz") +@FieldDefaults(level = AccessLevel.PRIVATE) public class Quiz { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Integer id; + Integer id; @Column(nullable = false, name = "created_at") - private LocalDateTime createdAt; + LocalDateTime createdAt; @Column(nullable = false, name = "updated_at") - private LocalDateTime updatedAt; + LocalDateTime updatedAt; @Column(nullable = false) - private String title; + String title; @Column(name = "source_document_id") - private Integer sourceDocumentId; + Integer sourceDocumentId; // Relationship: quiz - quiz set @JsonIgnore @ManyToOne @JoinColumn(name = "quiz_set_id", referencedColumnName = "id") - private QuizSet quizSet; + QuizSet quizSet; // Relationship: quiz - question @OneToMany(mappedBy = "quiz", cascade = CascadeType.ALL, orphanRemoval = true) - private List questions; + List questions; @PrePersist protected void onCreate() { @@ -49,7 +48,8 @@ protected void onCreate() { this.updatedAt = LocalDateTime.now(); } - @PreUpdate void onUpdate() { + @PreUpdate + protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } } diff --git a/src/main/java/com/be08/smart_notes/model/QuizSet.java b/src/main/java/com/be08/smart_notes/model/QuizSet.java index 0755d14..b90ea88 100644 --- a/src/main/java/com/be08/smart_notes/model/QuizSet.java +++ b/src/main/java/com/be08/smart_notes/model/QuizSet.java @@ -2,10 +2,8 @@ import com.be08.smart_notes.enums.OriginType; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; +import lombok.experimental.FieldDefaults; import java.time.LocalDateTime; import java.util.ArrayList; @@ -16,30 +14,31 @@ @AllArgsConstructor @Builder @Entity(name = "quiz_set") +@FieldDefaults(level = AccessLevel.PRIVATE) public class QuizSet { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Integer id; + Integer id; @Column(nullable = false, name = "user_id") - private Integer userId; + Integer userId; @Column(nullable = false, name = "title") - private String title; + String title; @Column(nullable = false, name = "origin_type") @Enumerated(EnumType.STRING) - private OriginType originType; + OriginType originType; @Column(nullable = false, name = "created_at") - private LocalDateTime createdAt; + LocalDateTime createdAt; @Column(nullable = false, name = "updated_at") - private LocalDateTime updatedAt; + LocalDateTime updatedAt; // Relationship @OneToMany(mappedBy = "quizSet", cascade = CascadeType.ALL, orphanRemoval = true) - private List quizzes; + List quizzes; public void addQuiz(Quiz quiz){ if (this.quizzes == null) { diff --git a/src/main/java/com/be08/smart_notes/service/QuizService.java b/src/main/java/com/be08/smart_notes/service/QuizService.java index 145d6c0..d84fa8b 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizService.java @@ -31,8 +31,11 @@ public class QuizService { * @return quiz response dto */ public QuizResponse createQuiz(QuizUpsertDTO quizUpsertDTO) { - int quizSetId = quizUpsertDTO.getQuizSetId(); - QuizSet existingQuizSet = quizSetService.getQuizSetEntityById(quizSetId); + Integer quizSetId = quizUpsertDTO.getQuizSetId(); + QuizSet existingQuizSet = null; + if (quizSetId != null) { + existingQuizSet = quizSetService.getQuizSetEntityById(quizSetId); + } if (existingQuizSet == null) { existingQuizSet = quizSetService.getOrCreateDefaultSet(); } From e9f854e67c4755de62e157059fc614950a170317 Mon Sep 17 00:00:00 2001 From: Phu Vo Date: Fri, 7 Nov 2025 17:13:46 +1000 Subject: [PATCH 32/63] :hammer: feat(flashcard): Implement basic CRUD Operations on Flashcard + Flashcard Set --- .../controller/FlashcardController.java | 58 ++++++ .../controller/FlashcardSetController.java | 113 ++++++++++++ .../dto/request/FlashcardCreationRequest.java | 24 +++ .../request/FlashcardSetCreationRequest.java | 17 ++ .../dto/response/FlashcardResponse.java | 21 +++ .../dto/response/FlashcardSetResponse.java | 21 +++ .../be08/smart_notes/enums/OriginType.java | 7 + .../be08/smart_notes/exception/ErrorCode.java | 17 ++ .../smart_notes/mapper/FlashcardMapper.java | 31 ++++ .../mapper/FlashcardSetMapper.java | 30 ++++ .../com/be08/smart_notes/model/Flashcard.java | 51 ++++++ .../be08/smart_notes/model/FlashcardSet.java | 53 ++++++ .../repository/DocumentRepository.java | 5 + .../repository/FlashcardRepository.java | 26 +++ .../repository/FlashcardSetRepository.java | 41 +++++ .../smart_notes/service/DocumentService.java | 30 ++++ .../smart_notes/service/FlashcardService.java | 165 ++++++++++++++++++ .../service/FlashcardSetService.java | 150 ++++++++++++++++ .../be08/smart_notes/service/UserService.java | 15 ++ 19 files changed, 875 insertions(+) create mode 100644 src/main/java/com/be08/smart_notes/controller/FlashcardController.java create mode 100644 src/main/java/com/be08/smart_notes/controller/FlashcardSetController.java create mode 100644 src/main/java/com/be08/smart_notes/dto/request/FlashcardCreationRequest.java create mode 100644 src/main/java/com/be08/smart_notes/dto/request/FlashcardSetCreationRequest.java create mode 100644 src/main/java/com/be08/smart_notes/dto/response/FlashcardResponse.java create mode 100644 src/main/java/com/be08/smart_notes/dto/response/FlashcardSetResponse.java create mode 100644 src/main/java/com/be08/smart_notes/enums/OriginType.java create mode 100644 src/main/java/com/be08/smart_notes/mapper/FlashcardMapper.java create mode 100644 src/main/java/com/be08/smart_notes/mapper/FlashcardSetMapper.java create mode 100644 src/main/java/com/be08/smart_notes/model/Flashcard.java create mode 100644 src/main/java/com/be08/smart_notes/model/FlashcardSet.java create mode 100644 src/main/java/com/be08/smart_notes/repository/FlashcardRepository.java create mode 100644 src/main/java/com/be08/smart_notes/repository/FlashcardSetRepository.java create mode 100644 src/main/java/com/be08/smart_notes/service/FlashcardService.java create mode 100644 src/main/java/com/be08/smart_notes/service/FlashcardSetService.java diff --git a/src/main/java/com/be08/smart_notes/controller/FlashcardController.java b/src/main/java/com/be08/smart_notes/controller/FlashcardController.java new file mode 100644 index 0000000..95c36a3 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/controller/FlashcardController.java @@ -0,0 +1,58 @@ +package com.be08.smart_notes.controller; + +import com.be08.smart_notes.dto.request.FlashcardCreationRequest; +import com.be08.smart_notes.dto.response.ApiResponse; +import com.be08.smart_notes.dto.response.FlashcardResponse; +import com.be08.smart_notes.service.FlashcardService; +import jakarta.validation.Valid; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/flashcards") +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class FlashcardController { + FlashcardService flashcardService; + + @PostMapping + public ApiResponse createFlashcard(@RequestBody FlashcardCreationRequest request){ + FlashcardResponse response = flashcardService.createFlashcard(request); + + return ApiResponse.builder() + .message("Flashcard created successfully!") + .data(response) + .build(); + } + + @GetMapping("/{flashcardId}") + public ApiResponse getFlashcard(@PathVariable int flashcardId){ + FlashcardResponse response = flashcardService.getFlashcardById(flashcardId); + + return ApiResponse.builder() + .message("Flashcard retrieved successfully!") + .data(response) + .build(); + } + + @PutMapping("/{flashcardId}") + public ApiResponse updateFlashcard(@PathVariable int flashcardId, @RequestBody @Valid FlashcardCreationRequest request){ + FlashcardResponse response = flashcardService.updateFlashcard(flashcardId, request); + + return ApiResponse.builder() + .message("Flashcard updated successfully!") + .data(response) + .build(); + } + + @DeleteMapping("/{flashcardId}") + public ApiResponse deleteFlashcard(@PathVariable int flashcardId){ + flashcardService.deleteFlashcard(flashcardId); + + return ApiResponse.builder() + .message("Flashcard deleted successfully!") + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/controller/FlashcardSetController.java b/src/main/java/com/be08/smart_notes/controller/FlashcardSetController.java new file mode 100644 index 0000000..b6fc92c --- /dev/null +++ b/src/main/java/com/be08/smart_notes/controller/FlashcardSetController.java @@ -0,0 +1,113 @@ +package com.be08.smart_notes.controller; + +import com.be08.smart_notes.dto.request.FlashcardSetCreationRequest; +import com.be08.smart_notes.dto.response.ApiResponse; +import com.be08.smart_notes.dto.response.FlashcardResponse; +import com.be08.smart_notes.dto.response.FlashcardSetResponse; +import com.be08.smart_notes.service.FlashcardService; +import com.be08.smart_notes.service.FlashcardSetService; +import jakarta.validation.Valid; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/flashcard-sets") +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class FlashcardSetController { + FlashcardSetService flashcardSetService; + FlashcardService flashcardService; + + /** + * Create a new flashcard set + * @param request FlashcardSetCreationRequest + * @return ApiResponse containing FlashcardSetResponse + */ + @PostMapping + public ApiResponse createFlashcardSet(@RequestBody @Valid FlashcardSetCreationRequest request){ + FlashcardSetResponse response = flashcardSetService.createFlashcardSet(request); + + return ApiResponse.builder() + .message("Flashcard set created successfully!") + .data(response) + .build(); + } + + /** + * Get all flashcard sets for the current user + * @return ApiResponse containing list of FlashcardSetResponse + */ + @GetMapping + public ApiResponse> getAllFlashcardSets(){ + List response = flashcardSetService.getAllFlashcardSets(); + + return ApiResponse.>builder() + .message("Flashcard sets retrieved successfully!") + .data(response) + .build(); + } + + /** + * Get a flashcard set by its ID + * @param flashcardSetId ID of the flashcard set + * @return ApiResponse containing FlashcardSetResponse + */ + @GetMapping("/{flashcardSetId}") + public ApiResponse getFlashcardSet(@PathVariable int flashcardSetId){ + FlashcardSetResponse response = flashcardSetService.getFlashcardSet(flashcardSetId); + + return ApiResponse.builder() + .message("Flashcard set retrieved successfully!") + .data(response) + .build(); + } + + /** + * Get all flashcards in a specific flashcard set + * @param flashcardSetId ID of the flashcard set + * @return ApiResponse containing list of FlashcardResponse + */ + @GetMapping("/{flashcardSetId}/flashcards") + public ApiResponse> getFlashcardsBySet(@PathVariable int flashcardSetId){ + List response = flashcardService.getFlashcardsBySetId(flashcardSetId); + + return ApiResponse.>builder() + .message("Flashcards retrieved successfully!") + .data(response) + .build(); + } + + /** + * Update a flashcard set + * @param flashcardSetId ID of the flashcard set + * @param request FlashcardSetCreationRequest + * @return ApiResponse containing updated FlashcardSetResponse + */ + @PutMapping("/{flashcardSetId}") + public ApiResponse updateFlashcardSet(@PathVariable int flashcardSetId, @RequestBody @Valid FlashcardSetCreationRequest request){ + FlashcardSetResponse response = flashcardSetService.updateFlashcardSet(flashcardSetId, request); + + return ApiResponse.builder() + .message("Flashcard set updated successfully!") + .data(response) + .build(); + } + + /** + * Delete a flashcard set + * @param flashcardSetId ID of the flashcard set + * @return ApiResponse with deletion confirmation + */ + @DeleteMapping("/{flashcardSetId}") + public ApiResponse deleteFlashcardSet(@PathVariable int flashcardSetId){ + flashcardSetService.deleteFlashcardSet(flashcardSetId); + + return ApiResponse.builder() + .message("Flashcard set deleted successfully!") + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/dto/request/FlashcardCreationRequest.java b/src/main/java/com/be08/smart_notes/dto/request/FlashcardCreationRequest.java new file mode 100644 index 0000000..14ec8cd --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/request/FlashcardCreationRequest.java @@ -0,0 +1,24 @@ +package com.be08.smart_notes.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import lombok.experimental.FieldDefaults; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class FlashcardCreationRequest { + Integer flashcardSetId; + + @NotBlank(message = "FLASHCARD_FRONT_CONTENT_REQUIRED") + String frontContent; + + @NotBlank(message = "FLASHCARD_BACK_CONTENT_REQUIRED") + String backContent; + + @NotNull(message = "FLASHCARD_SOURCE_DOCUMENT_ID_REQUIRED") + Integer sourceDocumentId; +} \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/dto/request/FlashcardSetCreationRequest.java b/src/main/java/com/be08/smart_notes/dto/request/FlashcardSetCreationRequest.java new file mode 100644 index 0000000..b2fa27c --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/request/FlashcardSetCreationRequest.java @@ -0,0 +1,17 @@ +package com.be08.smart_notes.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.*; +import lombok.experimental.FieldDefaults; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class FlashcardSetCreationRequest { + @NotBlank(message = "FLASHCARD_SET_TITLE_REQUIRED") + @Size(max = 100, message = "FLASHCARD_SET_TITLE_TOO_LONG") + String title; +} \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/dto/response/FlashcardResponse.java b/src/main/java/com/be08/smart_notes/dto/response/FlashcardResponse.java new file mode 100644 index 0000000..06b623c --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/response/FlashcardResponse.java @@ -0,0 +1,21 @@ +package com.be08.smart_notes.dto.response; + +import lombok.*; +import lombok.experimental.FieldDefaults; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class FlashcardResponse { + int id; + String frontContent; + String backContent; + int flashcardSetId; + int sourceDocumentId; + LocalDateTime createdAt; + LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/dto/response/FlashcardSetResponse.java b/src/main/java/com/be08/smart_notes/dto/response/FlashcardSetResponse.java new file mode 100644 index 0000000..53e0c92 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/response/FlashcardSetResponse.java @@ -0,0 +1,21 @@ +package com.be08.smart_notes.dto.response; + +import com.be08.smart_notes.enums.OriginType; +import lombok.*; +import lombok.experimental.FieldDefaults; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class FlashcardSetResponse { + int id; + String title; + int ownerId; + OriginType originType; + LocalDateTime createdAt; + LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/enums/OriginType.java b/src/main/java/com/be08/smart_notes/enums/OriginType.java new file mode 100644 index 0000000..292c916 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/enums/OriginType.java @@ -0,0 +1,7 @@ +package com.be08.smart_notes.enums; + +public enum OriginType { + DEFAULT, + USER, + AI +} \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java index c12646c..f1d2dcd 100644 --- a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -37,6 +37,23 @@ public enum ErrorCode { // 22xx - Document-related features DOCUMENT_NOT_FOUND(2201, "Document not found", HttpStatus.NOT_FOUND), NOTE_CONTENT_EMPTY(2202, "Note content cannot be empty", HttpStatus.BAD_REQUEST), + SYSTEM_SOURCE_DOCUMENT_NOT_FOUND(2205, "System source document not found", HttpStatus.NOT_FOUND), + DOCUMENT_CANNOT_BE_DELETED(2208, "This document cannot be deleted", HttpStatus.BAD_REQUEST), + + + // 25xx - Flashcard-related errors + FLASHCARD_FRONT_CONTENT_REQUIRED(2501, "Flashcard front content is required", HttpStatus.BAD_REQUEST), + FLASHCARD_BACK_CONTENT_REQUIRED(2502, "Flashcard back content is required", HttpStatus.BAD_REQUEST), + FLASHCARD_SOURCE_DOCUMENT_ID_REQUIRED(2503, "Flashcard source document ID is required", HttpStatus.BAD_REQUEST), + FLASHCARD_NOT_FOUND(2504, "Flashcard not found", HttpStatus.NOT_FOUND), + + // 26xx - Flashcard Set-related errors + FLASHCARD_SET_TITLE_REQUIRED(2601, "Flashcard set title is required", HttpStatus.BAD_REQUEST), + FLASHCARD_SET_TITLE_TOO_LONG(2602, "Flashcard set title is too long", HttpStatus.BAD_REQUEST), + INVALID_FLASHCARD_SET_TITLE(2603, "Invalid flashcard set title", HttpStatus.BAD_REQUEST), + FLASHCARD_SET_NOT_FOUND(2604, "Flashcard set not found", HttpStatus.NOT_FOUND), + FLASHCARD_SET_CANNOT_BE_MODIFIED(2605, "This flashcard set cannot be modified", HttpStatus.BAD_REQUEST), + FLASHCARD_SET_CANNOT_BE_DELETED(2606, "This flashcard set cannot be deleted", HttpStatus.BAD_REQUEST) ; int code; diff --git a/src/main/java/com/be08/smart_notes/mapper/FlashcardMapper.java b/src/main/java/com/be08/smart_notes/mapper/FlashcardMapper.java new file mode 100644 index 0000000..7c9f7fe --- /dev/null +++ b/src/main/java/com/be08/smart_notes/mapper/FlashcardMapper.java @@ -0,0 +1,31 @@ +package com.be08.smart_notes.mapper; + +import com.be08.smart_notes.dto.request.FlashcardCreationRequest; +import com.be08.smart_notes.dto.response.FlashcardResponse; +import com.be08.smart_notes.model.Flashcard; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; + +@Mapper(componentModel = "spring") +public interface FlashcardMapper { + @Mapping(target = "flashcardSet", ignore = true) + @Mapping(target = "sourceDocument", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "id", ignore = true) + Flashcard toFlashcard(FlashcardCreationRequest request); + + @Mapping(target = "flashcardSetId", source = "flashcardSet.id") + @Mapping(target = "sourceDocumentId", source = "sourceDocument.id") + FlashcardResponse toFlashcardResponse(Flashcard flashcard); + + @Mapping(target = "frontContent", source = "frontContent") + @Mapping(target = "backContent", source = "backContent") + @Mapping(target = "sourceDocument", ignore = true) + @Mapping(target = "flashcardSet", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "id", ignore = true) + void updateFlashcard(@MappingTarget Flashcard flashcard, FlashcardCreationRequest request); +} \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/mapper/FlashcardSetMapper.java b/src/main/java/com/be08/smart_notes/mapper/FlashcardSetMapper.java new file mode 100644 index 0000000..111ed66 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/mapper/FlashcardSetMapper.java @@ -0,0 +1,30 @@ +package com.be08.smart_notes.mapper; + +import com.be08.smart_notes.dto.request.FlashcardSetCreationRequest; +import com.be08.smart_notes.dto.response.FlashcardSetResponse; +import com.be08.smart_notes.model.FlashcardSet; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; + +@Mapper(componentModel = "spring") +public interface FlashcardSetMapper { + @Mapping(target = "id", ignore = true) + @Mapping(target = "owner", ignore = true) + @Mapping(target = "flashcards", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + FlashcardSet toFlashcardSet(FlashcardSetCreationRequest request); + + @Mapping(target = "ownerId", source = "owner.id") + @Mapping(target = "originType", source = "originType") + FlashcardSetResponse toFlashcardSetResponse(FlashcardSet flashcardSet); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "owner", ignore = true) + @Mapping(target = "flashcards", ignore = true) + @Mapping(target = "title", source = "title") + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + void updateFlashcardSet(@MappingTarget FlashcardSet flashcardSet, FlashcardSetCreationRequest request); +} \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/model/Flashcard.java b/src/main/java/com/be08/smart_notes/model/Flashcard.java new file mode 100644 index 0000000..3bb711e --- /dev/null +++ b/src/main/java/com/be08/smart_notes/model/Flashcard.java @@ -0,0 +1,51 @@ +package com.be08.smart_notes.model; + +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.FieldDefaults; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table(name = "flashcard") +@FieldDefaults(level = AccessLevel.PRIVATE) +public class Flashcard { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "flashcard_set_id", nullable = false) + FlashcardSet flashcardSet; + + @Column(nullable = false, name = "front_content", columnDefinition = "TEXT") + String frontContent; + + @Column(nullable = false, name = "back_content", columnDefinition = "TEXT") + String backContent; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "source_document_id", nullable = true) + Document sourceDocument; + + @Column(nullable = false, name = "created_at") + LocalDateTime createdAt; + + @Column(nullable = true, name = "updated_at") + LocalDateTime updatedAt; + + @PrePersist + protected void onCreate(){ + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate(){ + updatedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/model/FlashcardSet.java b/src/main/java/com/be08/smart_notes/model/FlashcardSet.java new file mode 100644 index 0000000..bb31029 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/model/FlashcardSet.java @@ -0,0 +1,53 @@ +package com.be08.smart_notes.model; + +import com.be08.smart_notes.enums.OriginType; +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.FieldDefaults; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table(name = "flashcard_set") +@FieldDefaults(level = AccessLevel.PRIVATE) +public class FlashcardSet { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + int id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + User owner; + + @Column(nullable = false) + String title; + + @OneToMany(mappedBy = "flashcardSet", cascade = CascadeType.ALL, orphanRemoval = true) + List flashcards; + + @Column(nullable = false, name = "created_at") + LocalDateTime createdAt; + + @Column(nullable = true, name = "updated_at") + LocalDateTime updatedAt; + + @Column(nullable = false, name = "origin_type") + @Enumerated(EnumType.STRING) + OriginType originType = OriginType.USER; + + @PrePersist + protected void onCreate(){ + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate(){ + updatedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java index 73e1ca4..fe96bdd 100644 --- a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java @@ -1,6 +1,7 @@ package com.be08.smart_notes.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,4 +11,8 @@ @Repository public interface DocumentRepository extends JpaRepository { List findAllByUserId(Integer userId); + + Optional findByIdAndUserId(int documentId, int userId); + + Optional findByTitleAndUserId(String title, int userId); } diff --git a/src/main/java/com/be08/smart_notes/repository/FlashcardRepository.java b/src/main/java/com/be08/smart_notes/repository/FlashcardRepository.java new file mode 100644 index 0000000..8ddca8b --- /dev/null +++ b/src/main/java/com/be08/smart_notes/repository/FlashcardRepository.java @@ -0,0 +1,26 @@ +package com.be08.smart_notes.repository; + +import com.be08.smart_notes.model.Flashcard; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface FlashcardRepository extends JpaRepository { + /** + * Find all flashcards belonging to a specific flashcard set + * @param flashcardSetId ID of the flashcard set + * @return List of flashcards + */ + List findByFlashcardSet_Id(int flashcardSetId); + + /** + * Find a flashcard by its ID and the owner's user ID + * @param flashcardId ID of the flashcard + * @param userId ID of the flashcard owner + * @return Optional containing the flashcard if found, otherwise empty + */ + Optional findByIdAndFlashcardSet_Owner_Id(int flashcardId, int userId); +} \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/repository/FlashcardSetRepository.java b/src/main/java/com/be08/smart_notes/repository/FlashcardSetRepository.java new file mode 100644 index 0000000..bb9aa65 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/repository/FlashcardSetRepository.java @@ -0,0 +1,41 @@ +package com.be08.smart_notes.repository; + +import com.be08.smart_notes.enums.OriginType; +import com.be08.smart_notes.model.FlashcardSet; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface FlashcardSetRepository extends JpaRepository { + /** + * Find the specific flashcard set by its id and owner id + * @param flashcardSetId + * @param ownerId + * @return Optional containing the flashcard set if found, otherwise empty + */ + Optional findByIdAndOwner_Id(int flashcardSetId, int ownerId); + + /** + * Find the unique default flashcard set for a user by owner id and origin type + * @param ownerId + * @param originType + * @return Optional containing the flashcard set if found, otherwise empty + */ + Optional findByOwner_IdAndOriginType(int ownerId, OriginType originType); + + /** + * Find all flashcard sets owned by a specific user + * @param ownerId + * @return List of flashcard sets owned by the user + */ + List findAllByOwner_Id(int ownerId); + + /** + * Find all flashcard sets owned by a specific user with a specific origin type + * @param ownerId + * @param originType + * @return List of flashcard sets owned by the user with the specified origin type + */ + List findAllByOwner_IdAndOriginType(int ownerId, OriginType originType); +} \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/service/DocumentService.java b/src/main/java/com/be08/smart_notes/service/DocumentService.java index 01fd6d0..3b2ee51 100644 --- a/src/main/java/com/be08/smart_notes/service/DocumentService.java +++ b/src/main/java/com/be08/smart_notes/service/DocumentService.java @@ -21,6 +21,8 @@ public class DocumentService { AuthorizationService authorizationService; DocumentRepository documentRepository; + private static final String SYSTEM_SOURCE_TITLE = "__SYSTEM_UNFILED_SOURCE__"; + public List getAllDocuments() { // Get current user int currentUserId = authorizationService.getCurrentUserId(); @@ -39,6 +41,34 @@ public void deleteDocument(int id) { // Check ownership authorizationService.validateOwnership(document.getUserId()); + // Prevent deletion of system source document + if(SYSTEM_SOURCE_TITLE.equals(document.getTitle())){ + throw new AppException(ErrorCode.DOCUMENT_CANNOT_BE_DELETED); + } + documentRepository.delete(document); } + + /** + * Get document if owned by user + * @param documentId + * @param userId + * @return Document + * @throws AppException if document not found or not owned by user + */ + public Document getDocumentIfOwned(int documentId, int userId){ + return documentRepository.findByIdAndUserId(documentId, userId) + .orElseThrow(() -> new AppException(ErrorCode.DOCUMENT_NOT_FOUND)); + } + + /** + * Get system source document for user + * @param userId + * @return Document + * @throws AppException if system source document not found + */ + public Document getSystemSourceDocument(int userId){ + return documentRepository.findByTitleAndUserId(SYSTEM_SOURCE_TITLE, userId) + .orElseThrow(() -> new AppException(ErrorCode.SYSTEM_SOURCE_DOCUMENT_NOT_FOUND)); + } } diff --git a/src/main/java/com/be08/smart_notes/service/FlashcardService.java b/src/main/java/com/be08/smart_notes/service/FlashcardService.java new file mode 100644 index 0000000..b7f53c7 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/FlashcardService.java @@ -0,0 +1,165 @@ +package com.be08.smart_notes.service; + +import com.be08.smart_notes.dto.request.FlashcardCreationRequest; +import com.be08.smart_notes.dto.response.FlashcardResponse; +import com.be08.smart_notes.exception.AppException; +import com.be08.smart_notes.exception.ErrorCode; +import com.be08.smart_notes.mapper.FlashcardMapper; +import com.be08.smart_notes.model.Document; +import com.be08.smart_notes.model.Flashcard; +import com.be08.smart_notes.model.FlashcardSet; +import com.be08.smart_notes.repository.FlashcardRepository; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@Slf4j +@Transactional +public class FlashcardService { + FlashcardRepository flashcardRepository; + FlashcardMapper flashcardMapper; + + DocumentService documentService; + FlashcardSetService flashcardSetService; + AuthorizationService authorizationService; + + // -- CRUD Operations -- + // -- Create Flashcard -- + /** + * Create a new flashcard + * @param request FlashcardCreationRequest + * @return FlashcardResponse + */ + public FlashcardResponse createFlashcard(FlashcardCreationRequest request){ + // Get current user ID from claim sub of JWT token (security context) + int currentUserId = authorizationService.getCurrentUserId(); + + Document sourceDocument = validateAndGetSourceDocument(request.getSourceDocumentId(), currentUserId); + FlashcardSet flashcardSet = validateAndGetFlashcardSet(request.getFlashcardSetId(), currentUserId); + + Flashcard flashcard = flashcardMapper.toFlashcard(request); + flashcard.setSourceDocument(sourceDocument); + flashcard.setFlashcardSet(flashcardSet); + + flashcard = flashcardRepository.save(flashcard); + return flashcardMapper.toFlashcardResponse(flashcard); + } + + // -- Read Flashcard -- + /** + * Get a flashcard by its ID + * @param flashcardId ID of the flashcard + * @return FlashcardResponse + */ + public FlashcardResponse getFlashcardById(int flashcardId){ + int currentUserId = authorizationService.getCurrentUserId(); + + Flashcard flashcard = flashcardRepository.findByIdAndFlashcardSet_Owner_Id(flashcardId, currentUserId) + .orElseThrow(() -> new AppException(ErrorCode.FLASHCARD_NOT_FOUND)); + + return flashcardMapper.toFlashcardResponse(flashcard); + } + + /** + * Get all flashcards in a specific flashcard set + * @param flashcardSetId ID of the flashcard set + * @return List of FlashcardResponse + */ + public List getFlashcardsBySetId(int flashcardSetId){ + int currentUserId = authorizationService.getCurrentUserId(); + + // Validate ownership of the flashcard set + flashcardSetService.validateOwner(flashcardSetId, currentUserId); + + List flashcards = flashcardRepository.findByFlashcardSet_Id(flashcardSetId); + + return flashcards.stream() + .map(flashcardMapper::toFlashcardResponse) + .collect(Collectors.toList()); + } + + // -- Update Flashcard -- + /** + * Update an existing flashcard + * @param flashcardId ID of the flashcard to update + * @param request Update request data + * @return Updated FlashcardResponse + */ + public FlashcardResponse updateFlashcard(int flashcardId, FlashcardCreationRequest request){ + int currentUserId = authorizationService.getCurrentUserId(); + + Flashcard existingFlashcard = flashcardRepository.findByIdAndFlashcardSet_Owner_Id(flashcardId, currentUserId) + .orElseThrow(() -> new AppException(ErrorCode.FLASHCARD_NOT_FOUND)); + + if(existingFlashcard.getSourceDocument().getId() != request.getSourceDocumentId()){ + Document newSourceDocument = validateAndGetSourceDocument(request.getSourceDocumentId(), currentUserId); + existingFlashcard.setSourceDocument(newSourceDocument); + } + + if(request.getFlashcardSetId() != null && existingFlashcard.getFlashcardSet().getId() != request.getFlashcardSetId()){ + FlashcardSet newFlashcardSet = validateAndGetFlashcardSet(request.getFlashcardSetId(), currentUserId); + existingFlashcard.setFlashcardSet(newFlashcardSet); + } + + flashcardMapper.updateFlashcard(existingFlashcard, request); + + existingFlashcard = flashcardRepository.save(existingFlashcard); + return flashcardMapper.toFlashcardResponse(existingFlashcard); + } + + // -- Delete Flashcard -- + /** + * Delete a flashcard by ID + * @param flashcardId ID of the flashcard to delete + */ + public void deleteFlashcard(int flashcardId){ + int currentUserId = authorizationService.getCurrentUserId(); + Flashcard flashcard = flashcardRepository.findByIdAndFlashcardSet_Owner_Id(flashcardId, currentUserId) + .orElseThrow(() -> new AppException(ErrorCode.FLASHCARD_NOT_FOUND)); + + flashcardRepository.delete(flashcard); + } + + // -- Private Helper Methods -- + + /** + * Validate and get the source document + * @param requestedDocumentId + * @param userId + * @return Document + */ + private Document validateAndGetSourceDocument(Integer requestedDocumentId, int userId){ + if(requestedDocumentId != null){ + // If a specific document ID is provided, validate ownership and existence + // and return that document + return documentService.getDocumentIfOwned(requestedDocumentId, userId); + } + // If no document ID is provided, return the system source document + return documentService.getSystemSourceDocument(userId); + } + + /** + * Validate and get the flashcard set + * @param requestedSetId + * @param userId + * @return FlashcardSet + */ + private FlashcardSet validateAndGetFlashcardSet(Integer requestedSetId, int userId){ + if(requestedSetId != null){ + // If a specific flashcard set ID is provided, validate ownership and existence + // and return that flashcard set + return flashcardSetService.validateOwner(requestedSetId, userId); + } + // If no flashcard set ID is provided, return or create the default flashcard set + return flashcardSetService.getOrCreateDefaultSet(userId); + } +} \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/service/FlashcardSetService.java b/src/main/java/com/be08/smart_notes/service/FlashcardSetService.java new file mode 100644 index 0000000..972c9c7 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/FlashcardSetService.java @@ -0,0 +1,150 @@ +package com.be08.smart_notes.service; + +import com.be08.smart_notes.dto.request.FlashcardSetCreationRequest; +import com.be08.smart_notes.dto.response.FlashcardSetResponse; +import com.be08.smart_notes.enums.OriginType; +import com.be08.smart_notes.exception.AppException; +import com.be08.smart_notes.exception.ErrorCode; +import com.be08.smart_notes.mapper.FlashcardSetMapper; +import com.be08.smart_notes.model.FlashcardSet; +import com.be08.smart_notes.model.User; +import com.be08.smart_notes.repository.FlashcardSetRepository; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@Transactional +@Slf4j +public class FlashcardSetService { + FlashcardSetRepository flashcardSetRepository; + UserService userService; + + AuthorizationService authorizationService; + FlashcardSetMapper flashcardSetMapper; + + private static final String DEFAULT_SET_TITLE = "Unsorted Flashcards"; + + /** + * Validate that the flashcard set with the given ID is owned by the user with the given ID + * @param flashcardSetId + * @param userId + * @return FlashcardSet + */ + public FlashcardSet validateOwner(int flashcardSetId, int userId){ + return flashcardSetRepository.findByIdAndOwner_Id(flashcardSetId, userId) + .orElseThrow(() -> new AppException(ErrorCode.FLASHCARD_SET_NOT_FOUND)); + } + + /** + * Get or create the default flashcard set for the user with the given ID + * @param userId + * @return FlashcardSet + */ + public FlashcardSet getOrCreateDefaultSet(int userId){ + return flashcardSetRepository.findByOwner_IdAndOriginType(userId, OriginType.DEFAULT) + .orElseGet(() -> { + User user = userService.getById(userId); + + FlashcardSet defaultSet = FlashcardSet.builder() + .title(DEFAULT_SET_TITLE) + .owner(user) + .originType(OriginType.DEFAULT) + .build(); + + return flashcardSetRepository.save(defaultSet); + }); + } + + /** + * Create a new flashcard set + * @param request + * @return FlashcardSetResponse + */ + public FlashcardSetResponse createFlashcardSet(FlashcardSetCreationRequest request){ + int currentUserId = authorizationService.getCurrentUserId(); + User user = userService.getById(currentUserId); + + if(DEFAULT_SET_TITLE.equals(request.getTitle())){ + log.error("Flashcard Set Creation Failed: Title '{}' is reserved for default set", DEFAULT_SET_TITLE); + throw new AppException(ErrorCode.INVALID_FLASHCARD_SET_TITLE); + } + + FlashcardSet newSet = flashcardSetMapper.toFlashcardSet(request); + newSet.setOwner(user); + newSet.setOriginType(OriginType.USER); + + newSet = flashcardSetRepository.save(newSet); + return flashcardSetMapper.toFlashcardSetResponse(newSet); + } + + /** + * Get all flashcard sets owned by the current user, excluding default sets + * @return List of FlashcardSetResponse + */ + public List getAllFlashcardSets(){ + int currentUserId = authorizationService.getCurrentUserId(); + + List flashcardSetList = flashcardSetRepository.findAllByOwner_Id(currentUserId); + return flashcardSetList.stream() + .filter(set -> set.getOriginType() != OriginType.DEFAULT) + .map(flashcardSetMapper::toFlashcardSetResponse) + .collect(Collectors.toList()); + } + + /** + * Get a flashcard set by its ID + * @param flashcardSetId + * @return FlashcardSetResponse + */ + public FlashcardSetResponse getFlashcardSet(int flashcardSetId){ + int currentUserId = authorizationService.getCurrentUserId(); + FlashcardSet existingSet = validateOwner(flashcardSetId, currentUserId); + return flashcardSetMapper.toFlashcardSetResponse(existingSet); + } + + /** + * Update an existing flashcard set + * @param flashcardSetId + * @param request + * @return FlashcardSetResponse + */ + public FlashcardSetResponse updateFlashcardSet(int flashcardSetId, FlashcardSetCreationRequest request){ + int currentUserId = authorizationService.getCurrentUserId(); + FlashcardSet existingSet = validateOwner(flashcardSetId, currentUserId); + + if(existingSet.getOriginType() == OriginType.DEFAULT){ + log.error("Flashcard Set Update Failed: Cannot update default flashcard set with id {}", flashcardSetId); + throw new AppException(ErrorCode.FLASHCARD_SET_CANNOT_BE_MODIFIED); + } + + flashcardSetMapper.updateFlashcardSet(existingSet, request); + + existingSet = flashcardSetRepository.save(existingSet); + return flashcardSetMapper.toFlashcardSetResponse(existingSet); + } + + /** + * Delete a flashcard set by its ID + * @param flashcardSetId + */ + public void deleteFlashcardSet(int flashcardSetId){ + int currentUserId = authorizationService.getCurrentUserId(); + FlashcardSet existingSet = validateOwner(flashcardSetId, currentUserId); + + if(existingSet.getOriginType() == OriginType.DEFAULT){ + log.error("Flashcard Set Deletion Failed: Cannot delete default flashcard set with id {}", flashcardSetId); + throw new AppException(ErrorCode.FLASHCARD_SET_CANNOT_BE_DELETED); + } + + flashcardSetRepository.deleteById(flashcardSetId); + } +} \ No newline at end of file diff --git a/src/main/java/com/be08/smart_notes/service/UserService.java b/src/main/java/com/be08/smart_notes/service/UserService.java index cf2fc9a..c1b126d 100644 --- a/src/main/java/com/be08/smart_notes/service/UserService.java +++ b/src/main/java/com/be08/smart_notes/service/UserService.java @@ -65,4 +65,19 @@ public UserResponse getMe(){ return userMapper.toUserResponse(user); } + + /** + * Get user by ID + * @param userId ID of the user to fetch + * @return User entity + */ + public User getById(int userId){ + // Fetch user from repository + User user = userRepository.findById(userId) + .orElseThrow(() -> { + log.error("User with id {} not found", userId); + return new AppException(ErrorCode.USER_NOT_FOUND); + }); + return user; + } } From 3e1311ab28433b71670215e8e838d6b6a3e3cd72 Mon Sep 17 00:00:00 2001 From: Phu Vo Date: Fri, 7 Nov 2025 18:04:09 +1000 Subject: [PATCH 33/63] :wrench: feat(cors): Configure CORS with FrontEnd for local dev environment --- pom.xml | 8 ----- .../smart_notes/config/SecurityConfig.java | 29 +++++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/pom.xml b/pom.xml index 99ab7ac..bde7996 100644 --- a/pom.xml +++ b/pom.xml @@ -81,14 +81,6 @@ lombok ${lombok.version} - - - - - org.springframework.boot - spring-boot-starter-validation - 3.5.6 - diff --git a/src/main/java/com/be08/smart_notes/config/SecurityConfig.java b/src/main/java/com/be08/smart_notes/config/SecurityConfig.java index 2d7eace..f9a6324 100644 --- a/src/main/java/com/be08/smart_notes/config/SecurityConfig.java +++ b/src/main/java/com/be08/smart_notes/config/SecurityConfig.java @@ -26,8 +26,12 @@ import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.io.IOException; +import java.util.Arrays; @Configuration @EnableWebSecurity @@ -80,6 +84,28 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } } + /** + * Configure CORS settings + * @return CorsConfigurationSource + */ + @Bean + public CorsConfigurationSource corsConfigurationSource(){ + CorsConfiguration corsConfiguration = new CorsConfiguration(); + + corsConfiguration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://127.0.0.1:3000")); + corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH","DELETE")); + + corsConfiguration.setAllowedHeaders(Arrays.asList("*")); + + // Allow credentials such as cookies and authorization headers for JWT tokens + corsConfiguration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", corsConfiguration); + + return source; + } + /** * Configure security filter chain * @param httpSecurity @@ -89,6 +115,9 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse @Bean public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, ObjectMapper objectMapper, LogoutService logoutService) throws Exception { httpSecurity + // Enable CORS with the defined configuration source + .cors(Customizer.withDefaults()) + // Disable CSRF protection for stateless REST APIs .csrf(AbstractHttpConfigurer::disable) From 6e5cddf7ff02ebc5909632dc6ffe1aa78e3c4535 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Fri, 7 Nov 2025 21:47:25 +1100 Subject: [PATCH 34/63] refactor(quiz): add origin type in quiz set response and minor bug fix --- .../com/be08/smart_notes/dto/response/QuizSetResponse.java | 4 ++++ src/main/java/com/be08/smart_notes/exception/ErrorCode.java | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java b/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java index c481e69..686a250 100644 --- a/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java @@ -1,6 +1,7 @@ package com.be08.smart_notes.dto.response; import com.be08.smart_notes.dto.view.Level; +import com.be08.smart_notes.enums.OriginType; import com.fasterxml.jackson.annotation.JsonView; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -22,6 +23,9 @@ public class QuizSetResponse { @JsonView(Level.Basic.class) String title; + @JsonView(Level.Basic.class) + OriginType originType; + @JsonView(Level.Basic.class) LocalDateTime createdAt; diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java index 2d18bea..dc70b2e 100644 --- a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -63,7 +63,7 @@ public enum ErrorCode { INVALID_FLASHCARD_SET_TITLE(2603, "Invalid flashcard set title", HttpStatus.BAD_REQUEST), FLASHCARD_SET_NOT_FOUND(2604, "Flashcard set not found", HttpStatus.NOT_FOUND), FLASHCARD_SET_CANNOT_BE_MODIFIED(2605, "This flashcard set cannot be modified", HttpStatus.BAD_REQUEST), - FLASHCARD_SET_CANNOT_BE_DELETED(2606, "This flashcard set cannot be deleted", HttpStatus.BAD_REQUEST) + FLASHCARD_SET_CANNOT_BE_DELETED(2606, "This flashcard set cannot be deleted", HttpStatus.BAD_REQUEST), // 27xx - Quiz-related features QUIZ_SET_NOT_FOUND(2701, "Quiz set not found", HttpStatus.NOT_FOUND), From 644676172994cf8e56c6a2d4858b6f5005ae9d9b Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Fri, 7 Nov 2025 23:00:57 +1100 Subject: [PATCH 35/63] refactor: minor bug fixes and replace post query ownership-validation with direct query with userId --- .../be08/smart_notes/dto/QuizUpsertDTO.java | 3 +- .../be08/smart_notes/exception/ErrorCode.java | 5 +-- .../smart_notes/service/DocumentService.java | 11 +++---- .../be08/smart_notes/service/NoteService.java | 32 +++++++++---------- .../be08/smart_notes/service/QuizService.java | 8 +++-- .../smart_notes/service/QuizSetService.java | 14 +++----- 6 files changed, 36 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/be08/smart_notes/dto/QuizUpsertDTO.java b/src/main/java/com/be08/smart_notes/dto/QuizUpsertDTO.java index 396eac8..5e5a25c 100644 --- a/src/main/java/com/be08/smart_notes/dto/QuizUpsertDTO.java +++ b/src/main/java/com/be08/smart_notes/dto/QuizUpsertDTO.java @@ -27,7 +27,8 @@ public class QuizUpsertDTO { @JsonProperty(value = "topic") String title; - @NotEmpty(groups = OnCreate.class) + // Questions are ignored in mapper, will not modify questions in quiz update request (currently not supported) + @NotEmpty(groups = OnCreate.class, message = "QUIZ_QUESTION_REQUIRED") @JsonProperty(value = "questions") List questions; diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java index dc70b2e..56d34c2 100644 --- a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -49,7 +49,8 @@ public enum ErrorCode { QUIZ_NOT_FOUND(2401, "Quiz not found", HttpStatus.NOT_FOUND), QUIZ_DOCUMENT_SIZE_EXCEED(2402, "Number of quiz IDs must be between 1 and 5", HttpStatus.BAD_REQUEST), INVALID_QUIZ_SIZE(2403, "Total number of questions must between 1 and 20", HttpStatus.BAD_REQUEST), - + QUIZ_TITLE_REQUIRED(2404, "Quiz title cannot be empty", HttpStatus.BAD_REQUEST), + QUIZ_QUESTION_REQUIRED(2405, "Quiz question cannot be empty", HttpStatus.BAD_REQUEST), // 25xx - Flashcard-related errors FLASHCARD_FRONT_CONTENT_REQUIRED(2501, "Flashcard front content is required", HttpStatus.BAD_REQUEST), @@ -65,7 +66,7 @@ public enum ErrorCode { FLASHCARD_SET_CANNOT_BE_MODIFIED(2605, "This flashcard set cannot be modified", HttpStatus.BAD_REQUEST), FLASHCARD_SET_CANNOT_BE_DELETED(2606, "This flashcard set cannot be deleted", HttpStatus.BAD_REQUEST), - // 27xx - Quiz-related features + // 27xx - Quiz set-related features QUIZ_SET_NOT_FOUND(2701, "Quiz set not found", HttpStatus.NOT_FOUND), QUIZ_SET_ID_REQUIRED(2702, "Quiz set ID required", HttpStatus.BAD_REQUEST), QUIZ_SET_TITLE_REQUIRED(2703, "Quiz set title required", HttpStatus.BAD_REQUEST), diff --git a/src/main/java/com/be08/smart_notes/service/DocumentService.java b/src/main/java/com/be08/smart_notes/service/DocumentService.java index 3b2ee51..2b9ca16 100644 --- a/src/main/java/com/be08/smart_notes/service/DocumentService.java +++ b/src/main/java/com/be08/smart_notes/service/DocumentService.java @@ -27,20 +27,19 @@ public List getAllDocuments() { // Get current user int currentUserId = authorizationService.getCurrentUserId(); - // Get document return documentRepository.findAllByUserId(currentUserId); } public void deleteDocument(int id) { + // Get current user + int currentUserId = authorizationService.getCurrentUserId(); + // Get document - Document document = documentRepository.findById(id).orElseThrow(() -> { - log.error("Document with id {} not found", id); + Document document = documentRepository.findByIdAndUserId(id, currentUserId).orElseThrow(() -> { + log.error("Document with id {} not found in user's account", id); throw new AppException(ErrorCode.DOCUMENT_NOT_FOUND); }); - // Check ownership - authorizationService.validateOwnership(document.getUserId()); - // Prevent deletion of system source document if(SYSTEM_SOURCE_TITLE.equals(document.getTitle())){ throw new AppException(ErrorCode.DOCUMENT_CANNOT_BE_DELETED); diff --git a/src/main/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index 7a5840b..5589986 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -29,15 +29,15 @@ public class NoteService { DocumentMapper documentMapper; public NoteResponse getNote(int noteId) { + // Get current user id + int currentUserId = authorizationService.getCurrentUserId(); + // Get note - Document note = documentRepository.findById(noteId).orElseThrow(() -> { - log.error("Note with id {} not found", noteId); + Document note = documentRepository.findByIdAndUserId(noteId, currentUserId).orElseThrow(() -> { + log.error("Note with id {} not found in user's account", noteId); return new AppException(ErrorCode.DOCUMENT_NOT_FOUND); }); - // Check ownership - authorizationService.validateOwnership(note.getUserId()); - return documentMapper.toNoteResponse(note); } @@ -60,14 +60,14 @@ public NoteResponse createNote(NoteUpsertRequest newData) { } public NoteResponse updateNote(int noteId, NoteUpsertRequest updateData) { + // Get current user id + int currentUserId = authorizationService.getCurrentUserId(); + // Get note - Document note = documentRepository.findById(noteId).orElseThrow(() -> { - log.error("Note with id {} not found", noteId); + Document note = documentRepository.findByIdAndUserId(noteId, currentUserId).orElseThrow(() -> { + log.error("Note with id {} not found in user's account", noteId); return new AppException(ErrorCode.DOCUMENT_NOT_FOUND); }); - - // Check ownership - authorizationService.validateOwnership(note.getUserId()); note.setTitle(updateData.getTitle()); note.setUpdatedAt(LocalDateTime.now());; @@ -77,15 +77,15 @@ public NoteResponse updateNote(int noteId, NoteUpsertRequest updateData) { } public void deleteNote(int noteId) { + // Get current user id + int currentUserId = authorizationService.getCurrentUserId(); + // Get document - Document note = documentRepository.findById(noteId).orElseThrow(() -> { - log.error("Note with id {} not found", noteId); - throw new AppException(ErrorCode.DOCUMENT_NOT_FOUND); + Document note = documentRepository.findByIdAndUserId(noteId, currentUserId).orElseThrow(() -> { + log.error("Note with id {} not found in user's account", noteId); + return new AppException(ErrorCode.DOCUMENT_NOT_FOUND); }); - // Check ownership - authorizationService.validateOwnership(note.getUserId()); - documentRepository.deleteById(noteId); } diff --git a/src/main/java/com/be08/smart_notes/service/QuizService.java b/src/main/java/com/be08/smart_notes/service/QuizService.java index d84fa8b..e2a69b3 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizService.java @@ -31,13 +31,15 @@ public class QuizService { * @return quiz response dto */ public QuizResponse createQuiz(QuizUpsertDTO quizUpsertDTO) { + int currentUserId = authorizationService.getCurrentUserId(); + Integer quizSetId = quizUpsertDTO.getQuizSetId(); QuizSet existingQuizSet = null; if (quizSetId != null) { - existingQuizSet = quizSetService.getQuizSetEntityById(quizSetId); + existingQuizSet = quizSetService.getQuizSetEntityById(quizSetId, currentUserId); } if (existingQuizSet == null) { - existingQuizSet = quizSetService.getOrCreateDefaultSet(); + existingQuizSet = quizSetService.getOrCreateDefaultSet(currentUserId); } Quiz newQuiz = quizMapper.toQuiz(quizUpsertDTO); @@ -78,7 +80,7 @@ public QuizResponse updateQuiz(int quizId, QuizUpsertDTO quizUpsertDTO) { }); if (quizUpsertDTO.getQuizSetId() != null) { - QuizSet quizSet = quizSetService.getQuizSetEntityById(quizUpsertDTO.getQuizSetId()); + QuizSet quizSet = quizSetService.getQuizSetEntityById(quizUpsertDTO.getQuizSetId(), currentUserId); existingQuiz.setQuizSet(quizSet); } diff --git a/src/main/java/com/be08/smart_notes/service/QuizSetService.java b/src/main/java/com/be08/smart_notes/service/QuizSetService.java index 9ffc3f4..f2c53de 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizSetService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizSetService.java @@ -143,12 +143,10 @@ public void deleteAllQuizSet() { * Get or create a default set if not exist for current user. Default set is unique for each user * @return default QuizSet entity */ - public QuizSet getOrCreateDefaultSet() { - int currentUserId = authorizationService.getCurrentUserId(); - - return quizSetRepository.findByUserIdAndOriginType(currentUserId, OriginType.DEFAULT).orElseGet(() -> { + public QuizSet getOrCreateDefaultSet(int userId) { + return quizSetRepository.findByUserIdAndOriginType(userId, OriginType.DEFAULT).orElseGet(() -> { QuizSet defaultSet = QuizSet.builder() - .userId(currentUserId) + .userId(userId) .title(AppConstants.DEFAULT_QUIZ_SET_TITLE) .originType(OriginType.DEFAULT).build(); return quizSetRepository.save(defaultSet); @@ -160,10 +158,8 @@ public QuizSet getOrCreateDefaultSet() { * @param quizSetId id of quiz set * @return found QuizSet entity */ - public QuizSet getQuizSetEntityById(int quizSetId) { - int currentUserId = authorizationService.getCurrentUserId(); - - QuizSet quizSet = quizSetRepository.findByIdAndUserId(quizSetId, currentUserId).orElseThrow(() -> { + public QuizSet getQuizSetEntityById(int quizSetId, int userId) { + QuizSet quizSet = quizSetRepository.findByIdAndUserId(quizSetId, userId).orElseThrow(() -> { log.error("Quiz set with id {} not found in user's account", quizSetId); return new AppException(ErrorCode.QUIZ_SET_NOT_FOUND); }); From 65f4ff987bb15a72c853f951873a5fcf2827eeb3 Mon Sep 17 00:00:00 2001 From: Phu Vo Date: Fri, 7 Nov 2025 22:21:14 +1000 Subject: [PATCH 36/63] :wrench: feat(cors): Configure profiles for development and production environments --- pom.xml | 24 +++++++++++++++++++ .../smart_notes/config/SecurityConfig.java | 6 ++++- src/main/resources/application-dev.properties | 20 ++++++++++++++++ .../resources/application-prod.properties | 20 ++++++++++++++++ .../resources/application-test.properties | 0 src/main/resources/application.properties | 20 ++++------------ 6 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 src/main/resources/application-dev.properties create mode 100644 src/main/resources/application-prod.properties create mode 100644 src/main/resources/application-test.properties diff --git a/pom.xml b/pom.xml index bde7996..53ec4c5 100644 --- a/pom.xml +++ b/pom.xml @@ -174,4 +174,28 @@ + + + dev + + true + + + dev + + + + test + + test + + + + prod + + prod + + + + diff --git a/src/main/java/com/be08/smart_notes/config/SecurityConfig.java b/src/main/java/com/be08/smart_notes/config/SecurityConfig.java index f9a6324..63f5e5e 100644 --- a/src/main/java/com/be08/smart_notes/config/SecurityConfig.java +++ b/src/main/java/com/be08/smart_notes/config/SecurityConfig.java @@ -9,6 +9,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManagerResolver; @@ -37,6 +38,9 @@ @EnableWebSecurity public class SecurityConfig { + @Value("${app.frontend.url}") + private String frontendUrl; + @Bean public ObjectMapper objectMapper(){ ObjectMapper mapper = new ObjectMapper(); @@ -92,7 +96,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse public CorsConfigurationSource corsConfigurationSource(){ CorsConfiguration corsConfiguration = new CorsConfiguration(); - corsConfiguration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://127.0.0.1:3000")); + corsConfiguration.setAllowedOrigins(Arrays.asList(frontendUrl)); corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH","DELETE")); corsConfiguration.setAllowedHeaders(Arrays.asList("*")); diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..b2bf96e --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,20 @@ +# Database Configuration for Development (Local MySQL) +spring.datasource.url=jdbc:mysql://localhost:${DB_PORT}/${DB_NAME} +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} +spring.datasource.diver-class-name =com.mysql.cj.jdbc.Driver + +# JPA Settings for Development +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.hibernate.ddl-auto=update + +# REDIS Configuration for Development (Local Redis - Docker) +spring.data.redis.host=${REDIS_HOST} +spring.data.redis.port=${REDIS_PORT} + +# Frontend URL for Local Development +app.frontend.url=http://localhost:3000 + +# Logging Level for Development +logging.level.com.be08=DEBUG \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..42e8ccf --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,20 @@ +# Database Configuration for Production (AWS RDS) +spring.datasource.url=jdbc:mysql://${RDS_HOST}:${RDS_PORT}/${RDS_DB_NAME} +spring.datasource.username=${RDS_USERNAME} +spring.datasource.password=${RDS_PASSWORD} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# JPA Settings for Production +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=false +spring.jpa.hibernate.ddl-auto=validate + +# REDIS Configuration for Production (AWS ElastiCache) +spring.data.redis.host=${ELASTICACHE_HOST} +spring.data.redis.port=${ELASTICACHE_PORT} + +# Frontend URL for Production (Vercel) +app.frontend.url=https://${VERCEL_FRONTEND_DOMAIN} + +# Logging Level for Production +logging.level.com.be08=INFO \ No newline at end of file diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 17f3e34..4e46427 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,14 +4,8 @@ server.port=8080 # Environment spring.config.import=file:.env[.properties] -# Database -spring.datasource.url=jdbc:mysql://localhost:${DB_PORT}/${DB_NAME} -spring.datasource.username=${DB_USERNAME} -spring.datasource.password=${DB_PASSWORD} - -spring.datasource.diver-class-name =com.mysql.cj.jdbc.Driver -spring.jpa.show-sql=true -spring.jpa.properties.hibernate.format_sql=true +# Active Profile +spring.profiles.active=dev # JWT Configuration jwt.access-token.expiration-minutes=15 @@ -19,13 +13,9 @@ jwt.refresh-token.expiration-days=7 # JWT KeyStore Configuration jwt.keystore.location=classpath:keys/jwt-keystore.jks -jwt.keystore.password=PhuVoDev -jwt.key.password=AliceTat +jwt.keystore.password=${JWT_KEYSTORE_PASSWORD} +jwt.key.password=${JWT_KEY_PASSWORD} # JWT Key Rotation Configuration jwt.signing.key.alias=jwtkey-2025 -jwt.verification.key.aliases=jwtkey-2025 - -# Spring Data Redis Configuration -spring.data.redis.host=localhost -spring.data.redis.port=6379 \ No newline at end of file +jwt.verification.key.aliases=jwtkey-2025 \ No newline at end of file From cf5f9cc0d37b9072afc80fc0a69fbab445d2d666 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Sun, 9 Nov 2025 01:04:06 +1100 Subject: [PATCH 37/63] feat(quiz-attempt): add create, read one, read all and read answer for quiz attempt --- .../controller/AttemptController.java | 79 +++++++++++++ .../request/AttemptDetailUpdateRequest.java | 20 ++++ .../dto/response/AttemptResponse.java | 69 ++++++++++++ .../smart_notes/dto/view/AttemptView.java | 7 ++ .../be08/smart_notes/exception/ErrorCode.java | 8 ++ .../mapper/AttemptDetailMapper.java | 20 ++++ .../smart_notes/mapper/AttemptMapper.java | 23 ++++ .../smart_notes/mapper/QuestionMapper.java | 38 +++++++ .../be08/smart_notes/mapper/QuizMapper.java | 23 +--- .../com/be08/smart_notes/model/Attempt.java | 47 ++++++++ .../be08/smart_notes/model/AttemptDetail.java | 36 ++++++ .../java/com/be08/smart_notes/model/Quiz.java | 7 +- .../repository/AttemptDetailRepository.java | 12 ++ .../repository/AttemptRepository.java | 15 +++ .../smart_notes/service/AttemptService.java | 105 ++++++++++++++++++ .../be08/smart_notes/service/QuizService.java | 13 ++- 16 files changed, 495 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/controller/AttemptController.java create mode 100644 src/main/java/com/be08/smart_notes/dto/request/AttemptDetailUpdateRequest.java create mode 100644 src/main/java/com/be08/smart_notes/dto/response/AttemptResponse.java create mode 100644 src/main/java/com/be08/smart_notes/dto/view/AttemptView.java create mode 100644 src/main/java/com/be08/smart_notes/mapper/AttemptDetailMapper.java create mode 100644 src/main/java/com/be08/smart_notes/mapper/AttemptMapper.java create mode 100644 src/main/java/com/be08/smart_notes/mapper/QuestionMapper.java create mode 100644 src/main/java/com/be08/smart_notes/model/Attempt.java create mode 100644 src/main/java/com/be08/smart_notes/model/AttemptDetail.java create mode 100644 src/main/java/com/be08/smart_notes/repository/AttemptDetailRepository.java create mode 100644 src/main/java/com/be08/smart_notes/repository/AttemptRepository.java create mode 100644 src/main/java/com/be08/smart_notes/service/AttemptService.java diff --git a/src/main/java/com/be08/smart_notes/controller/AttemptController.java b/src/main/java/com/be08/smart_notes/controller/AttemptController.java new file mode 100644 index 0000000..cfa0834 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/controller/AttemptController.java @@ -0,0 +1,79 @@ +package com.be08.smart_notes.controller; + +import com.be08.smart_notes.dto.response.ApiResponse; +import com.be08.smart_notes.dto.response.AttemptResponse; +import com.be08.smart_notes.dto.view.AttemptView; +import com.be08.smart_notes.service.AttemptService; +import com.fasterxml.jackson.annotation.JsonView; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Controller +@RequestMapping("/api/quizzes") +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class AttemptController { + AttemptService attemptService; + + @PostMapping("/{quizId}/attempts") + @JsonView(AttemptView.Detail.class) + public ResponseEntity createAttempt(@PathVariable int quizId) { + AttemptResponse attemptResponse = attemptService.createNewAttempt(quizId); + ApiResponse apiResponse = ApiResponse.builder() + .message("Attempt created successfully") + .data(attemptResponse) + .build(); + return ResponseEntity.status(HttpStatus.CREATED).body(apiResponse); + } + + @GetMapping("/{quizId}/attempts") + @JsonView(AttemptView.Basic.class) + public ResponseEntity getAllAttemptsForQuiz(@PathVariable int quizId) { + List attemptResponseList = attemptService.getAllAttemptsByQuizId(quizId); + ApiResponse apiResponse = ApiResponse.builder() + .message("All attempts for quiz fetched successfully") + .data(attemptResponseList) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + + @GetMapping("/{quizId}/attempts/{attemptId}") + @JsonView(AttemptView.Detail.class) + public ResponseEntity getAttempt(@PathVariable int quizId, @PathVariable int attemptId) { + AttemptResponse attemptResponseList = attemptService.getAttemptByIdAndQuizId(quizId, attemptId); + ApiResponse apiResponse = ApiResponse.builder() + .message("Attempt fetched successfully") + .data(attemptResponseList) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + + @GetMapping("/{quizId}/attempts/{attemptId}/answer") + @JsonView(AttemptView.Answer.class) + public ResponseEntity getAttemptResult(@PathVariable int quizId, @PathVariable int attemptId) { + AttemptResponse attemptResponseList = attemptService.getAttemptByIdAndQuizId(quizId, attemptId); + ApiResponse apiResponse = ApiResponse.builder() + .message("Attempt fetched successfully") + .data(attemptResponseList) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + +// @PatchMapping("/{quizId}/attempts/{attemptId}") +// @JsonView(AttemptView.Answer.class) +// public ResponseEntity updateAttemptDetail(@PathVariable int quizId, @PathVariable int attemptId, @Valid @RequestBody AttemptDetailUpdateRequest request) { +// AttemptResponse attemptResponseList = attemptService.updateAttempt(quizId, attemptId, request); +// ApiResponse apiResponse = ApiResponse.builder() +// .message("Attempt updated successfully") +// .data(attemptResponseList) +// .build(); +// return ResponseEntity.status(HttpStatus.OK).body(apiResponse); +// } +} diff --git a/src/main/java/com/be08/smart_notes/dto/request/AttemptDetailUpdateRequest.java b/src/main/java/com/be08/smart_notes/dto/request/AttemptDetailUpdateRequest.java new file mode 100644 index 0000000..2801978 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/request/AttemptDetailUpdateRequest.java @@ -0,0 +1,20 @@ +package com.be08.smart_notes.dto.request; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import lombok.experimental.FieldDefaults; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) +@JsonIgnoreProperties(ignoreUnknown = true) +public class AttemptDetailUpdateRequest { + @NotNull(message = "ATTEMPT_DETAIL_ID_REQUIRED") + Integer id; + + @NotNull(message = "ATTEMPT_DETAIL_ANSWER_REQUIRED") + Character userAnswer; +} diff --git a/src/main/java/com/be08/smart_notes/dto/response/AttemptResponse.java b/src/main/java/com/be08/smart_notes/dto/response/AttemptResponse.java new file mode 100644 index 0000000..7ecf61a --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/response/AttemptResponse.java @@ -0,0 +1,69 @@ +package com.be08.smart_notes.dto.response; + +import com.be08.smart_notes.dto.view.AttemptView; +import com.fasterxml.jackson.annotation.JsonView; +import lombok.*; +import lombok.experimental.FieldDefaults; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) +public class AttemptResponse { + @JsonView(AttemptView.Basic.class) + Integer id; + + @JsonView(AttemptView.Basic.class) + Integer quizId; + + @JsonView(AttemptView.Basic.class) + LocalDateTime attemptAt; + + @JsonView(AttemptView.Basic.class) + Integer totalQuestion; + + @JsonView(AttemptView.Basic.class) + Integer score; + + @JsonView(AttemptView.Detail.class) + List attemptDetails; + + // Nested static class + @Data + @AllArgsConstructor + @NoArgsConstructor + @FieldDefaults(level = AccessLevel.PRIVATE) + public static class Detail { + @JsonView(AttemptView.Detail.class) + Integer id; + + // Question detail + @JsonView(AttemptView.Detail.class) + String questionText; + + @JsonView(AttemptView.Detail.class) + String optionA; + + @JsonView(AttemptView.Detail.class) + String optionB; + + @JsonView(AttemptView.Detail.class) + String optionC; + + @JsonView(AttemptView.Detail.class) + String optionD; + + @JsonView(AttemptView.Answer.class) + String correctAnswer; + + // User selection + @JsonView(AttemptView.Answer.class) + Character userAnswer; + + @JsonView(AttemptView.Answer.class) + Boolean isCorrect; + } +} diff --git a/src/main/java/com/be08/smart_notes/dto/view/AttemptView.java b/src/main/java/com/be08/smart_notes/dto/view/AttemptView.java new file mode 100644 index 0000000..75192dd --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/view/AttemptView.java @@ -0,0 +1,7 @@ +package com.be08.smart_notes.dto.view; + +public interface AttemptView { + public interface Basic {} + public interface Detail extends AttemptView.Basic {} + public interface Answer extends AttemptView.Detail {} +} diff --git a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java index 56d34c2..ad3d444 100644 --- a/src/main/java/com/be08/smart_notes/exception/ErrorCode.java +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -70,6 +70,14 @@ public enum ErrorCode { QUIZ_SET_NOT_FOUND(2701, "Quiz set not found", HttpStatus.NOT_FOUND), QUIZ_SET_ID_REQUIRED(2702, "Quiz set ID required", HttpStatus.BAD_REQUEST), QUIZ_SET_TITLE_REQUIRED(2703, "Quiz set title required", HttpStatus.BAD_REQUEST), + + // 28xx - Quiz attempt-related features + ATTEMPT_NOT_FOUND(2801, "Quiz attempt not found", HttpStatus.NOT_FOUND), + ATTEMPT_DETAIL_NOT_FOUND(2802, "Quiz attempt detail not found", HttpStatus.NOT_FOUND), + ATTEMPT_ID_REQUIRED(2803, "Quiz attempt id required", HttpStatus.BAD_REQUEST), + ATTEMPT_DETAIL_NOT_EMPTY(2804, "Quiz attempt detail cannot be empty", HttpStatus.BAD_REQUEST), + ATTEMPT_DETAIL_ID_REQUIRED(2805, "Quiz attempt detail must have id", HttpStatus.BAD_REQUEST), + ATTEMPT_DETAIL_ANSWER_REQUIRED(2806, "Quiz attempt detail must include user's answer", HttpStatus.BAD_REQUEST), ; int code; diff --git a/src/main/java/com/be08/smart_notes/mapper/AttemptDetailMapper.java b/src/main/java/com/be08/smart_notes/mapper/AttemptDetailMapper.java new file mode 100644 index 0000000..5c00e73 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/mapper/AttemptDetailMapper.java @@ -0,0 +1,20 @@ +package com.be08.smart_notes.mapper; + +import com.be08.smart_notes.dto.request.AttemptDetailUpdateRequest; +import com.be08.smart_notes.model.AttemptDetail; +import org.mapstruct.*; + +@Mapper(componentModel = "spring") +public interface AttemptDetailMapper { + // For update requests + @BeanMapping(ignoreByDefault = true) + @Mapping(target = "entity.userAnswer", source = "request.userAnswer") + void updateAttemptDetail(@MappingTarget AttemptDetail entity, AttemptDetailUpdateRequest request); + + @AfterMapping + default void updateAttemptResult(@MappingTarget AttemptDetail attemptDetail, AttemptDetailUpdateRequest request) { + Character correct = attemptDetail.getQuestion().getCorrectAnswer(); + Character choice = request.getUserAnswer(); + attemptDetail.setIsCorrect(choice == correct); + } +} diff --git a/src/main/java/com/be08/smart_notes/mapper/AttemptMapper.java b/src/main/java/com/be08/smart_notes/mapper/AttemptMapper.java new file mode 100644 index 0000000..9e8b0d5 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/mapper/AttemptMapper.java @@ -0,0 +1,23 @@ +package com.be08.smart_notes.mapper; + +import com.be08.smart_notes.dto.response.AttemptResponse; +import com.be08.smart_notes.model.Attempt; +import com.be08.smart_notes.model.AttemptDetail; +import org.mapstruct.*; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface AttemptMapper { + @Mapping(target = "quizId", source = "entity.quiz.id") + AttemptResponse toAttemptResponse(Attempt entity); + List toAttemptResponseList(List entity); + + @Mapping(target = "questionText", source = "entity.question.questionText") + @Mapping(target = "optionA", source = "entity.question.optionA") + @Mapping(target = "optionB", source = "entity.question.optionB") + @Mapping(target = "optionC", source = "entity.question.optionC") + @Mapping(target = "optionD", source = "entity.question.optionD") + @Mapping(target = "correctAnswer", source = "entity.question.correctAnswer") + AttemptResponse.Detail toAttemptResponseDetail(AttemptDetail entity); +} diff --git a/src/main/java/com/be08/smart_notes/mapper/QuestionMapper.java b/src/main/java/com/be08/smart_notes/mapper/QuestionMapper.java new file mode 100644 index 0000000..4b9b072 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/mapper/QuestionMapper.java @@ -0,0 +1,38 @@ +package com.be08.smart_notes.mapper; + +import com.be08.smart_notes.dto.QuizUpsertDTO; +import com.be08.smart_notes.model.Question; +import com.be08.smart_notes.model.Quiz; +import org.mapstruct.AfterMapping; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface QuestionMapper { + // For update requests + // Questions are not allowed to be updated, unless delete entire quiz + + // Question Entity <--> QuizUpsertDTO.Question dto + @Mapping(target = "id", ignore = true) + @Mapping(target = "optionA", expression = "java(dto.getOptions()[0])") + @Mapping(target = "optionB", expression = "java(dto.getOptions()[1])") + @Mapping(target = "optionC", expression = "java(dto.getOptions()[2])") + @Mapping(target = "optionD", expression = "java(dto.getOptions()[3])") + @Mapping(target = "correctAnswer", expression = "java(indexToLetter(dto.getCorrectIndex()))") + Question toQuestionEntity(QuizUpsertDTO.Question dto); + + default Character indexToLetter(Integer index) { + return (char) ('A' + index); + } + + @AfterMapping + default void linkQuizToQuestions(@MappingTarget Quiz quiz) { + List questions = quiz.getQuestions(); + for (Question question : questions) { + question.setQuiz(quiz); + } + } +} diff --git a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java index adb2660..b133392 100644 --- a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java @@ -11,7 +11,7 @@ import java.util.List; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", uses = QuestionMapper.class) public interface QuizMapper { // For update requests @Mapping(target = "id", ignore = true) @@ -31,25 +31,4 @@ public interface QuizMapper { // Quiz Entity <--> QuizUpsertDTO Quiz toQuiz(QuizUpsertDTO dto); List toQuizList(List dtoList); - - // Question Entity <--> QuizUpsertDTO.Question dto - @Mapping(target = "id", ignore = true) - @Mapping(target = "optionA", expression = "java(dto.getOptions()[0])") - @Mapping(target = "optionB", expression = "java(dto.getOptions()[1])") - @Mapping(target = "optionC", expression = "java(dto.getOptions()[2])") - @Mapping(target = "optionD", expression = "java(dto.getOptions()[3])") - @Mapping(target = "correctAnswer", expression = "java(indexToLetter(dto.getCorrectIndex()))") - Question toQuestionEntity(QuizUpsertDTO.Question dto); - - default Character indexToLetter(Integer index) { - return (char) ('A' + index); - } - - @AfterMapping - default void linkQuizToQuestions(@MappingTarget Quiz quiz) { - List questions = quiz.getQuestions(); - for (Question question : questions) { - question.setQuiz(quiz); - } - } } diff --git a/src/main/java/com/be08/smart_notes/model/Attempt.java b/src/main/java/com/be08/smart_notes/model/Attempt.java new file mode 100644 index 0000000..de954e4 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/model/Attempt.java @@ -0,0 +1,47 @@ +package com.be08.smart_notes.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.FieldDefaults; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table(name = "attempt") +@FieldDefaults(level = AccessLevel.PRIVATE) +public class Attempt { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Integer id; + + @Column(nullable = false) + LocalDateTime attemptAt; + + @Column(nullable = false, name = "total_question") + Integer totalQuestion; + + @Column + Integer score; + + // Relationship: attempt - quiz + @JsonIgnore + @ManyToOne + @JoinColumn(name = "quiz_id", referencedColumnName = "id") + Quiz quiz; + + // Relationship: attempt - attempt detail + @OneToMany(mappedBy = "attempt", cascade = CascadeType.ALL, orphanRemoval = true) + List attemptDetails; + + @PrePersist + protected void onCreate() { + this.attemptAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/be08/smart_notes/model/AttemptDetail.java b/src/main/java/com/be08/smart_notes/model/AttemptDetail.java new file mode 100644 index 0000000..79109ae --- /dev/null +++ b/src/main/java/com/be08/smart_notes/model/AttemptDetail.java @@ -0,0 +1,36 @@ +package com.be08.smart_notes.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.FieldDefaults; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table(name = "attempt_detail") +@FieldDefaults(level = AccessLevel.PRIVATE) +public class AttemptDetail { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Integer id; + + // Relationship: attempt detail - attempt + @JsonIgnore + @ManyToOne + @JoinColumn(name = "attempt_id", referencedColumnName = "id") + Attempt attempt; + + // Relationship: attempt detail - question (Unidirectional) + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "question_id", referencedColumnName = "id") + Question question; + + @Column(name = "user_answer") + Character userAnswer; + + @Column(name = "is_correct") + Boolean isCorrect; +} diff --git a/src/main/java/com/be08/smart_notes/model/Quiz.java b/src/main/java/com/be08/smart_notes/model/Quiz.java index ab71f3b..3ad7d6b 100644 --- a/src/main/java/com/be08/smart_notes/model/Quiz.java +++ b/src/main/java/com/be08/smart_notes/model/Quiz.java @@ -8,7 +8,8 @@ import java.time.LocalDateTime; import java.util.List; -@Data +@Getter +@Setter @NoArgsConstructor @AllArgsConstructor @Builder @@ -42,6 +43,10 @@ public class Quiz { @OneToMany(mappedBy = "quiz", cascade = CascadeType.ALL, orphanRemoval = true) List questions; + // Relationship: quiz - attempt + @OneToMany(mappedBy = "quiz", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + List attempts; + @PrePersist protected void onCreate() { this.createdAt = LocalDateTime.now(); diff --git a/src/main/java/com/be08/smart_notes/repository/AttemptDetailRepository.java b/src/main/java/com/be08/smart_notes/repository/AttemptDetailRepository.java new file mode 100644 index 0000000..7325a7e --- /dev/null +++ b/src/main/java/com/be08/smart_notes/repository/AttemptDetailRepository.java @@ -0,0 +1,12 @@ +package com.be08.smart_notes.repository; + +import com.be08.smart_notes.model.AttemptDetail; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface AttemptDetailRepository extends JpaRepository { + Optional findByIdAndAttemptId(int id, int attemptId); +} diff --git a/src/main/java/com/be08/smart_notes/repository/AttemptRepository.java b/src/main/java/com/be08/smart_notes/repository/AttemptRepository.java new file mode 100644 index 0000000..4f91a1f --- /dev/null +++ b/src/main/java/com/be08/smart_notes/repository/AttemptRepository.java @@ -0,0 +1,15 @@ +package com.be08.smart_notes.repository; + +import com.be08.smart_notes.model.Attempt; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface AttemptRepository extends JpaRepository { + Optional findByIdAndQuiz_QuizSet_UserId(int id, int userId); + + List findByQuizIdAndQuiz_QuizSet_UserId(int quizId, int userId); +} diff --git a/src/main/java/com/be08/smart_notes/service/AttemptService.java b/src/main/java/com/be08/smart_notes/service/AttemptService.java new file mode 100644 index 0000000..65baec1 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/AttemptService.java @@ -0,0 +1,105 @@ +package com.be08.smart_notes.service; + +import com.be08.smart_notes.dto.request.AttemptDetailUpdateRequest; +import com.be08.smart_notes.dto.response.AttemptResponse; +import com.be08.smart_notes.exception.AppException; +import com.be08.smart_notes.exception.ErrorCode; +import com.be08.smart_notes.mapper.AttemptDetailMapper; +import com.be08.smart_notes.mapper.AttemptMapper; +import com.be08.smart_notes.model.Attempt; +import com.be08.smart_notes.model.AttemptDetail; +import com.be08.smart_notes.model.Question; +import com.be08.smart_notes.model.Quiz; +import com.be08.smart_notes.repository.AttemptDetailRepository; +import com.be08.smart_notes.repository.AttemptRepository; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@Slf4j +public class AttemptService { + AuthorizationService authorizationService; + QuizService quizService; + AttemptRepository attemptRepository; + AttemptDetailRepository attemptDetailRepository; + AttemptMapper attemptMapper; + AttemptDetailMapper attemptDetailMapper; + + public AttemptResponse createNewAttempt(int quizId) { + int currentUserId = authorizationService.getCurrentUserId(); + + Quiz quiz = quizService.getQuizById(quizId, currentUserId); + Attempt newAttempt = Attempt.builder() + .totalQuestion(quiz.getQuestions().size()) + .quiz(quiz) + .build(); + + // Init and add all attempt details with associated questions + List attemptDetailList = new ArrayList<>(); + for (Question question : quiz.getQuestions()) { + AttemptDetail attemptDetail = AttemptDetail.builder() + .attempt(newAttempt) + .question(question).build(); + attemptDetailList.add(attemptDetail); + // Other fields (user answer and correctness) are null at default + // They can be updated through patch request, after the user finish their attempt + } + newAttempt.setAttemptDetails(attemptDetailList); + + Attempt savedAttempt = attemptRepository.save(newAttempt); + return attemptMapper.toAttemptResponse(savedAttempt); + } + + public AttemptResponse getAttemptByIdAndQuizId(int quizId, int attemptId) { + int currentUserId = authorizationService.getCurrentUserId(); + + Attempt attempt = getAttemptEntityByIdAndQuizId(quizId, attemptId, currentUserId); + + return attemptMapper.toAttemptResponse(attempt); + } + + public List getAllAttemptsByQuizId(int quizId) { + int currentUserId = authorizationService.getCurrentUserId(); + + List attempts = attemptRepository.findByQuizIdAndQuiz_QuizSet_UserId(quizId, currentUserId); + + return attemptMapper.toAttemptResponseList(attempts); + } + +// public AttemptResponse updateAttempt(int quizId, int attemptId, AttemptDetailUpdateRequest request) { +// int currentUserId = authorizationService.getCurrentUserId(); +// +// // Get quiz to validate user id (owner) +// Quiz quiz = quizService.getQuizById(quizId, currentUserId); +// +// AttemptDetail existingAttemptDetail = attemptDetailRepository.findByIdAndAttemptId(request.getId(), attemptId).orElseThrow(() -> { +// log.error("Attempt detail with id {} not found in attempt {}", request.getId(), attemptId); +// return new AppException(ErrorCode.ATTEMPT_DETAIL_NOT_FOUND); +// }); +// +// attemptDetailMapper.updateAttemptDetail(existingAttempt, request); +// existingAttempt = attemptRepository.save(existingAttempt); +// return attemptMapper.toAttemptResponse(existingAttempt); +// } + + // ------ Methods that returns entity ------ // + public Attempt getAttemptEntityByIdAndQuizId(int quizId, int attemptId, int userId) { + Attempt attempt = attemptRepository.findByIdAndQuiz_QuizSet_UserId(attemptId, userId).orElseThrow(() -> { + log.error("Attempt with id {} not found in user's account", attemptId); + return new AppException(ErrorCode.ATTEMPT_NOT_FOUND); + }); + if (attempt.getQuiz().getId() != quizId) { + log.error("Attempt with id {} not found for quiz {}", attemptId, quizId); + throw new AppException(ErrorCode.ATTEMPT_NOT_FOUND); + } + return attempt; + } +} diff --git a/src/main/java/com/be08/smart_notes/service/QuizService.java b/src/main/java/com/be08/smart_notes/service/QuizService.java index e2a69b3..3183856 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizService.java @@ -57,10 +57,7 @@ public QuizResponse createQuiz(QuizUpsertDTO quizUpsertDTO) { public QuizResponse getQuizById(int quizId) { int currentUserId = authorizationService.getCurrentUserId(); - Quiz quiz = quizRepository.findByIdAndQuizSetUserId(quizId, currentUserId).orElseThrow(() -> { - log.error("Quiz with id {} not found in user's account", quizId); - return new AppException(ErrorCode.QUIZ_NOT_FOUND); - }); + Quiz quiz = getQuizById(quizId, currentUserId); return quizMapper.toQuizResponse(quiz); } @@ -103,4 +100,12 @@ public void deleteQuizById(int quizId) { quizRepository.deleteById(quizId); } + + // ------ Methods that returns entity ------ // + public Quiz getQuizById(int quizId, int userId) { + return quizRepository.findByIdAndQuizSetUserId(quizId, userId).orElseThrow(() -> { + log.error("Quiz with id {} not found in user's account", quizId); + return new AppException(ErrorCode.QUIZ_NOT_FOUND); + }); + } } From 72c55a95bab058e8cef6958d84be9bb1ec91ea25 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Sun, 9 Nov 2025 01:25:23 +1100 Subject: [PATCH 38/63] feat(quiz-attempt): add update functionality and calculate attempt result --- .../controller/AttemptController.java | 35 +++++++++----- .../mapper/AttemptDetailMapper.java | 10 ++++ .../smart_notes/mapper/AttemptMapper.java | 11 +---- .../smart_notes/service/AttemptService.java | 48 +++++++++++++------ 4 files changed, 68 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/be08/smart_notes/controller/AttemptController.java b/src/main/java/com/be08/smart_notes/controller/AttemptController.java index cfa0834..2cd20f8 100644 --- a/src/main/java/com/be08/smart_notes/controller/AttemptController.java +++ b/src/main/java/com/be08/smart_notes/controller/AttemptController.java @@ -1,10 +1,12 @@ package com.be08.smart_notes.controller; +import com.be08.smart_notes.dto.request.AttemptDetailUpdateRequest; import com.be08.smart_notes.dto.response.ApiResponse; import com.be08.smart_notes.dto.response.AttemptResponse; import com.be08.smart_notes.dto.view.AttemptView; import com.be08.smart_notes.service.AttemptService; import com.fasterxml.jackson.annotation.JsonView; +import jakarta.validation.Valid; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; @@ -60,20 +62,31 @@ public ResponseEntity getAttempt(@PathVariable int quizId, @PathVariable public ResponseEntity getAttemptResult(@PathVariable int quizId, @PathVariable int attemptId) { AttemptResponse attemptResponseList = attemptService.getAttemptByIdAndQuizId(quizId, attemptId); ApiResponse apiResponse = ApiResponse.builder() - .message("Attempt fetched successfully") + .message("Attempt result fetched successfully") .data(attemptResponseList) .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } -// @PatchMapping("/{quizId}/attempts/{attemptId}") -// @JsonView(AttemptView.Answer.class) -// public ResponseEntity updateAttemptDetail(@PathVariable int quizId, @PathVariable int attemptId, @Valid @RequestBody AttemptDetailUpdateRequest request) { -// AttemptResponse attemptResponseList = attemptService.updateAttempt(quizId, attemptId, request); -// ApiResponse apiResponse = ApiResponse.builder() -// .message("Attempt updated successfully") -// .data(attemptResponseList) -// .build(); -// return ResponseEntity.status(HttpStatus.OK).body(apiResponse); -// } + @PostMapping("/{quizId}/attempts/{attemptId}/answer") + @JsonView(AttemptView.Answer.class) + public ResponseEntity finishAttempt(@PathVariable int quizId, @PathVariable int attemptId) { + AttemptResponse attemptResponseList = attemptService.calculateAttemptResult(quizId, attemptId); + ApiResponse apiResponse = ApiResponse.builder() + .message("Attempt result calculated successfully") + .data(attemptResponseList) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + + @PatchMapping("/{quizId}/attempts/{attemptId}") + @JsonView(AttemptView.Answer.class) + public ResponseEntity updateAttemptDetail(@PathVariable int quizId, @PathVariable int attemptId, @Valid @RequestBody AttemptDetailUpdateRequest request) { + AttemptResponse.Detail attemptDetailResponse = attemptService.updateAttemptDetail(quizId, attemptId, request); + ApiResponse apiResponse = ApiResponse.builder() + .message("Attempt detail updated successfully") + .data(attemptDetailResponse) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } } diff --git a/src/main/java/com/be08/smart_notes/mapper/AttemptDetailMapper.java b/src/main/java/com/be08/smart_notes/mapper/AttemptDetailMapper.java index 5c00e73..f0f5be3 100644 --- a/src/main/java/com/be08/smart_notes/mapper/AttemptDetailMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/AttemptDetailMapper.java @@ -1,6 +1,7 @@ package com.be08.smart_notes.mapper; import com.be08.smart_notes.dto.request.AttemptDetailUpdateRequest; +import com.be08.smart_notes.dto.response.AttemptResponse; import com.be08.smart_notes.model.AttemptDetail; import org.mapstruct.*; @@ -11,6 +12,15 @@ public interface AttemptDetailMapper { @Mapping(target = "entity.userAnswer", source = "request.userAnswer") void updateAttemptDetail(@MappingTarget AttemptDetail entity, AttemptDetailUpdateRequest request); + // + @Mapping(target = "questionText", source = "entity.question.questionText") + @Mapping(target = "optionA", source = "entity.question.optionA") + @Mapping(target = "optionB", source = "entity.question.optionB") + @Mapping(target = "optionC", source = "entity.question.optionC") + @Mapping(target = "optionD", source = "entity.question.optionD") + @Mapping(target = "correctAnswer", source = "entity.question.correctAnswer") + AttemptResponse.Detail toAttemptResponseDetail(AttemptDetail entity); + @AfterMapping default void updateAttemptResult(@MappingTarget AttemptDetail attemptDetail, AttemptDetailUpdateRequest request) { Character correct = attemptDetail.getQuestion().getCorrectAnswer(); diff --git a/src/main/java/com/be08/smart_notes/mapper/AttemptMapper.java b/src/main/java/com/be08/smart_notes/mapper/AttemptMapper.java index 9e8b0d5..9d84c0b 100644 --- a/src/main/java/com/be08/smart_notes/mapper/AttemptMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/AttemptMapper.java @@ -2,22 +2,13 @@ import com.be08.smart_notes.dto.response.AttemptResponse; import com.be08.smart_notes.model.Attempt; -import com.be08.smart_notes.model.AttemptDetail; import org.mapstruct.*; import java.util.List; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", uses = AttemptDetailMapper.class) public interface AttemptMapper { @Mapping(target = "quizId", source = "entity.quiz.id") AttemptResponse toAttemptResponse(Attempt entity); List toAttemptResponseList(List entity); - - @Mapping(target = "questionText", source = "entity.question.questionText") - @Mapping(target = "optionA", source = "entity.question.optionA") - @Mapping(target = "optionB", source = "entity.question.optionB") - @Mapping(target = "optionC", source = "entity.question.optionC") - @Mapping(target = "optionD", source = "entity.question.optionD") - @Mapping(target = "correctAnswer", source = "entity.question.correctAnswer") - AttemptResponse.Detail toAttemptResponseDetail(AttemptDetail entity); } diff --git a/src/main/java/com/be08/smart_notes/service/AttemptService.java b/src/main/java/com/be08/smart_notes/service/AttemptService.java index 65baec1..c49dd1c 100644 --- a/src/main/java/com/be08/smart_notes/service/AttemptService.java +++ b/src/main/java/com/be08/smart_notes/service/AttemptService.java @@ -74,21 +74,39 @@ public List getAllAttemptsByQuizId(int quizId) { return attemptMapper.toAttemptResponseList(attempts); } -// public AttemptResponse updateAttempt(int quizId, int attemptId, AttemptDetailUpdateRequest request) { -// int currentUserId = authorizationService.getCurrentUserId(); -// -// // Get quiz to validate user id (owner) -// Quiz quiz = quizService.getQuizById(quizId, currentUserId); -// -// AttemptDetail existingAttemptDetail = attemptDetailRepository.findByIdAndAttemptId(request.getId(), attemptId).orElseThrow(() -> { -// log.error("Attempt detail with id {} not found in attempt {}", request.getId(), attemptId); -// return new AppException(ErrorCode.ATTEMPT_DETAIL_NOT_FOUND); -// }); -// -// attemptDetailMapper.updateAttemptDetail(existingAttempt, request); -// existingAttempt = attemptRepository.save(existingAttempt); -// return attemptMapper.toAttemptResponse(existingAttempt); -// } + public AttemptResponse.Detail updateAttemptDetail(int quizId, int attemptId, AttemptDetailUpdateRequest request) { + int currentUserId = authorizationService.getCurrentUserId(); + + // Re-use method to check if the information is valid + getAttemptEntityByIdAndQuizId(quizId, attemptId, currentUserId); + + AttemptDetail existingAttemptDetail = attemptDetailRepository.findByIdAndAttemptId(request.getId(), attemptId).orElseThrow(() -> { + log.error("Attempt detail with id {} not found in attempt {}", request.getId(), attemptId); + return new AppException(ErrorCode.ATTEMPT_DETAIL_NOT_FOUND); + }); + + attemptDetailMapper.updateAttemptDetail(existingAttemptDetail, request); + existingAttemptDetail = attemptDetailRepository.save(existingAttemptDetail); + return attemptDetailMapper.toAttemptResponseDetail(existingAttemptDetail); + } + + public AttemptResponse calculateAttemptResult(int quizId, int attemptId) { + int currentUserId = authorizationService.getCurrentUserId(); + + Attempt existingAttempt = getAttemptEntityByIdAndQuizId(quizId, attemptId, currentUserId); + int score = 0; + for (AttemptDetail detail : existingAttempt.getAttemptDetails()) { + if (detail.getIsCorrect() == null || !detail.getIsCorrect()) { + continue; + } + score += 1; + } + existingAttempt.setScore(score); + + existingAttempt = attemptRepository.save(existingAttempt); + + return attemptMapper.toAttemptResponse(existingAttempt); + } // ------ Methods that returns entity ------ // public Attempt getAttemptEntityByIdAndQuizId(int quizId, int attemptId, int userId) { From b7b3852eb475add509f3a3e0a9e8d0e52234d886 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Sun, 9 Nov 2025 01:32:27 +1100 Subject: [PATCH 39/63] feat(quiz-attempt): add delete quiz attempt endpoint --- .../be08/smart_notes/controller/AttemptController.java | 9 +++++++++ .../com/be08/smart_notes/service/AttemptService.java | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/src/main/java/com/be08/smart_notes/controller/AttemptController.java b/src/main/java/com/be08/smart_notes/controller/AttemptController.java index 2cd20f8..5dc05f4 100644 --- a/src/main/java/com/be08/smart_notes/controller/AttemptController.java +++ b/src/main/java/com/be08/smart_notes/controller/AttemptController.java @@ -89,4 +89,13 @@ public ResponseEntity updateAttemptDetail(@PathVariable int quizId, @Pat .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } + + @DeleteMapping("/{quizId}/attempts/{attemptId}") + public ResponseEntity deleteAttempt(@PathVariable int quizId, @PathVariable int attemptId) { + attemptService.deleteAttemptByIdAndQuizId(quizId, attemptId); + ApiResponse apiResponse = ApiResponse.builder() + .message("Attempt deleted successfully") + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } } diff --git a/src/main/java/com/be08/smart_notes/service/AttemptService.java b/src/main/java/com/be08/smart_notes/service/AttemptService.java index c49dd1c..2a4f64b 100644 --- a/src/main/java/com/be08/smart_notes/service/AttemptService.java +++ b/src/main/java/com/be08/smart_notes/service/AttemptService.java @@ -108,6 +108,14 @@ public AttemptResponse calculateAttemptResult(int quizId, int attemptId) { return attemptMapper.toAttemptResponse(existingAttempt); } + public void deleteAttemptByIdAndQuizId(int quizId, int attemptId) { + int currentUserId = authorizationService.getCurrentUserId(); + + Attempt attempt = getAttemptEntityByIdAndQuizId(quizId, attemptId, currentUserId); + + attemptRepository.delete(attempt); + } + // ------ Methods that returns entity ------ // public Attempt getAttemptEntityByIdAndQuizId(int quizId, int attemptId, int userId) { Attempt attempt = attemptRepository.findByIdAndQuiz_QuizSet_UserId(attemptId, userId).orElseThrow(() -> { From f045d32fafb2025c23ea7c70a6eacfc669f4a882 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Sun, 9 Nov 2025 14:30:05 +1100 Subject: [PATCH 40/63] refactor(quiz-attempt): simplify update mapping with ignoreByDefault --- .../com/be08/smart_notes/mapper/AttemptDetailMapper.java | 2 +- .../java/com/be08/smart_notes/mapper/AttemptMapper.java | 4 ++++ src/main/java/com/be08/smart_notes/mapper/QuizMapper.java | 6 +----- .../java/com/be08/smart_notes/mapper/QuizSetMapper.java | 8 ++------ 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/be08/smart_notes/mapper/AttemptDetailMapper.java b/src/main/java/com/be08/smart_notes/mapper/AttemptDetailMapper.java index f0f5be3..64ce230 100644 --- a/src/main/java/com/be08/smart_notes/mapper/AttemptDetailMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/AttemptDetailMapper.java @@ -12,7 +12,7 @@ public interface AttemptDetailMapper { @Mapping(target = "entity.userAnswer", source = "request.userAnswer") void updateAttemptDetail(@MappingTarget AttemptDetail entity, AttemptDetailUpdateRequest request); - // + // AttemptDetail entity <--> AttemptResponse.Detail dto @Mapping(target = "questionText", source = "entity.question.questionText") @Mapping(target = "optionA", source = "entity.question.optionA") @Mapping(target = "optionB", source = "entity.question.optionB") diff --git a/src/main/java/com/be08/smart_notes/mapper/AttemptMapper.java b/src/main/java/com/be08/smart_notes/mapper/AttemptMapper.java index 9d84c0b..110edeb 100644 --- a/src/main/java/com/be08/smart_notes/mapper/AttemptMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/AttemptMapper.java @@ -8,6 +8,10 @@ @Mapper(componentModel = "spring", uses = AttemptDetailMapper.class) public interface AttemptMapper { + // For update requests + // Attempts does not have fields to be updated, data should be update are attempt details only + + // Attempt entity <--> AttemptResponse dto @Mapping(target = "quizId", source = "entity.quiz.id") AttemptResponse toAttemptResponse(Attempt entity); List toAttemptResponseList(List entity); diff --git a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java index b133392..338d445 100644 --- a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java @@ -14,13 +14,9 @@ @Mapper(componentModel = "spring", uses = QuestionMapper.class) public interface QuizMapper { // For update requests - @Mapping(target = "id", ignore = true) - @Mapping(target = "createdAt", ignore = true) - @Mapping(target = "updatedAt", ignore = true) + @BeanMapping(ignoreByDefault = true) @Mapping(target = "title", source = "title") @Mapping(target = "sourceDocumentId", source = "sourceDocumentId") - @Mapping(target = "quizSet", ignore = true) - @Mapping(target = "questions", ignore = true) void updateQuiz(@MappingTarget Quiz quiz, QuizUpsertDTO request); // Quiz Entity <--> QuizResponse dto diff --git a/src/main/java/com/be08/smart_notes/mapper/QuizSetMapper.java b/src/main/java/com/be08/smart_notes/mapper/QuizSetMapper.java index 16f5bf2..7b484ce 100644 --- a/src/main/java/com/be08/smart_notes/mapper/QuizSetMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/QuizSetMapper.java @@ -3,6 +3,7 @@ import com.be08.smart_notes.dto.request.QuizSetUpsertRequest; import com.be08.smart_notes.dto.response.QuizSetResponse; import com.be08.smart_notes.model.QuizSet; +import org.mapstruct.BeanMapping; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; @@ -12,13 +13,8 @@ @Mapper(componentModel = "spring", uses = QuizMapper.class) public interface QuizSetMapper { // For update request - @Mapping(target = "id", ignore = true) - @Mapping(target = "userId", ignore = true) - @Mapping(target = "originType", ignore = true) - @Mapping(target = "createdAt", ignore = true) - @Mapping(target = "updatedAt", ignore = true) + @BeanMapping(ignoreByDefault = true) @Mapping(target = "title", source = "title") - @Mapping(target = "quizzes", ignore = true) void updateQuizSet(@MappingTarget QuizSet quizSet, QuizSetUpsertRequest request); // QuizSet entity <--> QuizSetResponse dto From 4c1c04d1c9b28ecc6cf6b9c03ff0cb12dc461529 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Sun, 9 Nov 2025 14:33:52 +1100 Subject: [PATCH 41/63] refactor(ai-quiz-attempt): restructure json views for clarity and update response status code --- .../controller/AIGenerationController.java | 4 ++-- .../smart_notes/controller/QuizController.java | 10 +++++----- .../controller/QuizSetController.java | 12 ++++++------ .../smart_notes/dto/response/QuizResponse.java | 16 ++++++++-------- .../dto/response/QuizSetResponse.java | 14 +++++++------- .../be08/smart_notes/dto/view/AttemptView.java | 6 ++---- .../com/be08/smart_notes/dto/view/QuizView.java | 4 ++++ .../dto/view/{Level.java => View.java} | 2 +- 8 files changed, 35 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/be08/smart_notes/dto/view/QuizView.java rename src/main/java/com/be08/smart_notes/dto/view/{Level.java => View.java} (82%) diff --git a/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java b/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java index afdb4de..c6c70ba 100644 --- a/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java +++ b/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java @@ -40,7 +40,7 @@ public ResponseEntity generateQuiz(@Validated(SingleDocument.class) @Req .message("Quiz successfully generated and added to default set") .data(quizSetResponse) .build(); - return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + return ResponseEntity.status(HttpStatus.CREATED).body(apiResponse); } @PostMapping("/quiz-sets") @@ -50,6 +50,6 @@ public ResponseEntity generateQuizSet(@Validated(MultipleDocument.class) .message("Quiz set successfully generated") .data(quizSetResponse) .build(); - return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + return ResponseEntity.status(HttpStatus.CREATED).body(apiResponse); } } diff --git a/src/main/java/com/be08/smart_notes/controller/QuizController.java b/src/main/java/com/be08/smart_notes/controller/QuizController.java index 30bc359..db2375a 100644 --- a/src/main/java/com/be08/smart_notes/controller/QuizController.java +++ b/src/main/java/com/be08/smart_notes/controller/QuizController.java @@ -3,7 +3,7 @@ import com.be08.smart_notes.dto.QuizUpsertDTO; import com.be08.smart_notes.dto.response.ApiResponse; import com.be08.smart_notes.dto.response.QuizResponse; -import com.be08.smart_notes.dto.view.Level; +import com.be08.smart_notes.dto.view.QuizView; import com.be08.smart_notes.service.QuizService; import com.be08.smart_notes.validation.group.OnCreate; import com.be08.smart_notes.validation.group.OnUpdate; @@ -24,18 +24,18 @@ public class QuizController { QuizService quizService; @PostMapping - @JsonView(Level.Detail.class) + @JsonView(QuizView.Detail.class) public ResponseEntity createQuiz(@RequestBody @Validated(OnCreate.class) QuizUpsertDTO request) { QuizResponse quizResponse = quizService.createQuiz(request); ApiResponse apiResponse = ApiResponse.builder() .message("Quiz created successfully in default set") .data(quizResponse) .build(); - return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + return ResponseEntity.status(HttpStatus.CREATED).body(apiResponse); } @GetMapping("/{id}") - @JsonView(Level.Detail.class) + @JsonView(QuizView.Detail.class) public ResponseEntity getQuiz(@PathVariable int id) { QuizResponse quizResponse = quizService.getQuizById(id); ApiResponse apiResponse = ApiResponse.builder() @@ -46,7 +46,7 @@ public ResponseEntity getQuiz(@PathVariable int id) { } @PutMapping("/{id}") - @JsonView(Level.Detail.class) + @JsonView(QuizView.Detail.class) public ResponseEntity updateQuiz(@PathVariable int id, @Validated(OnUpdate.class) @RequestBody QuizUpsertDTO request) { QuizResponse quizResponse = quizService.updateQuiz(id, request); ApiResponse apiResponse = ApiResponse.builder() diff --git a/src/main/java/com/be08/smart_notes/controller/QuizSetController.java b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java index f492c1b..021faaf 100644 --- a/src/main/java/com/be08/smart_notes/controller/QuizSetController.java +++ b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java @@ -3,7 +3,7 @@ import com.be08.smart_notes.dto.request.QuizSetUpsertRequest; import com.be08.smart_notes.dto.response.ApiResponse; import com.be08.smart_notes.dto.response.QuizSetResponse; -import com.be08.smart_notes.dto.view.Level; +import com.be08.smart_notes.dto.view.QuizView; import com.be08.smart_notes.enums.OriginType; import com.be08.smart_notes.service.QuizSetService; import com.fasterxml.jackson.annotation.JsonView; @@ -25,18 +25,18 @@ public class QuizSetController { QuizSetService quizSetService; @PostMapping - @JsonView(Level.Detail.class) + @JsonView(QuizView.Detail.class) public ResponseEntity createQuizSet(@RequestBody @Valid QuizSetUpsertRequest request) { QuizSetResponse quizSetResponse = quizSetService.createQuizSet(request, OriginType.USER); ApiResponse apiResponse = ApiResponse.builder() .message("Quiz set created successfully") .data(quizSetResponse) .build(); - return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + return ResponseEntity.status(HttpStatus.CREATED).body(apiResponse); } @GetMapping - @JsonView(Level.Basic.class) + @JsonView(QuizView.Basic.class) public ResponseEntity getAllQuizSets() { List quizSetResponseList = quizSetService.getAllQuizSets(); ApiResponse apiResponse = ApiResponse.builder() @@ -47,7 +47,7 @@ public ResponseEntity getAllQuizSets() { } @GetMapping("/{id}") - @JsonView(Level.Detail.class) + @JsonView(QuizView.Detail.class) public ResponseEntity getQuizSet(@PathVariable int id) { QuizSetResponse quizSetResponse = quizSetService.getQuizSetById(id); ApiResponse apiResponse = ApiResponse.builder() @@ -58,7 +58,7 @@ public ResponseEntity getQuizSet(@PathVariable int id) { } @PutMapping("/{id}") - @JsonView(Level.Detail.class) + @JsonView(QuizView.Detail.class) public ResponseEntity updateQuizSet(@PathVariable int id, @RequestBody QuizSetUpsertRequest request) { QuizSetResponse quizSetResponse = quizSetService.updateQuizSet(id, request); ApiResponse apiResponse = ApiResponse.builder() diff --git a/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java index 15628e0..2254134 100644 --- a/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java @@ -3,7 +3,7 @@ import java.time.LocalDateTime; import java.util.List; -import com.be08.smart_notes.dto.view.Level; +import com.be08.smart_notes.dto.view.QuizView; import com.fasterxml.jackson.annotation.JsonView; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -16,25 +16,25 @@ @AllArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE) public class QuizResponse { - @JsonView(Level.Basic.class) + @JsonView(QuizView.Basic.class) Integer id; - @JsonView(Level.Basic.class) + @JsonView(QuizView.Basic.class) String title; - @JsonView(Level.Basic.class) + @JsonView(QuizView.Basic.class) Integer quizSetId; - @JsonView(Level.Basic.class) + @JsonView(QuizView.Basic.class) Integer sourceDocumentId; - @JsonView(Level.Basic.class) + @JsonView(QuizView.Basic.class) LocalDateTime createdAt; - @JsonView(Level.Basic.class) + @JsonView(QuizView.Basic.class) LocalDateTime updatedAt; - @JsonView(Level.Detail.class) + @JsonView(QuizView.Detail.class) List questions; // Static nested class diff --git a/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java b/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java index 686a250..609cc0c 100644 --- a/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java @@ -1,6 +1,6 @@ package com.be08.smart_notes.dto.response; -import com.be08.smart_notes.dto.view.Level; +import com.be08.smart_notes.dto.view.QuizView; import com.be08.smart_notes.enums.OriginType; import com.fasterxml.jackson.annotation.JsonView; import lombok.AccessLevel; @@ -17,21 +17,21 @@ @AllArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE) public class QuizSetResponse { - @JsonView(Level.Basic.class) + @JsonView(QuizView.Basic.class) Integer id; - @JsonView(Level.Basic.class) + @JsonView(QuizView.Basic.class) String title; - @JsonView(Level.Basic.class) + @JsonView(QuizView.Basic.class) OriginType originType; - @JsonView(Level.Basic.class) + @JsonView(QuizView.Basic.class) LocalDateTime createdAt; - @JsonView(Level.Basic.class) + @JsonView(QuizView.Basic.class) LocalDateTime updatedAt; - @JsonView(Level.Detail.class) + @JsonView(QuizView.Detail.class) List quizzes; } diff --git a/src/main/java/com/be08/smart_notes/dto/view/AttemptView.java b/src/main/java/com/be08/smart_notes/dto/view/AttemptView.java index 75192dd..4534bbe 100644 --- a/src/main/java/com/be08/smart_notes/dto/view/AttemptView.java +++ b/src/main/java/com/be08/smart_notes/dto/view/AttemptView.java @@ -1,7 +1,5 @@ package com.be08.smart_notes.dto.view; -public interface AttemptView { - public interface Basic {} - public interface Detail extends AttemptView.Basic {} - public interface Answer extends AttemptView.Detail {} +public interface AttemptView extends View { + public interface Answer extends Detail {} } diff --git a/src/main/java/com/be08/smart_notes/dto/view/QuizView.java b/src/main/java/com/be08/smart_notes/dto/view/QuizView.java new file mode 100644 index 0000000..0940422 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/view/QuizView.java @@ -0,0 +1,4 @@ +package com.be08.smart_notes.dto.view; + +public interface QuizView extends View { +} diff --git a/src/main/java/com/be08/smart_notes/dto/view/Level.java b/src/main/java/com/be08/smart_notes/dto/view/View.java similarity index 82% rename from src/main/java/com/be08/smart_notes/dto/view/Level.java rename to src/main/java/com/be08/smart_notes/dto/view/View.java index 9d7b660..7ca0729 100644 --- a/src/main/java/com/be08/smart_notes/dto/view/Level.java +++ b/src/main/java/com/be08/smart_notes/dto/view/View.java @@ -1,6 +1,6 @@ package com.be08.smart_notes.dto.view; -public interface Level { +public interface View { public interface Basic {} public interface Detail extends Basic {} } From 73590d5a48973eefc7a3ce87973d622c396b8897 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Sun, 9 Nov 2025 14:34:53 +1100 Subject: [PATCH 42/63] refactor(ai-quiz): ignore unknown fields in requests --- .../com/be08/smart_notes/dto/request/QuizGenerationRequest.java | 2 ++ .../com/be08/smart_notes/dto/request/QuizSetUpsertRequest.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java b/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java index eb6339b..b01cd8b 100644 --- a/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java +++ b/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java @@ -2,6 +2,7 @@ import com.be08.smart_notes.validation.group.MultipleDocument; import com.be08.smart_notes.validation.group.SingleDocument; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.validation.constraints.*; import lombok.*; import lombok.experimental.FieldDefaults; @@ -13,6 +14,7 @@ @AllArgsConstructor @Builder @FieldDefaults(level = AccessLevel.PRIVATE) +@JsonIgnoreProperties(ignoreUnknown = true) public class QuizGenerationRequest { @NotNull(groups = SingleDocument.class, message = "DOCUMENT_ID_REQUIRED") Integer docId; diff --git a/src/main/java/com/be08/smart_notes/dto/request/QuizSetUpsertRequest.java b/src/main/java/com/be08/smart_notes/dto/request/QuizSetUpsertRequest.java index 98006e6..1359f43 100644 --- a/src/main/java/com/be08/smart_notes/dto/request/QuizSetUpsertRequest.java +++ b/src/main/java/com/be08/smart_notes/dto/request/QuizSetUpsertRequest.java @@ -1,5 +1,6 @@ package com.be08.smart_notes.dto.request; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.validation.constraints.NotBlank; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -11,6 +12,7 @@ @NoArgsConstructor @AllArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE) +@JsonIgnoreProperties(ignoreUnknown = true) public class QuizSetUpsertRequest { @NotBlank(message = "QUIZ_SET_TITLE_REQUIRED") String title; From 03efa34c538e59e42948a5b082790d706d17747b Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Sun, 9 Nov 2025 14:50:18 +1100 Subject: [PATCH 43/63] feat(quiz): remove source document id mapping to restrict update for data integrity --- src/main/java/com/be08/smart_notes/mapper/QuizMapper.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java index 338d445..3c2cab2 100644 --- a/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java @@ -1,12 +1,8 @@ package com.be08.smart_notes.mapper; import com.be08.smart_notes.dto.QuizUpsertDTO; -import com.be08.smart_notes.dto.request.QuizSetUpsertRequest; import com.be08.smart_notes.dto.response.QuizResponse; -import com.be08.smart_notes.dto.response.QuizSetResponse; -import com.be08.smart_notes.model.Question; import com.be08.smart_notes.model.Quiz; -import com.be08.smart_notes.model.QuizSet; import org.mapstruct.*; import java.util.List; @@ -16,7 +12,6 @@ public interface QuizMapper { // For update requests @BeanMapping(ignoreByDefault = true) @Mapping(target = "title", source = "title") - @Mapping(target = "sourceDocumentId", source = "sourceDocumentId") void updateQuiz(@MappingTarget Quiz quiz, QuizUpsertDTO request); // Quiz Entity <--> QuizResponse dto From 9960cc9e8feabeda4c3664e68e769b8cb95c867f Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Sun, 9 Nov 2025 15:23:41 +1100 Subject: [PATCH 44/63] docs(quiz-attempt): update Javadoc for attempt service --- .../smart_notes/service/AttemptService.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/main/java/com/be08/smart_notes/service/AttemptService.java b/src/main/java/com/be08/smart_notes/service/AttemptService.java index 2a4f64b..2b255a0 100644 --- a/src/main/java/com/be08/smart_notes/service/AttemptService.java +++ b/src/main/java/com/be08/smart_notes/service/AttemptService.java @@ -33,6 +33,11 @@ public class AttemptService { AttemptMapper attemptMapper; AttemptDetailMapper attemptDetailMapper; + /** + * Create new attempt for a given quiz. This also map associated questions to attempts details. + * @param quizId id of target quiz + * @return attempt response dto + */ public AttemptResponse createNewAttempt(int quizId) { int currentUserId = authorizationService.getCurrentUserId(); @@ -58,6 +63,12 @@ public AttemptResponse createNewAttempt(int quizId) { return attemptMapper.toAttemptResponse(savedAttempt); } + /** + * Get attempt and its detail by id and its quiz id + * @param quizId id of target quiz + * @param attemptId id of target attempt + * @return attempt response dto + */ public AttemptResponse getAttemptByIdAndQuizId(int quizId, int attemptId) { int currentUserId = authorizationService.getCurrentUserId(); @@ -66,6 +77,11 @@ public AttemptResponse getAttemptByIdAndQuizId(int quizId, int attemptId) { return attemptMapper.toAttemptResponse(attempt); } + /** + * Get list of attempts by quiz id + * @param quizId id of target quiz + * @return list of attempt response dto + */ public List getAllAttemptsByQuizId(int quizId) { int currentUserId = authorizationService.getCurrentUserId(); @@ -74,6 +90,14 @@ public List getAllAttemptsByQuizId(int quizId) { return attemptMapper.toAttemptResponseList(attempts); } + /** + * Update single attempt detail (user answer) of given attempt id and quiz id. + * The method compare user answer and correct answer to set the correctness of the attempt detail. + * @param quizId id of target quiz + * @param attemptId id of target attempt + * @param request request dto with attempt detail id and user answer + * @return attempt detail response dto + */ public AttemptResponse.Detail updateAttemptDetail(int quizId, int attemptId, AttemptDetailUpdateRequest request) { int currentUserId = authorizationService.getCurrentUserId(); @@ -90,6 +114,13 @@ public AttemptResponse.Detail updateAttemptDetail(int quizId, int attemptId, Att return attemptDetailMapper.toAttemptResponseDetail(existingAttemptDetail); } + /** + * Calculate attempt result (score) of given attempt id and quiz id. + * The score is calculated based on the number of correct answer, ignoring unknown (null answer) + * @param quizId id of target quiz + * @param attemptId id of target attempt + * @return attempt response dto + */ public AttemptResponse calculateAttemptResult(int quizId, int attemptId) { int currentUserId = authorizationService.getCurrentUserId(); @@ -108,6 +139,11 @@ public AttemptResponse calculateAttemptResult(int quizId, int attemptId) { return attemptMapper.toAttemptResponse(existingAttempt); } + /** + * Delete attempt based on given quiz id and attempt id + * @param quizId id of target quiz + * @param attemptId id of target attempt + */ public void deleteAttemptByIdAndQuizId(int quizId, int attemptId) { int currentUserId = authorizationService.getCurrentUserId(); @@ -117,6 +153,13 @@ public void deleteAttemptByIdAndQuizId(int quizId, int attemptId) { } // ------ Methods that returns entity ------ // + /** + * Get user's attempt and its detail from database based on given information + * @param quizId id of target quiz + * @param attemptId id of target attempt + * @param userId id of current user + * @return attempt entity + */ public Attempt getAttemptEntityByIdAndQuizId(int quizId, int attemptId, int userId) { Attempt attempt = attemptRepository.findByIdAndQuiz_QuizSet_UserId(attemptId, userId).orElseThrow(() -> { log.error("Attempt with id {} not found in user's account", attemptId); From 3d0d0241706b19c811f7c1255537521f5a5c7a38 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Mon, 10 Nov 2025 11:34:19 +1100 Subject: [PATCH 45/63] feat(note): add get all notes and date time format inn response --- .../smart_notes/controller/NoteController.java | 16 ++++++++++++++-- .../smart_notes/dto/response/NoteResponse.java | 4 ++++ .../be08/smart_notes/mapper/DocumentMapper.java | 3 +++ .../be08/smart_notes/service/NoteService.java | 13 +++++++++++-- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/be08/smart_notes/controller/NoteController.java b/src/main/java/com/be08/smart_notes/controller/NoteController.java index 23de57f..07c4ab6 100644 --- a/src/main/java/com/be08/smart_notes/controller/NoteController.java +++ b/src/main/java/com/be08/smart_notes/controller/NoteController.java @@ -20,6 +20,8 @@ import com.be08.smart_notes.dto.response.ApiResponse; import com.be08.smart_notes.service.NoteService; +import java.util.List; + @RestController @RequestMapping("/api/documents/notes") @RequiredArgsConstructor @@ -46,8 +48,18 @@ public ResponseEntity getNote(@PathVariable int id) { .build(); return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } - - @PutMapping("/{id}") + + @GetMapping + public ResponseEntity getAllNotes() { + List noteList = noteService.getAllNotes(); + ApiResponse apiResponse = ApiResponse.builder() + .message("Notes fetched successfully") + .data(noteList) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + + @PutMapping("/{id}") public ResponseEntity updateNote(@PathVariable int id, @RequestBody @Valid NoteUpsertRequest noteUpdateRequest) { NoteResponse note = noteService.updateNote(id, noteUpdateRequest); ApiResponse apiResponse = ApiResponse.builder() diff --git a/src/main/java/com/be08/smart_notes/dto/response/NoteResponse.java b/src/main/java/com/be08/smart_notes/dto/response/NoteResponse.java index dbaa9d0..68b8a44 100644 --- a/src/main/java/com/be08/smart_notes/dto/response/NoteResponse.java +++ b/src/main/java/com/be08/smart_notes/dto/response/NoteResponse.java @@ -1,5 +1,6 @@ package com.be08.smart_notes.dto.response; +import com.fasterxml.jackson.annotation.JsonFormat; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -16,6 +17,9 @@ public class NoteResponse { private String title; private String content; + @JsonFormat(pattern = "dd-MM-yyyy HH:mm:ss") private LocalDateTime createdAt; + + @JsonFormat(pattern = "dd-MM-yyyy HH:mm:ss") private LocalDateTime updatedAt; } diff --git a/src/main/java/com/be08/smart_notes/mapper/DocumentMapper.java b/src/main/java/com/be08/smart_notes/mapper/DocumentMapper.java index d41e501..36740aa 100644 --- a/src/main/java/com/be08/smart_notes/mapper/DocumentMapper.java +++ b/src/main/java/com/be08/smart_notes/mapper/DocumentMapper.java @@ -4,7 +4,10 @@ import com.be08.smart_notes.model.Document; import org.mapstruct.Mapper; +import java.util.List; + @Mapper(componentModel = "spring") public interface DocumentMapper { NoteResponse toNoteResponse(Document note); + List toNoteResponseList(List noteList); } diff --git a/src/main/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index 5589986..0fee555 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -2,7 +2,6 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Map; import com.be08.smart_notes.dto.response.NoteResponse; import com.be08.smart_notes.exception.AppException; @@ -59,7 +58,17 @@ public NoteResponse createNote(NoteUpsertRequest newData) { return documentMapper.toNoteResponse(newNote); } - public NoteResponse updateNote(int noteId, NoteUpsertRequest updateData) { + public List getAllNotes() { + // Get current user id + int currentUserId = authorizationService.getCurrentUserId(); + + // Get note + List noteList = documentRepository.findAllByUserId(currentUserId); + + return documentMapper.toNoteResponseList(noteList); + } + + public NoteResponse updateNote(int noteId, NoteUpsertRequest updateData) { // Get current user id int currentUserId = authorizationService.getCurrentUserId(); From 68633b1e6db7b7b81b8501e675b64d6e1dac052c Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Mon, 10 Nov 2025 11:46:31 +1100 Subject: [PATCH 46/63] fix(note): aresolve long text warning issue usingcolumn definition for note content --- src/main/java/com/be08/smart_notes/model/Document.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/be08/smart_notes/model/Document.java b/src/main/java/com/be08/smart_notes/model/Document.java index 57e374c..e48afc7 100644 --- a/src/main/java/com/be08/smart_notes/model/Document.java +++ b/src/main/java/com/be08/smart_notes/model/Document.java @@ -47,7 +47,7 @@ public class Document { private LocalDateTime updatedAt; // Information for note document - @Column + @Column(columnDefinition = "TEXT") private String content; // Information for pdf document From c00e4b5d6ea1b89911741b29362a4d341fa8a142 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Mon, 10 Nov 2025 12:12:00 +1100 Subject: [PATCH 47/63] refactor(note): change update note endpoint from PUT to PATCH --- .../be08/smart_notes/controller/NoteController.java | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/be08/smart_notes/controller/NoteController.java b/src/main/java/com/be08/smart_notes/controller/NoteController.java index 07c4ab6..3d03d39 100644 --- a/src/main/java/com/be08/smart_notes/controller/NoteController.java +++ b/src/main/java/com/be08/smart_notes/controller/NoteController.java @@ -7,14 +7,7 @@ import lombok.experimental.FieldDefaults; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import com.be08.smart_notes.dto.request.NoteUpsertRequest; import com.be08.smart_notes.dto.response.ApiResponse; @@ -59,7 +52,8 @@ public ResponseEntity getAllNotes() { return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } - @PutMapping("/{id}") + // @PutMapping("/{id}") + @PatchMapping("/{id}") public ResponseEntity updateNote(@PathVariable int id, @RequestBody @Valid NoteUpsertRequest noteUpdateRequest) { NoteResponse note = noteService.updateNote(id, noteUpdateRequest); ApiResponse apiResponse = ApiResponse.builder() From ca625d2b71c6978440a5bb9ab17db1542bebb6eb Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Mon, 10 Nov 2025 15:41:05 +1100 Subject: [PATCH 48/63] feat(quiz-set): add default set request --- .../smart_notes/controller/QuizSetController.java | 11 +++++++++++ .../com/be08/smart_notes/service/QuizSetService.java | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/main/java/com/be08/smart_notes/controller/QuizSetController.java b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java index 021faaf..eb1a205 100644 --- a/src/main/java/com/be08/smart_notes/controller/QuizSetController.java +++ b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java @@ -46,6 +46,17 @@ public ResponseEntity getAllQuizSets() { return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } + @GetMapping("/default") + @JsonView(QuizView.Detail.class) + public ResponseEntity getDefaultQuizSet() { + QuizSetResponse quizSetResponse = quizSetService.getDefaultSet(); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz set fetched successfully") + .data(quizSetResponse) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + @GetMapping("/{id}") @JsonView(QuizView.Detail.class) public ResponseEntity getQuizSet(@PathVariable int id) { diff --git a/src/main/java/com/be08/smart_notes/service/QuizSetService.java b/src/main/java/com/be08/smart_notes/service/QuizSetService.java index f2c53de..71128d4 100644 --- a/src/main/java/com/be08/smart_notes/service/QuizSetService.java +++ b/src/main/java/com/be08/smart_notes/service/QuizSetService.java @@ -97,6 +97,17 @@ public List getAllQuizSets() { return quizSetMapper.toQuizSetResponseList(quizSets); } + /** + * Get default QuizSet with associated quizzes + * @return response dto for default quiz set + */ + public QuizSetResponse getDefaultSet() { + int currentUserId = authorizationService.getCurrentUserId(); + + QuizSet quizSet = getOrCreateDefaultSet(currentUserId); + return quizSetMapper.toQuizSetResponse(quizSet); + } + /** * Update existing QuizSet based on given request from client * @param request data packed in client's request From 2faefdcfc333d07a1cd37facf41867130fafff27 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 2 Dec 2025 17:13:39 +1100 Subject: [PATCH 49/63] test(note): add unit test for note service --- .../controller/NoteController.java | 1 - .../com/be08/smart_notes/model/Document.java | 20 +- .../be08/smart_notes/service/NoteService.java | 11 +- .../smart_notes/service/NoteServiceTest.java | 382 ++++++++++++++++++ 4 files changed, 397 insertions(+), 17 deletions(-) create mode 100644 src/test/java/com/be08/smart_notes/service/NoteServiceTest.java diff --git a/src/main/java/com/be08/smart_notes/controller/NoteController.java b/src/main/java/com/be08/smart_notes/controller/NoteController.java index 3d03d39..de5f08d 100644 --- a/src/main/java/com/be08/smart_notes/controller/NoteController.java +++ b/src/main/java/com/be08/smart_notes/controller/NoteController.java @@ -52,7 +52,6 @@ public ResponseEntity getAllNotes() { return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } - // @PutMapping("/{id}") @PatchMapping("/{id}") public ResponseEntity updateNote(@PathVariable int id, @RequestBody @Valid NoteUpsertRequest noteUpdateRequest) { NoteResponse note = noteService.updateNote(id, noteUpdateRequest); diff --git a/src/main/java/com/be08/smart_notes/model/Document.java b/src/main/java/com/be08/smart_notes/model/Document.java index e48afc7..89bfb18 100644 --- a/src/main/java/com/be08/smart_notes/model/Document.java +++ b/src/main/java/com/be08/smart_notes/model/Document.java @@ -5,14 +5,7 @@ import com.be08.smart_notes.enums.DocumentType; import com.fasterxml.jackson.annotation.JsonInclude; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -56,4 +49,15 @@ public class Document { @Column(name = "file_size") private Integer fileSize; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index 0fee555..2f1b243 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -50,12 +50,10 @@ public NoteResponse createNote(NoteUpsertRequest newData) { .title(newData.getTitle()) .content(newData.getContent()) .type(DocumentType.NOTE) - .createdAt(LocalDateTime.now()) - .updatedAt(LocalDateTime.now()) .build(); - documentRepository.save(newNote); - return documentMapper.toNoteResponse(newNote); + Document savedNote = documentRepository.save(newNote); + return documentMapper.toNoteResponse(savedNote); } public List getAllNotes() { @@ -100,10 +98,7 @@ public void deleteNote(int noteId) { // ------ Methods that returns entities ------ // public List getAllNotesByUserIdAndIds(int userId, List noteIds) { - // Get current user id - int currentUserId = authorizationService.getCurrentUserId(); - - return documentRepository.findAllByUserIdAndIdIn(currentUserId, noteIds); + return documentRepository.findAllByUserIdAndIdIn(userId, noteIds); } public List getAllNotesByIds(List noteIds) { diff --git a/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java b/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java new file mode 100644 index 0000000..eb25423 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java @@ -0,0 +1,382 @@ +package com.be08.smart_notes.service; + +import com.be08.smart_notes.dto.request.NoteUpsertRequest; +import com.be08.smart_notes.dto.response.NoteResponse; +import com.be08.smart_notes.enums.DocumentType; +import com.be08.smart_notes.exception.AppException; +import com.be08.smart_notes.exception.ErrorCode; +import com.be08.smart_notes.mapper.DocumentMapper; +import com.be08.smart_notes.model.Document; +import com.be08.smart_notes.model.User; +import com.be08.smart_notes.repository.DocumentRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class NoteServiceTest { + @Mock + DocumentRepository documentRepository; + @Mock + AuthorizationService authorizationService; + @Mock + DocumentMapper documentMapper; + + @InjectMocks + NoteService noteService; + + User existingUser; + Document existingNote; + Document anotherExistingNote; + NoteResponse existingNoteResponse; + NoteResponse anotherExistingNoteResponse; + + @BeforeEach + void setUp() { + existingUser = User.builder().id(100).build(); + existingNote = Document.builder().id(1).userId(100).build(); + anotherExistingNote = Document.builder().id(2).userId(100).build(); + existingNoteResponse = NoteResponse.builder().id(1).build(); + anotherExistingNoteResponse = NoteResponse.builder().id(2).build(); + + when(authorizationService.getCurrentUserId()).thenReturn(existingUser.getId()); + } + + // --- Get note --- // + @Test + void getNote_withNonExistentId_shouldThrowException() { + // Arrange + int nonExistentId = 999; + int userId = existingUser.getId(); + when(documentRepository.findByIdAndUserId(nonExistentId, userId)).thenReturn(Optional.empty()); + + // Act & Assert + AppException actualException = assertThrows(AppException.class, () -> { + noteService.getNote(nonExistentId); + }); + assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, actualException.getErrorCode()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(nonExistentId, userId); + verify(documentMapper, never()).toNoteResponse(any()); + } + + @Test + void getNote_whenNoteExists_shouldReturnNoteResponse() { + // Arrange + int noteId = existingNote.getId(); + int userId = existingUser.getId(); + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); + when(documentMapper.toNoteResponse(existingNote)).thenReturn(existingNoteResponse); + + // Act + NoteResponse actualResponse = noteService.getNote(noteId); + + // Assert + assertNotNull(actualResponse); + assertEquals(existingNoteResponse, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentMapper).toNoteResponse(existingNote); + } + + // -- Get all notes -- // + @Test + void getAllNotes_whenEmpty_shouldReturnEmptyNoteResponseList() { + // Arrange + int userId = existingUser.getId(); + List emptyList = Collections.emptyList(); + List expectedNoteResponseList = Collections.emptyList(); + when(documentRepository.findAllByUserId(userId)).thenReturn(emptyList); + when(documentMapper.toNoteResponseList(emptyList)).thenReturn(expectedNoteResponseList); + + // Act + List actualResponse = noteService.getAllNotes(); + + // Assert + assertNotNull(actualResponse); + assertTrue(actualResponse.isEmpty()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findAllByUserId(userId); + verify(documentMapper).toNoteResponseList(emptyList); + } + + @Test + void getAllNotes_whenNotEmpty_shouldReturnNoteResponseList() { + // Arrange + int userId = existingUser.getId(); + List noteList = List.of(existingNote, anotherExistingNote); + List expectedNoteResponseList = List.of(existingNoteResponse, anotherExistingNoteResponse); + when(documentRepository.findAllByUserId(userId)).thenReturn(noteList); + when(documentMapper.toNoteResponseList(noteList)).thenReturn(expectedNoteResponseList); + + // Act + List actualResponse = noteService.getAllNotes(); + + // Assert + assertNotNull(actualResponse); + assertFalse(actualResponse.isEmpty()); + assertEquals(expectedNoteResponseList.size(), actualResponse.size()); + assertEquals(expectedNoteResponseList, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findAllByUserId(userId); + verify(documentMapper).toNoteResponseList(noteList); + } + + @Test + void getAllNotes_withSingleNote_shouldReturnSingleNoteResponseList() { + // Arrange + int userId = existingUser.getId(); + List noteList = List.of(existingNote); + List expectedNoteResponseList = List.of(existingNoteResponse); + when(documentRepository.findAllByUserId(userId)).thenReturn(noteList); + when(documentMapper.toNoteResponseList(noteList)).thenReturn(expectedNoteResponseList); + + // Act + List actualResponse = noteService.getAllNotes(); + + // Assert + assertNotNull(actualResponse); + assertEquals(1, actualResponse.size()); + assertEquals(existingNoteResponse, actualResponse.get(0)); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findAllByUserId(userId); + verify(documentMapper).toNoteResponseList(noteList); + } + + // --- Create note --- // + @Test + void createNote_withValidInput_shouldCreateSuccessfully() { + // Arrange + int newId = 2; + String newTitle = "Test New Note"; + String newContent = "Test New Content"; + NoteUpsertRequest newRequest = NoteUpsertRequest.builder() + .title(newTitle).content(newContent).build(); + Document savedNote = Document.builder().id(newId).userId(existingUser.getId()) + .title(newTitle).content(newContent).type(DocumentType.NOTE).build(); + NoteResponse expectedResponse = NoteResponse.builder().id(newId) + .title(newTitle).content(newContent).build(); + + when(documentRepository.save(any(Document.class))).thenReturn(savedNote); + when(documentMapper.toNoteResponse(savedNote)).thenReturn(expectedResponse); + + // Act + NoteResponse actualResponse = noteService.createNote(newRequest); + + // Assert + assertNotNull(actualResponse); + assertEquals(expectedResponse, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).save(any(Document.class)); + verify(documentMapper).toNoteResponse(savedNote); + } + + // --- Update note --- // + @Test + void updateNote_whenNoteExists_shouldUpdateSuccessfully() { + // Arrange + int noteId = existingNote.getId(); + int userId = existingUser.getId(); + String updatedTitle = "Test Updated Title"; + String updatedContent = "Test Updated Content"; + NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() + .title(updatedTitle).content(updatedContent).build(); + Document updatedNote = Document.builder().id(noteId).userId(userId) + .title(updatedTitle).content(updatedContent).type(DocumentType.NOTE).build(); + NoteResponse expectedResponse = NoteResponse.builder().id(noteId) + .title(updatedTitle).content(updatedContent).build(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); + when(documentRepository.save(any(Document.class))).thenReturn(updatedNote); + when(documentMapper.toNoteResponse(updatedNote)).thenReturn(expectedResponse); + + // Act + NoteResponse actualResponse = noteService.updateNote(noteId, updateRequest); + + // Assert + assertNotNull(actualResponse); + assertEquals(expectedResponse, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository).save(any(Document.class)); + verify(documentMapper).toNoteResponse(updatedNote); + } + + @Test + void updateNote_whenNoteNotFound_shouldThrowException() { + // Arrange + int noteId = 999; + int userId = existingUser.getId(); + String updatedTitle = "Test Updated Title"; + String updatedContent = "Test Updated Content"; + NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() + .title(updatedTitle).content(updatedContent).build(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.empty()); + + // Act & Assert + AppException exception = assertThrows(AppException.class, () -> { + noteService.updateNote(noteId, updateRequest); + }); + assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, exception.getErrorCode()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository, never()).save(any()); + verify(documentMapper, never()).toNoteResponse(any()); + } + + // --- Delete note --- // + @Test + void deleteNote_whenNoteExists_shouldDeleteSuccessfully() { + // Arrange + int noteId = existingNote.getId(); + int userId = existingUser.getId(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); + + // Act + noteService.deleteNote(noteId); + + // Assert + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository).deleteById(noteId); + } + + @Test + void deleteNote_whenNoteNotFound_shouldThrowException() { + // Arrange + int noteId = 999; + int userId = existingUser.getId(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.empty()); + + // Act & Assert + AppException exception = assertThrows(AppException.class, () -> { + noteService.deleteNote(noteId); + }); + assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, exception.getErrorCode()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository, never()).deleteById(anyInt()); + } + + // ------ Methods that returns entities ------ // + @Test + void getAllNotesByUserIdAndIds_whenNotesExist_shouldReturnMatchingNotes() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(existingNote.getId(), anotherExistingNote.getId()); + List expectedNotes = List.of(existingNote, anotherExistingNote); + + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); + + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + + // Assert + assertNotNull(actualNotes); + assertEquals(2, actualNotes.size()); + assertEquals(expectedNotes, actualNotes); + + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } + + @Test + void getAllNotesByUserIdAndIds_whenNoMatches_shouldReturnEmptyList() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(998, 999); + + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(Collections.emptyList()); + + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + + // Assert + assertNotNull(actualNotes); + assertTrue(actualNotes.isEmpty()); + + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } + + @Test + void getAllNotesByUserIdAndIds_withEmptyIdList_shouldReturnEmptyList() { + // Arrange + int userId = existingUser.getId(); + List emptyIds = Collections.emptyList(); + + when(documentRepository.findAllByUserIdAndIdIn(userId, emptyIds)).thenReturn(Collections.emptyList()); + + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, emptyIds); + + // Assert + assertNotNull(actualNotes); + assertTrue(actualNotes.isEmpty()); + + verify(documentRepository).findAllByUserIdAndIdIn(userId, emptyIds); + } + + @Test + void getAllNotesByUserIdAndIds_withSingleId_shouldReturnSingleNote() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(existingNote.getId()); + List expectedNotes = List.of(existingNote); + + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); + + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + + // Assert + assertNotNull(actualNotes); + assertEquals(expectedNotes.size(), actualNotes.size()); + assertEquals(existingNote, actualNotes.get(0)); + + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } + + @Test + void getAllNotesByUserIdAndIds_withPartialMatches_shouldReturnOnlyMatchingNotes() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(existingNote.getId(), 999); // Only ID 1 exists + List expectedNotes = List.of(existingNote); // Only returns existing note + + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); + + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + + // Assert + assertNotNull(actualNotes); + assertEquals(expectedNotes.size(), actualNotes.size()); + assertEquals(existingNote, actualNotes.get(0)); + + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } +} From 3d6a7e4c11229136b1cdd3fe4f82cc88f3fbb69d Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Mon, 8 Dec 2025 18:31:27 +1100 Subject: [PATCH 50/63] test(note): add testing for note repository and resolve potential query error --- pom.xml | 22 +- .../java/com/be08/smart_notes/model/User.java | 2 +- .../repository/DocumentRepository.java | 4 +- .../smart_notes/service/DocumentService.java | 2 +- src/main/resources/application-dev.properties | 2 +- .../helper/DocumentDataBuilder.java | 29 ++ .../repository/DocumentRepositoryTest.java | 362 ++++++++++++++++++ .../smart_notes/service/NoteServiceTest.java | 12 +- src/test/resources/application.properties | 27 ++ 9 files changed, 450 insertions(+), 12 deletions(-) create mode 100644 src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java create mode 100644 src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java create mode 100644 src/test/resources/application.properties diff --git a/pom.xml b/pom.xml index b452060..c93002c 100644 --- a/pom.xml +++ b/pom.xml @@ -144,6 +144,15 @@ jackson-datatype-jsr310 + + + + com.h2database + h2 + 2.2.224 + test + + @@ -167,7 +176,7 @@ jsonschema-module-jakarta-validation 4.31.1 - + @@ -194,6 +203,17 @@ + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*Test.java + + + diff --git a/src/main/java/com/be08/smart_notes/model/User.java b/src/main/java/com/be08/smart_notes/model/User.java index d1b14d6..21231d6 100644 --- a/src/main/java/com/be08/smart_notes/model/User.java +++ b/src/main/java/com/be08/smart_notes/model/User.java @@ -18,7 +18,7 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "user") +@Table(name = "'user'") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java index 3cbb367..d4045c2 100644 --- a/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java +++ b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java @@ -6,8 +6,6 @@ import org.springframework.data.jpa.repository.JpaRepository; import com.be08.smart_notes.model.Document; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository @@ -15,7 +13,7 @@ public interface DocumentRepository extends JpaRepository { List findAllByUserId(Integer userId); Optional findByIdAndUserId(int documentId, int userId); - Optional findByTitleAndUserId(String title, int userId); + Optional findFirstByTitleAndUserId(String title, int userId); List findAllByIdIn(List ids); List findAllByUserIdAndIdIn(Integer userId, List ids); diff --git a/src/main/java/com/be08/smart_notes/service/DocumentService.java b/src/main/java/com/be08/smart_notes/service/DocumentService.java index 2b9ca16..0c30307 100644 --- a/src/main/java/com/be08/smart_notes/service/DocumentService.java +++ b/src/main/java/com/be08/smart_notes/service/DocumentService.java @@ -67,7 +67,7 @@ public Document getDocumentIfOwned(int documentId, int userId){ * @throws AppException if system source document not found */ public Document getSystemSourceDocument(int userId){ - return documentRepository.findByTitleAndUserId(SYSTEM_SOURCE_TITLE, userId) + return documentRepository.findFirstByTitleAndUserId(SYSTEM_SOURCE_TITLE, userId) .orElseThrow(() -> new AppException(ErrorCode.SYSTEM_SOURCE_DOCUMENT_NOT_FOUND)); } } diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index b2bf96e..0f327b6 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -2,7 +2,7 @@ spring.datasource.url=jdbc:mysql://localhost:${DB_PORT}/${DB_NAME} spring.datasource.username=${DB_USERNAME} spring.datasource.password=${DB_PASSWORD} -spring.datasource.diver-class-name =com.mysql.cj.jdbc.Driver +spring.datasource.driver-class-name =com.mysql.cj.jdbc.Driver # JPA Settings for Development spring.jpa.show-sql=true diff --git a/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java b/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java new file mode 100644 index 0000000..a37b44c --- /dev/null +++ b/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java @@ -0,0 +1,29 @@ +package com.be08.smart_notes.helper; + +import com.be08.smart_notes.dto.response.NoteResponse; +import com.be08.smart_notes.enums.DocumentType; +import com.be08.smart_notes.model.Document; +import com.be08.smart_notes.model.User; + +import java.time.LocalDateTime; + +public class DocumentDataBuilder { + public static User.UserBuilder createUser(int userId) { + return User.builder().id(userId); + } + + public static Document.DocumentBuilder createSampleNote(int userId) { + return Document.builder() + .userId(userId).type(DocumentType.NOTE) + .title("Sample Note").content("Sample Note Content") + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()); + } + + public static NoteResponse.NoteResponseBuilder createNoteResponse(Document note) { + return NoteResponse.builder().id(note.getId()) + .title(note.getTitle()).content(note.getContent()) + .createdAt(note.getCreatedAt()) + .updatedAt(note.getUpdatedAt()); + } +} diff --git a/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java b/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java new file mode 100644 index 0000000..42706e5 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java @@ -0,0 +1,362 @@ +package com.be08.smart_notes.repository; + +import com.be08.smart_notes.helper.DocumentDataBuilder; +import com.be08.smart_notes.model.Document; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +public class DocumentRepositoryTest { + @Autowired + DocumentRepository documentRepository; + + int FIRST_USER_ID = 100; + int SECOND_USER_ID = 101; + + Document document1; + Document document2; + Document document3; + Document documentAnotherUser; + + @BeforeEach + void setUp() { + documentRepository.deleteAll(); + + document1 = documentRepository.save(DocumentDataBuilder.createSampleNote(FIRST_USER_ID).build()); + document2 = documentRepository.save(DocumentDataBuilder.createSampleNote(FIRST_USER_ID).build()); + document3 = documentRepository.save(DocumentDataBuilder.createSampleNote(FIRST_USER_ID).build()); + documentAnotherUser = documentRepository.save(DocumentDataBuilder.createSampleNote(SECOND_USER_ID).build()); + } + + // --- findAllByUserId --- // + @Test + void findAllByUserId_whenUserHasDocuments_shouldReturnAllDocuments() { + // Act + List result = documentRepository.findAllByUserId(FIRST_USER_ID); + + // Assert + assertNotNull(result); + assertEquals(3, result.size()); + assertTrue(result.stream().allMatch(doc -> doc.getUserId() == FIRST_USER_ID)); + } + + @Test + void findAllByUserId_whenUserHasNoDocuments_shouldReturnEmptyList() { + // Arrange + int nonExistentUserId = 999; + + // Act + List result = documentRepository.findAllByUserId(nonExistentUserId); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserId_shouldNotReturnOtherUsersDocuments() { + // Act + List result = documentRepository.findAllByUserId(FIRST_USER_ID); + + // Assert + assertNotNull(result); + assertFalse(result.stream().anyMatch(doc -> doc.getUserId() == SECOND_USER_ID)); + } + + // --- findByIdAndUserId --- // + @Test + void findByIdAndUserId_whenDocumentExists_shouldReturnDocument() { + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), FIRST_USER_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(document1.getId(), result.get().getId()); + assertEquals(FIRST_USER_ID, result.get().getUserId()); + } + + @Test + void findByIdAndUserId_whenDocumentNotExists_shouldReturnEmpty() { + // Arrange + int nonExistentId = 999; + + // Act + Optional result = documentRepository.findByIdAndUserId(nonExistentId, FIRST_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void findByIdAndUserId_whenDocumentExistsButWrongUser_shouldReturnEmpty() { + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), SECOND_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void findByIdAndUserId_whenUserNotExists_shouldReturnEmpty() { + // Arrange + int nonExistentUserId = 999; + + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), nonExistentUserId); + + // Assert + assertFalse(result.isPresent()); + } + + // --- findByTitleAndUserId --- // + @Test + void findFirstByTitleAndUserId_whenDocumentExists_shouldReturnDocument() { + // Arrange + String existingTitle = document1.getTitle(); + + // Act + Optional result = documentRepository.findFirstByTitleAndUserId(existingTitle, FIRST_USER_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(existingTitle, result.get().getTitle()); + assertEquals(FIRST_USER_ID, result.get().getUserId()); + } + + @Test + void findFirstByTitleAndUserId_whenTitleNotExists_shouldReturnEmpty() { + // Arrange + String nonExistentTitle = "Non Existent Title"; + + // Act + Optional result = documentRepository.findFirstByTitleAndUserId(nonExistentTitle, FIRST_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void findFirstByTitleAndUserId_whenMultipleUsersHaveSameTitle_shouldReturnOnlyRequestedUser() { + // Arrange + String sharedTitle = "Shared Title"; + Document doc1 = documentRepository.save( + DocumentDataBuilder.createSampleNote(FIRST_USER_ID).title(sharedTitle).build() + ); + Document doc2 = documentRepository.save( + DocumentDataBuilder.createSampleNote(SECOND_USER_ID).title(sharedTitle).build() + ); + + // Act + Optional result = documentRepository.findFirstByTitleAndUserId(sharedTitle, FIRST_USER_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(FIRST_USER_ID, result.get().getUserId()); + assertEquals(doc1.getId(), result.get().getId()); + } + + // --- findAllByIdIn --- // + @Test + void findAllByIdIn_whenAllIdsExist_shouldReturnAllDocuments() { + // Arrange + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.stream().anyMatch(doc -> doc.getId().equals(document1.getId()))); + assertTrue(result.stream().anyMatch(doc -> doc.getId().equals(document2.getId()))); + } + + @Test + void findAllByIdIn_whenSomeIdsNotExist_shouldReturnOnlyExistingDocuments() { + // Arrange + List ids = Arrays.asList(document1.getId(), 999, 998); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } + + @Test + void findAllByIdIn_whenNoIdsExist_shouldReturnEmptyList() { + // Arrange + List ids = Arrays.asList(999, 998, 997); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByIdIn_withEmptyIdList_shouldReturnEmptyList() { + // Arrange + List emptyIds = Collections.emptyList(); + + // Act + List result = documentRepository.findAllByIdIn(emptyIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByIdIn_shouldReturnDocumentsFromMultipleUsers() { + // Arrange + List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.stream().anyMatch(doc -> doc.getUserId() == FIRST_USER_ID)); + assertTrue(result.stream().anyMatch(doc -> doc.getUserId() == SECOND_USER_ID)); + } + + @Test + void findAllByIdIn_withSingleId_shouldReturnSingleDocument() { + // Arrange + List ids = Collections.singletonList(document1.getId()); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } + + // --- findAllByUserIdAndIdIn --- // + @Test + void findAllByUserIdAndIdIn_whenAllIdsExistForUser_shouldReturnAllDocuments() { + // Arrange + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.stream().allMatch(doc -> doc.getUserId() == FIRST_USER_ID)); + } + + @Test + void findAllByUserIdAndIdIn_whenSomeIdsNotExistForUser_shouldReturnOnlyExisting() { + // Arrange + List ids = Arrays.asList(document1.getId(), 999); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } + + @Test + void findAllByUserIdAndIdIn_whenNoIdsExistForUser_shouldReturnEmptyList() { + // Arrange + List ids = Arrays.asList(999, 998); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserIdAndIdIn_withEmptyIdList_shouldReturnEmptyList() { + // Arrange + List emptyIds = Collections.emptyList(); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, emptyIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserIdAndIdIn_whenDocumentsBelongToAnotherUser_shouldReturnEmptyList() { + // Arrange - trying to get user 1's documents with user 2's ID + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(SECOND_USER_ID, ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserIdAndIdIn_withMixedUserDocuments_shouldReturnOnlyRequestedUserDocuments() { + // Arrange - mixing documents from different users + List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + assertEquals(FIRST_USER_ID, result.get(0).getUserId()); + } + + @Test + void findAllByUserIdAndIdIn_withSingleId_shouldReturnSingleDocument() { + // Arrange + List ids = Collections.singletonList(document1.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } + + @Test + void findAllByUserIdAndIdIn_whenUserNotExists_shouldReturnEmptyList() { + // Arrange + int nonExistentUserId = 999; + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(nonExistentUserId, ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } +} diff --git a/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java b/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java index eb25423..aeabee4 100644 --- a/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java +++ b/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java @@ -1,5 +1,6 @@ package com.be08.smart_notes.service; +import com.be08.smart_notes.helper.DocumentDataBuilder; import com.be08.smart_notes.dto.request.NoteUpsertRequest; import com.be08.smart_notes.dto.response.NoteResponse; import com.be08.smart_notes.enums.DocumentType; @@ -46,11 +47,12 @@ public class NoteServiceTest { @BeforeEach void setUp() { - existingUser = User.builder().id(100).build(); - existingNote = Document.builder().id(1).userId(100).build(); - anotherExistingNote = Document.builder().id(2).userId(100).build(); - existingNoteResponse = NoteResponse.builder().id(1).build(); - anotherExistingNoteResponse = NoteResponse.builder().id(2).build(); + int userId = 100; + existingUser = DocumentDataBuilder.createUser(userId).build(); + existingNote = DocumentDataBuilder.createSampleNote(userId).id(1).userId(100).build(); + anotherExistingNote = DocumentDataBuilder.createSampleNote(userId).id(2).userId(100).build(); + existingNoteResponse = DocumentDataBuilder.createNoteResponse(existingNote).build(); + anotherExistingNoteResponse = DocumentDataBuilder.createNoteResponse(anotherExistingNote).build(); when(authorizationService.getCurrentUserId()).thenReturn(existingUser.getId()); } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..361e796 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,27 @@ +# Database Configuration for Development (Local MySQL) +# H2 Database Configuration +spring.datasource.url=jdbc:h2:mem:test;MODE=MySQL; +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=testing +spring.datasource.password=password + +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.show-sql=false + +#spring.jpa.defer-datasource-initialization=true +#spring.sql.init.mode=never + +# JPA Settings for Development +#spring.jpa.properties.hibernate.format_sql=true +#spring.jpa.hibernate.ddl-auto=update + +# REDIS Configuration for Development (Local Redis - Docker) +#spring.data.redis.host=${REDIS_HOST} +#spring.data.redis.port=${REDIS_PORT} +# +## Frontend URL for Local Development +#app.frontend.url=http://localhost:3000 +# +## Logging Level for Development +#logging.level.com.be08=DEBUG \ No newline at end of file From 5efb580306bd51b2b9b8eaf036681688258b56a8 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 9 Dec 2025 21:26:15 +1100 Subject: [PATCH 51/63] test(note): reorganise test display, utilising tree structure --- .../repository/DocumentRepositoryTest.java | 643 +++++++++--------- .../smart_notes/service/NoteServiceTest.java | 591 ++++++++-------- 2 files changed, 640 insertions(+), 594 deletions(-) diff --git a/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java b/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java index 42706e5..4bde439 100644 --- a/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java +++ b/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java @@ -2,8 +2,7 @@ import com.be08.smart_notes.helper.DocumentDataBuilder; import com.be08.smart_notes.model.Document; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; @@ -15,6 +14,8 @@ import static org.junit.jupiter.api.Assertions.*; @DataJpaTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@DisplayName("Document Repository Test") public class DocumentRepositoryTest { @Autowired DocumentRepository documentRepository; @@ -38,325 +39,345 @@ void setUp() { } // --- findAllByUserId --- // - @Test - void findAllByUserId_whenUserHasDocuments_shouldReturnAllDocuments() { - // Act - List result = documentRepository.findAllByUserId(FIRST_USER_ID); - - // Assert - assertNotNull(result); - assertEquals(3, result.size()); - assertTrue(result.stream().allMatch(doc -> doc.getUserId() == FIRST_USER_ID)); - } - - @Test - void findAllByUserId_whenUserHasNoDocuments_shouldReturnEmptyList() { - // Arrange - int nonExistentUserId = 999; - - // Act - List result = documentRepository.findAllByUserId(nonExistentUserId); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void findAllByUserId_shouldNotReturnOtherUsersDocuments() { - // Act - List result = documentRepository.findAllByUserId(FIRST_USER_ID); - - // Assert - assertNotNull(result); - assertFalse(result.stream().anyMatch(doc -> doc.getUserId() == SECOND_USER_ID)); + @Nested + @DisplayName("findAllByUserId(): List") + class FindAllByUserIdTest { + @Test + void findAllByUserId_whenUserHasDocuments_shouldReturnAllDocuments() { + // Act + List result = documentRepository.findAllByUserId(FIRST_USER_ID); + + // Assert + assertNotNull(result); + assertEquals(3, result.size()); + assertTrue(result.stream().allMatch(doc -> doc.getUserId() == FIRST_USER_ID)); + } + + @Test + void findAllByUserId_whenUserHasNoDocuments_shouldReturnEmptyList() { + // Arrange + int nonExistentUserId = 999; + + // Act + List result = documentRepository.findAllByUserId(nonExistentUserId); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserId_shouldNotReturnOtherUsersDocuments() { + // Act + List result = documentRepository.findAllByUserId(FIRST_USER_ID); + + // Assert + assertNotNull(result); + assertFalse(result.stream().anyMatch(doc -> doc.getUserId() == SECOND_USER_ID)); + } } // --- findByIdAndUserId --- // - @Test - void findByIdAndUserId_whenDocumentExists_shouldReturnDocument() { - // Act - Optional result = documentRepository.findByIdAndUserId(document1.getId(), FIRST_USER_ID); - - // Assert - assertTrue(result.isPresent()); - assertEquals(document1.getId(), result.get().getId()); - assertEquals(FIRST_USER_ID, result.get().getUserId()); + @Nested + @DisplayName("findByIdAndUserId(): Optional") + class FindByIdAndUserIdTest { + @Test + void findByIdAndUserId_whenDocumentExists_shouldReturnDocument() { + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), FIRST_USER_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(document1.getId(), result.get().getId()); + assertEquals(FIRST_USER_ID, result.get().getUserId()); + } + + @Test + void findByIdAndUserId_whenDocumentNotExists_shouldReturnEmpty() { + // Arrange + int nonExistentId = 999; + + // Act + Optional result = documentRepository.findByIdAndUserId(nonExistentId, FIRST_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void findByIdAndUserId_whenDocumentExistsButWrongUser_shouldReturnEmpty() { + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), SECOND_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void findByIdAndUserId_whenUserNotExists_shouldReturnEmpty() { + // Arrange + int nonExistentUserId = 999; + + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), nonExistentUserId); + + // Assert + assertFalse(result.isPresent()); + } } - @Test - void findByIdAndUserId_whenDocumentNotExists_shouldReturnEmpty() { - // Arrange - int nonExistentId = 999; - - // Act - Optional result = documentRepository.findByIdAndUserId(nonExistentId, FIRST_USER_ID); - - // Assert - assertFalse(result.isPresent()); - } - - @Test - void findByIdAndUserId_whenDocumentExistsButWrongUser_shouldReturnEmpty() { - // Act - Optional result = documentRepository.findByIdAndUserId(document1.getId(), SECOND_USER_ID); - - // Assert - assertFalse(result.isPresent()); - } - - @Test - void findByIdAndUserId_whenUserNotExists_shouldReturnEmpty() { - // Arrange - int nonExistentUserId = 999; - - // Act - Optional result = documentRepository.findByIdAndUserId(document1.getId(), nonExistentUserId); - - // Assert - assertFalse(result.isPresent()); - } - - // --- findByTitleAndUserId --- // - @Test - void findFirstByTitleAndUserId_whenDocumentExists_shouldReturnDocument() { - // Arrange - String existingTitle = document1.getTitle(); - - // Act - Optional result = documentRepository.findFirstByTitleAndUserId(existingTitle, FIRST_USER_ID); - - // Assert - assertTrue(result.isPresent()); - assertEquals(existingTitle, result.get().getTitle()); - assertEquals(FIRST_USER_ID, result.get().getUserId()); - } - - @Test - void findFirstByTitleAndUserId_whenTitleNotExists_shouldReturnEmpty() { - // Arrange - String nonExistentTitle = "Non Existent Title"; - - // Act - Optional result = documentRepository.findFirstByTitleAndUserId(nonExistentTitle, FIRST_USER_ID); - - // Assert - assertFalse(result.isPresent()); - } - - @Test - void findFirstByTitleAndUserId_whenMultipleUsersHaveSameTitle_shouldReturnOnlyRequestedUser() { - // Arrange - String sharedTitle = "Shared Title"; - Document doc1 = documentRepository.save( - DocumentDataBuilder.createSampleNote(FIRST_USER_ID).title(sharedTitle).build() - ); - Document doc2 = documentRepository.save( - DocumentDataBuilder.createSampleNote(SECOND_USER_ID).title(sharedTitle).build() - ); - - // Act - Optional result = documentRepository.findFirstByTitleAndUserId(sharedTitle, FIRST_USER_ID); - - // Assert - assertTrue(result.isPresent()); - assertEquals(FIRST_USER_ID, result.get().getUserId()); - assertEquals(doc1.getId(), result.get().getId()); + // --- findFirstByTitleAndUserId --- // + @Nested + @DisplayName("findFirstByTitleAndUserId(): Optional") + class FindFirstByTitleAndUserIdTest { + @Test + void findFirstByTitleAndUserId_whenDocumentExists_shouldReturnDocument() { + // Arrange + String existingTitle = document1.getTitle(); + + // Act + Optional result = documentRepository.findFirstByTitleAndUserId(existingTitle, FIRST_USER_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(existingTitle, result.get().getTitle()); + assertEquals(FIRST_USER_ID, result.get().getUserId()); + } + + @Test + void findFirstByTitleAndUserId_whenTitleNotExists_shouldReturnEmpty() { + // Arrange + String nonExistentTitle = "Non Existent Title"; + + // Act + Optional result = documentRepository.findFirstByTitleAndUserId(nonExistentTitle, FIRST_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void findFirstByTitleAndUserId_whenMultipleUsersHaveSameTitle_shouldReturnOnlyRequestedUser() { + // Arrange + String sharedTitle = "Shared Title"; + Document doc1 = documentRepository.save( + DocumentDataBuilder.createSampleNote(FIRST_USER_ID).title(sharedTitle).build() + ); + Document doc2 = documentRepository.save( + DocumentDataBuilder.createSampleNote(SECOND_USER_ID).title(sharedTitle).build() + ); + + // Act + Optional result = documentRepository.findFirstByTitleAndUserId(sharedTitle, FIRST_USER_ID); + + // Assert + assertTrue(result.isPresent()); + assertEquals(FIRST_USER_ID, result.get().getUserId()); + assertEquals(doc1.getId(), result.get().getId()); + } } // --- findAllByIdIn --- // - @Test - void findAllByIdIn_whenAllIdsExist_shouldReturnAllDocuments() { - // Arrange - List ids = Arrays.asList(document1.getId(), document2.getId()); - - // Act - List result = documentRepository.findAllByIdIn(ids); - - // Assert - assertNotNull(result); - assertEquals(2, result.size()); - assertTrue(result.stream().anyMatch(doc -> doc.getId().equals(document1.getId()))); - assertTrue(result.stream().anyMatch(doc -> doc.getId().equals(document2.getId()))); - } - - @Test - void findAllByIdIn_whenSomeIdsNotExist_shouldReturnOnlyExistingDocuments() { - // Arrange - List ids = Arrays.asList(document1.getId(), 999, 998); - - // Act - List result = documentRepository.findAllByIdIn(ids); - - // Assert - assertNotNull(result); - assertEquals(1, result.size()); - assertEquals(document1.getId(), result.get(0).getId()); - } - - @Test - void findAllByIdIn_whenNoIdsExist_shouldReturnEmptyList() { - // Arrange - List ids = Arrays.asList(999, 998, 997); - - // Act - List result = documentRepository.findAllByIdIn(ids); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void findAllByIdIn_withEmptyIdList_shouldReturnEmptyList() { - // Arrange - List emptyIds = Collections.emptyList(); - - // Act - List result = documentRepository.findAllByIdIn(emptyIds); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void findAllByIdIn_shouldReturnDocumentsFromMultipleUsers() { - // Arrange - List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); - - // Act - List result = documentRepository.findAllByIdIn(ids); - - // Assert - assertNotNull(result); - assertEquals(2, result.size()); - assertTrue(result.stream().anyMatch(doc -> doc.getUserId() == FIRST_USER_ID)); - assertTrue(result.stream().anyMatch(doc -> doc.getUserId() == SECOND_USER_ID)); - } - - @Test - void findAllByIdIn_withSingleId_shouldReturnSingleDocument() { - // Arrange - List ids = Collections.singletonList(document1.getId()); - - // Act - List result = documentRepository.findAllByIdIn(ids); - - // Assert - assertNotNull(result); - assertEquals(1, result.size()); - assertEquals(document1.getId(), result.get(0).getId()); + @Nested + @DisplayName("findAllByIdIn(): List") + class FindAllByIdInTest { + @Test + void findAllByIdIn_whenAllIdsExist_shouldReturnAllDocuments() { + // Arrange + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.stream().anyMatch(doc -> doc.getId().equals(document1.getId()))); + assertTrue(result.stream().anyMatch(doc -> doc.getId().equals(document2.getId()))); + } + + @Test + void findAllByIdIn_whenSomeIdsNotExist_shouldReturnOnlyExistingDocuments() { + // Arrange + List ids = Arrays.asList(document1.getId(), 999, 998); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } + + @Test + void findAllByIdIn_whenNoIdsExist_shouldReturnEmptyList() { + // Arrange + List ids = Arrays.asList(999, 998, 997); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByIdIn_withEmptyIdList_shouldReturnEmptyList() { + // Arrange + List emptyIds = Collections.emptyList(); + + // Act + List result = documentRepository.findAllByIdIn(emptyIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByIdIn_shouldReturnDocumentsFromMultipleUsers() { + // Arrange + List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.stream().anyMatch(doc -> doc.getUserId() == FIRST_USER_ID)); + assertTrue(result.stream().anyMatch(doc -> doc.getUserId() == SECOND_USER_ID)); + } + + @Test + void findAllByIdIn_withSingleId_shouldReturnSingleDocument() { + // Arrange + List ids = Collections.singletonList(document1.getId()); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } } // --- findAllByUserIdAndIdIn --- // - @Test - void findAllByUserIdAndIdIn_whenAllIdsExistForUser_shouldReturnAllDocuments() { - // Arrange - List ids = Arrays.asList(document1.getId(), document2.getId()); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); - - // Assert - assertNotNull(result); - assertEquals(2, result.size()); - assertTrue(result.stream().allMatch(doc -> doc.getUserId() == FIRST_USER_ID)); - } - - @Test - void findAllByUserIdAndIdIn_whenSomeIdsNotExistForUser_shouldReturnOnlyExisting() { - // Arrange - List ids = Arrays.asList(document1.getId(), 999); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); - - // Assert - assertNotNull(result); - assertEquals(1, result.size()); - assertEquals(document1.getId(), result.get(0).getId()); - } - - @Test - void findAllByUserIdAndIdIn_whenNoIdsExistForUser_shouldReturnEmptyList() { - // Arrange - List ids = Arrays.asList(999, 998); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void findAllByUserIdAndIdIn_withEmptyIdList_shouldReturnEmptyList() { - // Arrange - List emptyIds = Collections.emptyList(); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, emptyIds); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void findAllByUserIdAndIdIn_whenDocumentsBelongToAnotherUser_shouldReturnEmptyList() { - // Arrange - trying to get user 1's documents with user 2's ID - List ids = Arrays.asList(document1.getId(), document2.getId()); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(SECOND_USER_ID, ids); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void findAllByUserIdAndIdIn_withMixedUserDocuments_shouldReturnOnlyRequestedUserDocuments() { - // Arrange - mixing documents from different users - List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); - - // Assert - assertNotNull(result); - assertEquals(1, result.size()); - assertEquals(document1.getId(), result.get(0).getId()); - assertEquals(FIRST_USER_ID, result.get(0).getUserId()); - } - - @Test - void findAllByUserIdAndIdIn_withSingleId_shouldReturnSingleDocument() { - // Arrange - List ids = Collections.singletonList(document1.getId()); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); - - // Assert - assertNotNull(result); - assertEquals(1, result.size()); - assertEquals(document1.getId(), result.get(0).getId()); - } - - @Test - void findAllByUserIdAndIdIn_whenUserNotExists_shouldReturnEmptyList() { - // Arrange - int nonExistentUserId = 999; - List ids = Arrays.asList(document1.getId(), document2.getId()); - - // Act - List result = documentRepository.findAllByUserIdAndIdIn(nonExistentUserId, ids); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); + @Nested + @DisplayName("findAllByUserAndIdIn(): List") + class FindAllByUserIdAndIdIn { + @Test + void findAllByUserIdAndIdIn_whenAllIdsExistForUser_shouldReturnAllDocuments() { + // Arrange + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.stream().allMatch(doc -> doc.getUserId() == FIRST_USER_ID)); + } + + @Test + void findAllByUserIdAndIdIn_whenSomeIdsNotExistForUser_shouldReturnOnlyExisting() { + // Arrange + List ids = Arrays.asList(document1.getId(), 999); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } + + @Test + void findAllByUserIdAndIdIn_whenNoIdsExistForUser_shouldReturnEmptyList() { + // Arrange + List ids = Arrays.asList(999, 998); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserIdAndIdIn_withEmptyIdList_shouldReturnEmptyList() { + // Arrange + List emptyIds = Collections.emptyList(); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, emptyIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserIdAndIdIn_whenDocumentsBelongToAnotherUser_shouldReturnEmptyList() { + // Arrange - trying to get user 1's documents with user 2's ID + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(SECOND_USER_ID, ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void findAllByUserIdAndIdIn_withMixedUserDocuments_shouldReturnOnlyRequestedUserDocuments() { + // Arrange - mixing documents from different users + List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + assertEquals(FIRST_USER_ID, result.get(0).getUserId()); + } + + @Test + void findAllByUserIdAndIdIn_withSingleId_shouldReturnSingleDocument() { + // Arrange + List ids = Collections.singletonList(document1.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(document1.getId(), result.get(0).getId()); + } + + @Test + void findAllByUserIdAndIdIn_whenUserNotExists_shouldReturnEmptyList() { + // Arrange + int nonExistentUserId = 999; + List ids = Arrays.asList(document1.getId(), document2.getId()); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(nonExistentUserId, ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } } } diff --git a/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java b/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java index aeabee4..58ef9d5 100644 --- a/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java +++ b/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java @@ -10,8 +10,7 @@ import com.be08.smart_notes.model.Document; import com.be08.smart_notes.model.User; import com.be08.smart_notes.repository.DocumentRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -28,6 +27,8 @@ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@DisplayName("Note Service Test") public class NoteServiceTest { @Mock DocumentRepository documentRepository; @@ -58,327 +59,351 @@ void setUp() { } // --- Get note --- // - @Test - void getNote_withNonExistentId_shouldThrowException() { - // Arrange - int nonExistentId = 999; - int userId = existingUser.getId(); - when(documentRepository.findByIdAndUserId(nonExistentId, userId)).thenReturn(Optional.empty()); - - // Act & Assert - AppException actualException = assertThrows(AppException.class, () -> { - noteService.getNote(nonExistentId); - }); - assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, actualException.getErrorCode()); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findByIdAndUserId(nonExistentId, userId); - verify(documentMapper, never()).toNoteResponse(any()); - } - - @Test - void getNote_whenNoteExists_shouldReturnNoteResponse() { - // Arrange - int noteId = existingNote.getId(); - int userId = existingUser.getId(); - when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); - when(documentMapper.toNoteResponse(existingNote)).thenReturn(existingNoteResponse); - - // Act - NoteResponse actualResponse = noteService.getNote(noteId); - - // Assert - assertNotNull(actualResponse); - assertEquals(existingNoteResponse, actualResponse); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findByIdAndUserId(noteId, userId); - verify(documentMapper).toNoteResponse(existingNote); + @Nested + @DisplayName("getNote(): NoteResponse") + class GetNoteTest { + @Test + void getNote_withNonExistentId_shouldThrowException() { + // Arrange + int nonExistentId = 999; + int userId = existingUser.getId(); + when(documentRepository.findByIdAndUserId(nonExistentId, userId)).thenReturn(Optional.empty()); + + // Act & Assert + AppException actualException = assertThrows(AppException.class, () -> { + noteService.getNote(nonExistentId); + }); + assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, actualException.getErrorCode()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(nonExistentId, userId); + verify(documentMapper, never()).toNoteResponse(any()); + } + + @Test + void getNote_whenNoteExists_shouldReturnNoteResponse() { + // Arrange + int noteId = existingNote.getId(); + int userId = existingUser.getId(); + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); + when(documentMapper.toNoteResponse(existingNote)).thenReturn(existingNoteResponse); + + // Act + NoteResponse actualResponse = noteService.getNote(noteId); + + // Assert + assertNotNull(actualResponse); + assertEquals(existingNoteResponse, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentMapper).toNoteResponse(existingNote); + } } // -- Get all notes -- // - @Test - void getAllNotes_whenEmpty_shouldReturnEmptyNoteResponseList() { - // Arrange - int userId = existingUser.getId(); - List emptyList = Collections.emptyList(); - List expectedNoteResponseList = Collections.emptyList(); - when(documentRepository.findAllByUserId(userId)).thenReturn(emptyList); - when(documentMapper.toNoteResponseList(emptyList)).thenReturn(expectedNoteResponseList); - - // Act - List actualResponse = noteService.getAllNotes(); - - // Assert - assertNotNull(actualResponse); - assertTrue(actualResponse.isEmpty()); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findAllByUserId(userId); - verify(documentMapper).toNoteResponseList(emptyList); - } - - @Test - void getAllNotes_whenNotEmpty_shouldReturnNoteResponseList() { - // Arrange - int userId = existingUser.getId(); - List noteList = List.of(existingNote, anotherExistingNote); - List expectedNoteResponseList = List.of(existingNoteResponse, anotherExistingNoteResponse); - when(documentRepository.findAllByUserId(userId)).thenReturn(noteList); - when(documentMapper.toNoteResponseList(noteList)).thenReturn(expectedNoteResponseList); - - // Act - List actualResponse = noteService.getAllNotes(); - - // Assert - assertNotNull(actualResponse); - assertFalse(actualResponse.isEmpty()); - assertEquals(expectedNoteResponseList.size(), actualResponse.size()); - assertEquals(expectedNoteResponseList, actualResponse); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findAllByUserId(userId); - verify(documentMapper).toNoteResponseList(noteList); - } - - @Test - void getAllNotes_withSingleNote_shouldReturnSingleNoteResponseList() { - // Arrange - int userId = existingUser.getId(); - List noteList = List.of(existingNote); - List expectedNoteResponseList = List.of(existingNoteResponse); - when(documentRepository.findAllByUserId(userId)).thenReturn(noteList); - when(documentMapper.toNoteResponseList(noteList)).thenReturn(expectedNoteResponseList); - - // Act - List actualResponse = noteService.getAllNotes(); - - // Assert - assertNotNull(actualResponse); - assertEquals(1, actualResponse.size()); - assertEquals(existingNoteResponse, actualResponse.get(0)); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findAllByUserId(userId); - verify(documentMapper).toNoteResponseList(noteList); + @Nested + @DisplayName("getAllNotes(): List") + class GetAllNotesTest { + @Test + void getAllNotes_whenEmpty_shouldReturnEmptyNoteResponseList() { + // Arrange + int userId = existingUser.getId(); + List emptyList = Collections.emptyList(); + List expectedNoteResponseList = Collections.emptyList(); + when(documentRepository.findAllByUserId(userId)).thenReturn(emptyList); + when(documentMapper.toNoteResponseList(emptyList)).thenReturn(expectedNoteResponseList); + + // Act + List actualResponse = noteService.getAllNotes(); + + // Assert + assertNotNull(actualResponse); + assertTrue(actualResponse.isEmpty()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findAllByUserId(userId); + verify(documentMapper).toNoteResponseList(emptyList); + } + + @Test + void getAllNotes_whenNotEmpty_shouldReturnNoteResponseList() { + // Arrange + int userId = existingUser.getId(); + List noteList = List.of(existingNote, anotherExistingNote); + List expectedNoteResponseList = List.of(existingNoteResponse, anotherExistingNoteResponse); + when(documentRepository.findAllByUserId(userId)).thenReturn(noteList); + when(documentMapper.toNoteResponseList(noteList)).thenReturn(expectedNoteResponseList); + + // Act + List actualResponse = noteService.getAllNotes(); + + // Assert + assertNotNull(actualResponse); + assertFalse(actualResponse.isEmpty()); + assertEquals(expectedNoteResponseList.size(), actualResponse.size()); + assertEquals(expectedNoteResponseList, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findAllByUserId(userId); + verify(documentMapper).toNoteResponseList(noteList); + } + + @Test + void getAllNotes_withSingleNote_shouldReturnSingleNoteResponseList() { + // Arrange + int userId = existingUser.getId(); + List noteList = List.of(existingNote); + List expectedNoteResponseList = List.of(existingNoteResponse); + when(documentRepository.findAllByUserId(userId)).thenReturn(noteList); + when(documentMapper.toNoteResponseList(noteList)).thenReturn(expectedNoteResponseList); + + // Act + List actualResponse = noteService.getAllNotes(); + + // Assert + assertNotNull(actualResponse); + assertEquals(1, actualResponse.size()); + assertEquals(existingNoteResponse, actualResponse.get(0)); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findAllByUserId(userId); + verify(documentMapper).toNoteResponseList(noteList); + } } // --- Create note --- // - @Test - void createNote_withValidInput_shouldCreateSuccessfully() { - // Arrange - int newId = 2; - String newTitle = "Test New Note"; - String newContent = "Test New Content"; - NoteUpsertRequest newRequest = NoteUpsertRequest.builder() - .title(newTitle).content(newContent).build(); - Document savedNote = Document.builder().id(newId).userId(existingUser.getId()) - .title(newTitle).content(newContent).type(DocumentType.NOTE).build(); - NoteResponse expectedResponse = NoteResponse.builder().id(newId) - .title(newTitle).content(newContent).build(); - - when(documentRepository.save(any(Document.class))).thenReturn(savedNote); - when(documentMapper.toNoteResponse(savedNote)).thenReturn(expectedResponse); - - // Act - NoteResponse actualResponse = noteService.createNote(newRequest); - - // Assert - assertNotNull(actualResponse); - assertEquals(expectedResponse, actualResponse); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).save(any(Document.class)); - verify(documentMapper).toNoteResponse(savedNote); + @Nested + @DisplayName("createNote(): NoteResponse") + class CreateNoteTest { + @Test + void createNote_withValidInput_shouldCreateSuccessfully() { + // Arrange + int newId = 2; + String newTitle = "Test New Note"; + String newContent = "Test New Content"; + NoteUpsertRequest newRequest = NoteUpsertRequest.builder() + .title(newTitle).content(newContent).build(); + Document savedNote = Document.builder().id(newId).userId(existingUser.getId()) + .title(newTitle).content(newContent).type(DocumentType.NOTE).build(); + NoteResponse expectedResponse = NoteResponse.builder().id(newId) + .title(newTitle).content(newContent).build(); + + when(documentRepository.save(any(Document.class))).thenReturn(savedNote); + when(documentMapper.toNoteResponse(savedNote)).thenReturn(expectedResponse); + + // Act + NoteResponse actualResponse = noteService.createNote(newRequest); + + // Assert + assertNotNull(actualResponse); + assertEquals(expectedResponse, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).save(any(Document.class)); + verify(documentMapper).toNoteResponse(savedNote); + } } // --- Update note --- // - @Test - void updateNote_whenNoteExists_shouldUpdateSuccessfully() { - // Arrange - int noteId = existingNote.getId(); - int userId = existingUser.getId(); - String updatedTitle = "Test Updated Title"; - String updatedContent = "Test Updated Content"; - NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() - .title(updatedTitle).content(updatedContent).build(); - Document updatedNote = Document.builder().id(noteId).userId(userId) - .title(updatedTitle).content(updatedContent).type(DocumentType.NOTE).build(); - NoteResponse expectedResponse = NoteResponse.builder().id(noteId) - .title(updatedTitle).content(updatedContent).build(); - - when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); - when(documentRepository.save(any(Document.class))).thenReturn(updatedNote); - when(documentMapper.toNoteResponse(updatedNote)).thenReturn(expectedResponse); - - // Act - NoteResponse actualResponse = noteService.updateNote(noteId, updateRequest); - - // Assert - assertNotNull(actualResponse); - assertEquals(expectedResponse, actualResponse); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findByIdAndUserId(noteId, userId); - verify(documentRepository).save(any(Document.class)); - verify(documentMapper).toNoteResponse(updatedNote); - } - - @Test - void updateNote_whenNoteNotFound_shouldThrowException() { - // Arrange - int noteId = 999; - int userId = existingUser.getId(); - String updatedTitle = "Test Updated Title"; - String updatedContent = "Test Updated Content"; - NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() - .title(updatedTitle).content(updatedContent).build(); - - when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.empty()); - - // Act & Assert - AppException exception = assertThrows(AppException.class, () -> { - noteService.updateNote(noteId, updateRequest); - }); - assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, exception.getErrorCode()); - - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findByIdAndUserId(noteId, userId); - verify(documentRepository, never()).save(any()); - verify(documentMapper, never()).toNoteResponse(any()); + @Nested + @DisplayName("updateNote(): NoteResponse") + class UpdateNoteTest { + @Test + void updateNote_whenNoteExists_shouldUpdateSuccessfully() { + // Arrange + int noteId = existingNote.getId(); + int userId = existingUser.getId(); + String updatedTitle = "Test Updated Title"; + String updatedContent = "Test Updated Content"; + NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() + .title(updatedTitle).content(updatedContent).build(); + Document updatedNote = Document.builder().id(noteId).userId(userId) + .title(updatedTitle).content(updatedContent).type(DocumentType.NOTE).build(); + NoteResponse expectedResponse = NoteResponse.builder().id(noteId) + .title(updatedTitle).content(updatedContent).build(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); + when(documentRepository.save(any(Document.class))).thenReturn(updatedNote); + when(documentMapper.toNoteResponse(updatedNote)).thenReturn(expectedResponse); + + // Act + NoteResponse actualResponse = noteService.updateNote(noteId, updateRequest); + + // Assert + assertNotNull(actualResponse); + assertEquals(expectedResponse, actualResponse); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository).save(any(Document.class)); + verify(documentMapper).toNoteResponse(updatedNote); + } + + @Test + void updateNote_whenNoteNotFound_shouldThrowException() { + // Arrange + int noteId = 999; + int userId = existingUser.getId(); + String updatedTitle = "Test Updated Title"; + String updatedContent = "Test Updated Content"; + NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() + .title(updatedTitle).content(updatedContent).build(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.empty()); + + // Act & Assert + AppException exception = assertThrows(AppException.class, () -> { + noteService.updateNote(noteId, updateRequest); + }); + assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, exception.getErrorCode()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository, never()).save(any()); + verify(documentMapper, never()).toNoteResponse(any()); + } } // --- Delete note --- // - @Test - void deleteNote_whenNoteExists_shouldDeleteSuccessfully() { - // Arrange - int noteId = existingNote.getId(); - int userId = existingUser.getId(); - - when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); - - // Act - noteService.deleteNote(noteId); - - // Assert - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findByIdAndUserId(noteId, userId); - verify(documentRepository).deleteById(noteId); - } - - @Test - void deleteNote_whenNoteNotFound_shouldThrowException() { - // Arrange - int noteId = 999; - int userId = existingUser.getId(); - - when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.empty()); - - // Act & Assert - AppException exception = assertThrows(AppException.class, () -> { + @Nested + @DisplayName("deleteNote(): void") + class DeleteNoteTest { + @Test + void deleteNote_whenNoteExists_shouldDeleteSuccessfully() { + // Arrange + int noteId = existingNote.getId(); + int userId = existingUser.getId(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existingNote)); + + // Act noteService.deleteNote(noteId); - }); - assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, exception.getErrorCode()); - verify(authorizationService).getCurrentUserId(); - verify(documentRepository).findByIdAndUserId(noteId, userId); - verify(documentRepository, never()).deleteById(anyInt()); + // Assert + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository).deleteById(noteId); + } + + @Test + void deleteNote_whenNoteNotFound_shouldThrowException() { + // Arrange + int noteId = 999; + int userId = existingUser.getId(); + + when(documentRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.empty()); + + // Act & Assert + AppException exception = assertThrows(AppException.class, () -> { + noteService.deleteNote(noteId); + }); + assertEquals(ErrorCode.DOCUMENT_NOT_FOUND, exception.getErrorCode()); + + verify(authorizationService).getCurrentUserId(); + verify(documentRepository).findByIdAndUserId(noteId, userId); + verify(documentRepository, never()).deleteById(anyInt()); + } } // ------ Methods that returns entities ------ // - @Test - void getAllNotesByUserIdAndIds_whenNotesExist_shouldReturnMatchingNotes() { - // Arrange - int userId = existingUser.getId(); - List noteIds = List.of(existingNote.getId(), anotherExistingNote.getId()); - List expectedNotes = List.of(existingNote, anotherExistingNote); + @Nested + @DisplayName("getAllNotesByUserIdAndIds(): List") + class GetAllNotesByUserIdAndIdsTest { + @Test + void getAllNotesByUserIdAndIds_whenNotesExist_shouldReturnMatchingNotes() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(existingNote.getId(), anotherExistingNote.getId()); + List expectedNotes = List.of(existingNote, anotherExistingNote); - when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); - // Act - List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); - // Assert - assertNotNull(actualNotes); - assertEquals(2, actualNotes.size()); - assertEquals(expectedNotes, actualNotes); + // Assert + assertNotNull(actualNotes); + assertEquals(2, actualNotes.size()); + assertEquals(expectedNotes, actualNotes); - verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); - } + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } - @Test - void getAllNotesByUserIdAndIds_whenNoMatches_shouldReturnEmptyList() { - // Arrange - int userId = existingUser.getId(); - List noteIds = List.of(998, 999); + @Test + void getAllNotesByUserIdAndIds_whenNoMatches_shouldReturnEmptyList() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(998, 999); - when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(Collections.emptyList()); + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(Collections.emptyList()); - // Act - List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); - // Assert - assertNotNull(actualNotes); - assertTrue(actualNotes.isEmpty()); + // Assert + assertNotNull(actualNotes); + assertTrue(actualNotes.isEmpty()); - verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); - } + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } - @Test - void getAllNotesByUserIdAndIds_withEmptyIdList_shouldReturnEmptyList() { - // Arrange - int userId = existingUser.getId(); - List emptyIds = Collections.emptyList(); + @Test + void getAllNotesByUserIdAndIds_withEmptyIdList_shouldReturnEmptyList() { + // Arrange + int userId = existingUser.getId(); + List emptyIds = Collections.emptyList(); - when(documentRepository.findAllByUserIdAndIdIn(userId, emptyIds)).thenReturn(Collections.emptyList()); + when(documentRepository.findAllByUserIdAndIdIn(userId, emptyIds)).thenReturn(Collections.emptyList()); - // Act - List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, emptyIds); + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, emptyIds); - // Assert - assertNotNull(actualNotes); - assertTrue(actualNotes.isEmpty()); + // Assert + assertNotNull(actualNotes); + assertTrue(actualNotes.isEmpty()); - verify(documentRepository).findAllByUserIdAndIdIn(userId, emptyIds); - } + verify(documentRepository).findAllByUserIdAndIdIn(userId, emptyIds); + } - @Test - void getAllNotesByUserIdAndIds_withSingleId_shouldReturnSingleNote() { - // Arrange - int userId = existingUser.getId(); - List noteIds = List.of(existingNote.getId()); - List expectedNotes = List.of(existingNote); + @Test + void getAllNotesByUserIdAndIds_withSingleId_shouldReturnSingleNote() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(existingNote.getId()); + List expectedNotes = List.of(existingNote); - when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); - // Act - List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); - // Assert - assertNotNull(actualNotes); - assertEquals(expectedNotes.size(), actualNotes.size()); - assertEquals(existingNote, actualNotes.get(0)); + // Assert + assertNotNull(actualNotes); + assertEquals(expectedNotes.size(), actualNotes.size()); + assertEquals(existingNote, actualNotes.get(0)); - verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); - } + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } - @Test - void getAllNotesByUserIdAndIds_withPartialMatches_shouldReturnOnlyMatchingNotes() { - // Arrange - int userId = existingUser.getId(); - List noteIds = List.of(existingNote.getId(), 999); // Only ID 1 exists - List expectedNotes = List.of(existingNote); // Only returns existing note + @Test + void getAllNotesByUserIdAndIds_withPartialMatches_shouldReturnOnlyMatchingNotes() { + // Arrange + int userId = existingUser.getId(); + List noteIds = List.of(existingNote.getId(), 999); // Only ID 1 exists + List expectedNotes = List.of(existingNote); // Only returns existing note - when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); + when(documentRepository.findAllByUserIdAndIdIn(userId, noteIds)).thenReturn(expectedNotes); - // Act - List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); + // Act + List actualNotes = noteService.getAllNotesByUserIdAndIds(userId, noteIds); - // Assert - assertNotNull(actualNotes); - assertEquals(expectedNotes.size(), actualNotes.size()); - assertEquals(existingNote, actualNotes.get(0)); + // Assert + assertNotNull(actualNotes); + assertEquals(expectedNotes.size(), actualNotes.size()); + assertEquals(existingNote, actualNotes.get(0)); - verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + verify(documentRepository).findAllByUserIdAndIdIn(userId, noteIds); + } } } From c07941310be3d3050c911d3132bcd4bbba851d3d Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 9 Dec 2025 21:26:59 +1100 Subject: [PATCH 52/63] test(note): add integration test for note controller with testcontainers --- README.md | 17 + pom.xml | 124 ++++++- .../java/com/be08/smart_notes/model/User.java | 2 +- .../com/be08/smart_notes/controller/Base.java | 43 +++ .../NoteControllerIntegrationTest.java | 334 ++++++++++++++++++ .../be08/smart_notes/helper/JwtBuilder.java | 15 + src/test/resources/application.properties | 36 +- src/test/resources/init-db.sql | 167 +++++++++ 8 files changed, 728 insertions(+), 10 deletions(-) create mode 100644 src/test/java/com/be08/smart_notes/controller/Base.java create mode 100644 src/test/java/com/be08/smart_notes/controller/NoteControllerIntegrationTest.java create mode 100644 src/test/java/com/be08/smart_notes/helper/JwtBuilder.java create mode 100644 src/test/resources/init-db.sql diff --git a/README.md b/README.md index 2ddb51b..0b3cdfb 100644 --- a/README.md +++ b/README.md @@ -47,4 +47,21 @@ DB_PORT= DB_NAME= DB_USERNAME= DB_PASSWORD= +``` + +## How to test +For Windows, run one of below commands + +```bash +# Run all tests +./mvnw.cmd test + +# Run specific test +./mvnw.cmd test -Dtest=NoteServiceTest + +# Run unit test only +./mvnw.cmd test -Punit-test + +# Run integration test only +./mvnw.cmd verify -Pintegration-test ``` \ No newline at end of file diff --git a/pom.xml b/pom.xml index c93002c..2518a42 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,9 @@ 17 1.18.30 1.6.3 + 1.16.2 + false + false @@ -153,6 +156,36 @@ test + + + org.springframework.security + spring-security-test + 6.5.5 + test + + + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + mysql + test + + @@ -178,6 +211,18 @@ + + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + + + @@ -204,16 +249,75 @@ - + org.apache.maven.plugins maven-surefire-plugin + 3.5.3 + + + me.fabriciorby + maven-surefire-junit5-tree-reporter + 1.5.1 + + + + ${skip.unit.tests} **/*Test.java + + **/*IntegrationTest.java + + + plain + + true + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.0.0 + + + + me.fabriciorby + maven-surefire-junit5-tree-reporter + 1.5.1 + + + + + ${skip.integration.tests} + + **/*IntegrationTest.java + + + plain + + true + + + + + + + + integration-test + verify + + + + @@ -231,6 +335,9 @@ test test + + false + false @@ -239,6 +346,21 @@ prod + + + + unit-test + + test + + + + integration-test + + false + true + + diff --git a/src/main/java/com/be08/smart_notes/model/User.java b/src/main/java/com/be08/smart_notes/model/User.java index 21231d6..d1b14d6 100644 --- a/src/main/java/com/be08/smart_notes/model/User.java +++ b/src/main/java/com/be08/smart_notes/model/User.java @@ -18,7 +18,7 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "'user'") +@Table(name = "user") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/test/java/com/be08/smart_notes/controller/Base.java b/src/test/java/com/be08/smart_notes/controller/Base.java new file mode 100644 index 0000000..4990117 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/controller/Base.java @@ -0,0 +1,43 @@ +package com.be08.smart_notes.controller; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +public class Base { + @Container + public static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.0.36") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test") + .withInitScript("init-db.sql") + .withReuse(true); + + @Container + public static final GenericContainer redisContainer = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) + .withExposedPorts(6379) + .withReuse(true); + + @DynamicPropertySource + public static void properties(DynamicPropertyRegistry registry) { + // MySQL configuration + registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl); + registry.add("spring.datasource.username", mysqlContainer::getUsername); + registry.add("spring.datasource.password", mysqlContainer::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver"); + + // JPA configuration + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); + registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.MySQLDialect"); + registry.add("spring.jpa.show-sql", () -> "false"); + + // Redis configuration + registry.add("spring.data.redis.host", redisContainer::getHost); + registry.add("spring.data.redis.port", redisContainer::getFirstMappedPort); + } +} diff --git a/src/test/java/com/be08/smart_notes/controller/NoteControllerIntegrationTest.java b/src/test/java/com/be08/smart_notes/controller/NoteControllerIntegrationTest.java new file mode 100644 index 0000000..0949d78 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/controller/NoteControllerIntegrationTest.java @@ -0,0 +1,334 @@ +package com.be08.smart_notes.controller; + +import com.be08.smart_notes.dto.request.NoteUpsertRequest; +import com.be08.smart_notes.helper.DocumentDataBuilder; +import com.be08.smart_notes.model.Document; +import com.be08.smart_notes.model.User; +import com.be08.smart_notes.repository.DocumentRepository; +import com.be08.smart_notes.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import java.security.KeyPair; +import java.time.LocalDateTime; + +import static com.be08.smart_notes.helper.JwtBuilder.jwtWithUserId; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@DisplayName("Note Controller Integration Test") +public class NoteControllerIntegrationTest extends Base { + @Autowired + MockMvc mockMvc; + @Autowired + ObjectMapper objectMapper; + @Autowired + DocumentRepository documentRepository; + @Autowired + UserRepository userRepository; + + @MockitoBean + JwtEncoder jwtEncoder; + @MockitoBean + JWKSource jwkSource; + @MockitoBean + KeyPair signingKeyPair; + + static final String BASE_URI = "/api/documents/notes"; + int TEST_USER_ID; + int OTHER_USER_ID; + Document existingNote; + Document anotherNote; + + @BeforeEach + void setUpEach() { + userRepository.deleteAll(); + documentRepository.deleteAll(); + + User testUser1 = userRepository.save(User.builder().name("Test User").email("example-user@gmail.com").password("123456").createdAt(LocalDateTime.now()).build()); + User testUser2 = userRepository.save(User.builder().name("Test User").email("another-user@gmail.com").password("123456").createdAt(LocalDateTime.now()).build()); + + TEST_USER_ID = testUser1.getId(); + OTHER_USER_ID = testUser2.getId(); + + existingNote = documentRepository.save(DocumentDataBuilder.createSampleNote(TEST_USER_ID).build()); + anotherNote = documentRepository.save(DocumentDataBuilder.createSampleNote(OTHER_USER_ID).build()); + } + + @Nested + @DisplayName("POST /api/documents/notes") + class CreateNoteIntegrationTests { + @Test + void shouldCreateNoteAndPersist() throws Exception { + // Arrange + String title = "Integration Test Note"; + String content = "Integration Test Content"; + NoteUpsertRequest request = NoteUpsertRequest.builder() + .title(title).content(content).build(); + + int initialSize = documentRepository.findAllByUserId(TEST_USER_ID).size(); + + // Act & Assert + MvcResult result = mockMvc.perform( + post(BASE_URI) + .with(jwtWithUserId(TEST_USER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.title").value(title)) + .andExpect(jsonPath("$.data.content").value(content)) + .andExpect(jsonPath("$.data.id").exists()) + .andExpect(jsonPath("$.data.createdAt").exists()) + .andReturn(); + + int createdNoteId = objectMapper.readTree(result.getResponse().getContentAsString()).get("data").get("id").asInt(); + Document createdNote = documentRepository.findById(createdNoteId).orElseThrow(); + + assertEquals(TEST_USER_ID, createdNote.getUserId()); + assertEquals(initialSize + 1, documentRepository.findAllByUserId(TEST_USER_ID).size()); + } + + @Test + void shouldRejectRequestWithNullTitle() throws Exception { + // Arrange + NoteUpsertRequest invalidRequest = NoteUpsertRequest.builder() + .title(null).content("Content").build(); + int initialSize = documentRepository.findAllByUserId(TEST_USER_ID).size(); + + // Act & Assert + mockMvc.perform( + post(BASE_URI) + .with(jwtWithUserId(TEST_USER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest)) + ) + .andExpect(status().isBadRequest()); + + assertEquals(initialSize, documentRepository.findAllByUserId(TEST_USER_ID).size()); + } + + @Test + void shouldRejectRequestWithNullContent() throws Exception { + // Arrange + NoteUpsertRequest invalidRequest = NoteUpsertRequest.builder() + .title("Title").content(null).build(); + int initialSize = documentRepository.findAllByUserId(TEST_USER_ID).size(); + + // Act & Assert + mockMvc.perform( + post(BASE_URI) + .with(jwtWithUserId(TEST_USER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest)) + ) + .andExpect(status().isBadRequest()); + + assertEquals(initialSize, documentRepository.findAllByUserId(TEST_USER_ID).size()); + } + + @Test + void shouldReturn401WhenNotAuthenticated() throws Exception { + // Arrange + NoteUpsertRequest request = NoteUpsertRequest.builder() + .title("Test Note").content("Content").build(); + + // Act & Assert + mockMvc.perform( + post(BASE_URI) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("GET /api/documents/notes") + class GetAllNotesIntegrationTests { + @Test + void shouldRetrieveAllNotesForCurrentUser() throws Exception { + // Act & Assert + mockMvc.perform( + get(BASE_URI) + .with(jwtWithUserId(TEST_USER_ID)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(1))); + } + + @Test + void shouldReturnEmptyListWhenUserHasNoNotes() throws Exception { + // Arrange + documentRepository.deleteAll(); + + // Act & Assert + mockMvc.perform( + get(BASE_URI) + .with(jwtWithUserId(TEST_USER_ID)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(0))); + } + + @Test + void shouldReturn401WhenNotAuthenticated() throws Exception { + // Act & Assert + mockMvc.perform(get(BASE_URI)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("PATCH /api/documents/notes/{id}") + class UpdateNoteIntegrationTests { + @Test + void shouldUpdateExistingNote() throws Exception { + // Arrange + String updatedTitle = "Updated Title"; + String updatedContent = "Updated Content"; + NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() + .title(updatedTitle).content(updatedContent).build(); + + // Act & Assert + mockMvc.perform( + patch(BASE_URI + "/" + existingNote.getId()) + .with(jwtWithUserId(TEST_USER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(existingNote.getId())) + .andExpect(jsonPath("$.data.title").value(updatedTitle)) + .andExpect(jsonPath("$.data.content").value(updatedContent)); + + // Verify database + Document updated = documentRepository.findById(existingNote.getId()).orElseThrow(); + assertEquals(updatedTitle, updated.getTitle()); + assertEquals(updatedContent, updated.getContent()); + } + + @Test + void shouldReturn404WhenUpdatingNonExistentNote() throws Exception { + // Arrange + NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() + .title("Updated Title").content("Updated Content").build(); + + // Act & Assert + mockMvc.perform( + patch(BASE_URI + "/99999") + .with(jwtWithUserId(TEST_USER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest)) + ) + .andExpect(status().isNotFound()); + } + + @Test + void shouldRejectUpdateWithNullTitle() throws Exception { + // Arrange + NoteUpsertRequest invalidRequest = NoteUpsertRequest.builder() + .title(null).content("Updated Content").build(); + + // Act & Assert + mockMvc.perform( + patch(BASE_URI + "/" + existingNote.getId()) + .with(jwtWithUserId(TEST_USER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest)) + ) + .andExpect(status().isBadRequest()); + + // Verify unchanged + Document unchanged = documentRepository.findById(existingNote.getId()).orElseThrow(); + assertEquals(existingNote.getTitle(), unchanged.getTitle()); + } + + @Test + void shouldRejectUpdateWithNullContent() throws Exception { + // Arrange + NoteUpsertRequest invalidRequest = NoteUpsertRequest.builder() + .title("Updated Title").content(null).build(); + + // Act & Assert + mockMvc.perform( + patch(BASE_URI + "/" + existingNote.getId()) + .with(jwtWithUserId(TEST_USER_ID)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest)) + ) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn401WhenNotAuthenticated() throws Exception { + // Arrange + NoteUpsertRequest updateRequest = NoteUpsertRequest.builder() + .title("Updated Title").content("Updated Content").build(); + + // Act & Assert + mockMvc.perform( + patch(BASE_URI + "/" + existingNote.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest)) + ) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("DELETE /api/documents/notes/{id}") + class DeleteNoteIntegrationTests { + @Test + void shouldDeleteExistingNote() throws Exception { + // Arrange + int initialSize = documentRepository.findAllByUserId(TEST_USER_ID).size(); + + // Act & Assert + mockMvc.perform( + delete("/api/documents/notes/" + existingNote.getId()) + .with(jwtWithUserId(TEST_USER_ID)) + ) + .andExpect(status().isOk()); + + // Verify deletion + assertFalse(documentRepository.existsById(existingNote.getId())); + assertEquals(initialSize - 1, documentRepository.findAllByUserId(TEST_USER_ID).size()); + } + + @Test + void shouldReturn404WhenDeletingNonExistentNote() throws Exception { + // Act & Assert + mockMvc.perform( + delete("/api/documents/notes/99999") + .with(jwtWithUserId(TEST_USER_ID)) + ) + .andExpect(status().isNotFound()); + } + + @Test + void shouldReturn401WhenNotAuthenticated() throws Exception { + // Act & Assert + mockMvc.perform(delete("/api/documents/notes/" + existingNote.getId())) + .andExpect(status().isUnauthorized()); + } + } +} diff --git a/src/test/java/com/be08/smart_notes/helper/JwtBuilder.java b/src/test/java/com/be08/smart_notes/helper/JwtBuilder.java new file mode 100644 index 0000000..a31b011 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/helper/JwtBuilder.java @@ -0,0 +1,15 @@ +package com.be08.smart_notes.helper; + +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; + +public class JwtBuilder { + public static SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtWithUserId(int userId) { + return jwt().jwt(builder -> builder + .issuer("smart-notes-auth-server") + .subject(String.valueOf(userId)) + .claim("email", "example@gmail.com") + .claim("scope", "USER").build()); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 361e796..3acab71 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -17,11 +17,31 @@ spring.jpa.show-sql=false #spring.jpa.hibernate.ddl-auto=update # REDIS Configuration for Development (Local Redis - Docker) -#spring.data.redis.host=${REDIS_HOST} -#spring.data.redis.port=${REDIS_PORT} -# -## Frontend URL for Local Development -#app.frontend.url=http://localhost:3000 -# -## Logging Level for Development -#logging.level.com.be08=DEBUG \ No newline at end of file +spring.data.redis.host=${REDIS_HOST:localhost} +spring.data.redis.port=${REDIS_PORT:6379} + +# Frontend URL for Local Development +app.frontend.url=http://localhost:3000 + +# AI Inference Configuration +ai.api.url=${API_URL} +ai.api.token=${API_TOKEN} +ai.api.model=${MODEL} + +ai.api.user-role=${AI_API_USER_ROLE:user} +ai.api.system-role=${AI_API_SYSTEM_ROLE:system} +ai.api.temperature=${AI_API_TEMPERATURE:0.7} +ai.api.top-p=${AI_API_TOP_P:0.9} + +# JWT Configuration +jwt.access-token.expiration-minutes=15 +jwt.refresh-token.expiration-days=7 + +# JWT KeyStore Configuration +jwt.keystore.location=classpath:keys/jwt-keystore.jks +jwt.keystore.password=test +jwt.key.password=test + +# JWT Key Rotation Configuration +jwt.signing.key.alias=jwtkey-2025 +jwt.verification.key.aliases=jwtkey-2025 \ No newline at end of file diff --git a/src/test/resources/init-db.sql b/src/test/resources/init-db.sql new file mode 100644 index 0000000..66b81d1 --- /dev/null +++ b/src/test/resources/init-db.sql @@ -0,0 +1,167 @@ +CREATE DATABASE IF NOT EXISTS `testdb`; +USE `testdb`; + +DROP TABLE IF EXISTS `user`; +CREATE TABLE `user` +( + `id` int NOT NULL AUTO_INCREMENT, + `email` varchar(255) NOT NULL, + `password` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `avatar_url` tinytext, + `created_at` datetime NOT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`) +); + +DROP TABLE IF EXISTS `tag`; +CREATE TABLE `tag` +( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `name` varchar(50) NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_tags_user_id` (`user_id`), + CONSTRAINT `fk_tags_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) +); + +DROP TABLE IF EXISTS `document`; +CREATE TABLE `document` +( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `title` varchar(255) NOT NULL, + `type` enum ('NOTE','PDF') NOT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime DEFAULT NULL, + `content` text, + `file_url` tinytext, + `file_size` int DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_documents_user_id` (`user_id`), + CONSTRAINT `fk_documents_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`), + CONSTRAINT `chk_document_type_content_file` CHECK ((((`type` = _utf8mb4'NOTE') and (`content` is not null)) or + ((`type` = _utf8mb4'PDF') and (`file_url` is not null) and + (`file_size` is not null)))) +); + +DROP TABLE IF EXISTS `document_tag`; +CREATE TABLE `document_tag` +( + `id` int NOT NULL AUTO_INCREMENT, + `document_id` int NOT NULL, + `tag_id` int DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_document_tags_document_id` (`document_id`), + KEY `fk_document_tags_tag_id` (`tag_id`), + CONSTRAINT `fk_document_tags_document_id` FOREIGN KEY (`document_id`) REFERENCES `document` (`id`), + CONSTRAINT `fk_document_tags_tag_id` FOREIGN KEY (`tag_id`) REFERENCES `tag` (`id`) +); + +DROP TABLE IF EXISTS `flashcard_set`; +CREATE TABLE `flashcard_set` +( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `title` varchar(255) NOT NULL, + `origin_type` enum ('AI','USER','DEFAULT') NOT NULL, + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_flashcard_sets_user_id` (`user_id`), + CONSTRAINT `fk_flashcard_sets_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) +); + +DROP TABLE IF EXISTS `flashcard`; +CREATE TABLE `flashcard` +( + `id` int NOT NULL AUTO_INCREMENT, + `flashcard_set_id` int NOT NULL, + `front_content` text NOT NULL, + `back_content` text NOT NULL, + `source_document_id` int DEFAULT NULL, + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_flashcards_flashcard_set_id` (`flashcard_set_id`), + KEY `fk_flashcards_source_document_id` (`source_document_id`), + CONSTRAINT `fk_flashcards_flashcard_set_id` FOREIGN KEY (`flashcard_set_id`) REFERENCES `flashcard_set` (`id`), + CONSTRAINT `fk_flashcards_source_document_id` FOREIGN KEY (`source_document_id`) REFERENCES `document` (`id`) +); + +DROP TABLE IF EXISTS `quiz_set`; +CREATE TABLE `quiz_set` +( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `title` varchar(255) NOT NULL, + `origin_type` enum ('AI','USER','DEFAULT') NOT NULL, + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_quiz_set_user_id_idx` (`user_id`), + CONSTRAINT `fk_quiz_set_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) +); + +DROP TABLE IF EXISTS `quiz`; +CREATE TABLE `quiz` +( + `id` int NOT NULL AUTO_INCREMENT, + `quiz_set_id` int NOT NULL, + `source_document_id` int DEFAULT NULL, + `title` varchar(255) NOT NULL, + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_quiz_set_id_idx` (`quiz_set_id`), + KEY `fk_quiz_src_document_id` (`source_document_id`), + CONSTRAINT `fk_quiz_set_id` FOREIGN KEY (`quiz_set_id`) REFERENCES `quiz_set` (`id`), + CONSTRAINT `fk_quiz_src_document_id` FOREIGN KEY (`source_document_id`) REFERENCES `document` (`id`) +); + +DROP TABLE IF EXISTS `question`; +CREATE TABLE `question` +( + `id` int NOT NULL AUTO_INCREMENT, + `quiz_id` int NOT NULL, + `question_text` varchar(255) NOT NULL, + `option_a` varchar(255) NOT NULL, + `option_b` varchar(255) NOT NULL, + `option_c` varchar(255) NOT NULL, + `option_d` varchar(255) NOT NULL, + `correct_answer` char(1) NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_questions_quiz_id` (`quiz_id`), + CONSTRAINT `fk_questions_quiz_id` FOREIGN KEY (`quiz_id`) REFERENCES `quiz` (`id`) +); + + + +DROP TABLE IF EXISTS `attempt`; +CREATE TABLE `attempt` +( + `id` int NOT NULL AUTO_INCREMENT, + `quiz_id` int NOT NULL, + `attempt_at` datetime NOT NULL, + `total_question` int NOT NULL, + `score` int DEFAULT '0', + PRIMARY KEY (`id`), + KEY `fk_attempts_quiz_id_idx` (`quiz_id`), + CONSTRAINT `fk_attempts_quiz_id` FOREIGN KEY (`quiz_id`) REFERENCES `quiz` (`id`) +); + +DROP TABLE IF EXISTS `attempt_detail`; +CREATE TABLE `attempt_detail` +( + `id` int NOT NULL AUTO_INCREMENT, + `attempt_id` int NOT NULL, + `question_id` int NOT NULL, + `user_answer` char(1) DEFAULT NULL, + `is_correct` tinyint(1) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_attempt_details_attempt_id` (`attempt_id`), + KEY `fk_attempt_details_question_id_idx` (`question_id`), + CONSTRAINT `fk_attempt_details_attempt_id` FOREIGN KEY (`attempt_id`) REFERENCES `attempt` (`id`), + CONSTRAINT `fk_attempt_details_question_id` FOREIGN KEY (`question_id`) REFERENCES `question` (`id`) +); \ No newline at end of file From 32b6ac87848e8cc213159be71ad8de68a09e6a66 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 9 Dec 2025 23:53:07 +1100 Subject: [PATCH 53/63] test(note): replace Testcontainers and Containers annotations with BeforeAll --- .../com/be08/smart_notes/controller/Base.java | 25 ++++++++----- src/test/resources/application.properties | 24 ++----------- src/test/resources/init-db.sql | 35 ++++++------------- 3 files changed, 30 insertions(+), 54 deletions(-) diff --git a/src/test/java/com/be08/smart_notes/controller/Base.java b/src/test/java/com/be08/smart_notes/controller/Base.java index 4990117..94b7794 100644 --- a/src/test/java/com/be08/smart_notes/controller/Base.java +++ b/src/test/java/com/be08/smart_notes/controller/Base.java @@ -1,25 +1,23 @@ package com.be08.smart_notes.controller; +import lombok.Getter; +import org.junit.jupiter.api.BeforeAll; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; -@Testcontainers +@Getter public class Base { - @Container - public static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.0.36") + private static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.0.36") .withDatabaseName("testdb") .withUsername("test") .withPassword("test") .withInitScript("init-db.sql") .withReuse(true); - @Container - public static final GenericContainer redisContainer = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) + private static final GenericContainer redisContainer = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) .withExposedPorts(6379) .withReuse(true); @@ -32,12 +30,21 @@ public static void properties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver"); // JPA configuration - registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); - registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.MySQLDialect"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "update"); registry.add("spring.jpa.show-sql", () -> "false"); // Redis configuration registry.add("spring.data.redis.host", redisContainer::getHost); registry.add("spring.data.redis.port", redisContainer::getFirstMappedPort); } + + @BeforeAll + static void initContainers() { + if (!mysqlContainer.isRunning()) { + mysqlContainer.start(); + } + if (!redisContainer.isRunning()) { + redisContainer.start(); + } + } } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 3acab71..c2caac1 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,29 +1,11 @@ -# Database Configuration for Development (Local MySQL) -# H2 Database Configuration -spring.datasource.url=jdbc:h2:mem:test;MODE=MySQL; -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=testing -spring.datasource.password=password - -spring.jpa.hibernate.ddl-auto=create-drop -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect -spring.jpa.show-sql=false - -#spring.jpa.defer-datasource-initialization=true -#spring.sql.init.mode=never - -# JPA Settings for Development -#spring.jpa.properties.hibernate.format_sql=true -#spring.jpa.hibernate.ddl-auto=update - -# REDIS Configuration for Development (Local Redis - Docker) +## REDIS Configuration spring.data.redis.host=${REDIS_HOST:localhost} spring.data.redis.port=${REDIS_PORT:6379} -# Frontend URL for Local Development +## Frontend URL for Local Development app.frontend.url=http://localhost:3000 -# AI Inference Configuration +## AI Inference Configuration ai.api.url=${API_URL} ai.api.token=${API_TOKEN} ai.api.model=${MODEL} diff --git a/src/test/resources/init-db.sql b/src/test/resources/init-db.sql index 66b81d1..4bdab8c 100644 --- a/src/test/resources/init-db.sql +++ b/src/test/resources/init-db.sql @@ -1,8 +1,7 @@ CREATE DATABASE IF NOT EXISTS `testdb`; USE `testdb`; -DROP TABLE IF EXISTS `user`; -CREATE TABLE `user` +CREATE TABLE IF NOT EXISTS `user` ( `id` int NOT NULL AUTO_INCREMENT, `email` varchar(255) NOT NULL, @@ -15,8 +14,7 @@ CREATE TABLE `user` UNIQUE KEY `email` (`email`) ); -DROP TABLE IF EXISTS `tag`; -CREATE TABLE `tag` +CREATE TABLE IF NOT EXISTS `tag` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, @@ -26,8 +24,7 @@ CREATE TABLE `tag` CONSTRAINT `fk_tags_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ); -DROP TABLE IF EXISTS `document`; -CREATE TABLE `document` +CREATE TABLE IF NOT EXISTS `document` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, @@ -46,8 +43,7 @@ CREATE TABLE `document` (`file_size` is not null)))) ); -DROP TABLE IF EXISTS `document_tag`; -CREATE TABLE `document_tag` +CREATE TABLE IF NOT EXISTS `document_tag` ( `id` int NOT NULL AUTO_INCREMENT, `document_id` int NOT NULL, @@ -59,8 +55,7 @@ CREATE TABLE `document_tag` CONSTRAINT `fk_document_tags_tag_id` FOREIGN KEY (`tag_id`) REFERENCES `tag` (`id`) ); -DROP TABLE IF EXISTS `flashcard_set`; -CREATE TABLE `flashcard_set` +CREATE TABLE IF NOT EXISTS `flashcard_set` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, @@ -73,8 +68,7 @@ CREATE TABLE `flashcard_set` CONSTRAINT `fk_flashcard_sets_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ); -DROP TABLE IF EXISTS `flashcard`; -CREATE TABLE `flashcard` +CREATE TABLE IF NOT EXISTS `flashcard` ( `id` int NOT NULL AUTO_INCREMENT, `flashcard_set_id` int NOT NULL, @@ -90,8 +84,7 @@ CREATE TABLE `flashcard` CONSTRAINT `fk_flashcards_source_document_id` FOREIGN KEY (`source_document_id`) REFERENCES `document` (`id`) ); -DROP TABLE IF EXISTS `quiz_set`; -CREATE TABLE `quiz_set` +CREATE TABLE IF NOT EXISTS `quiz_set` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, @@ -104,8 +97,7 @@ CREATE TABLE `quiz_set` CONSTRAINT `fk_quiz_set_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ); -DROP TABLE IF EXISTS `quiz`; -CREATE TABLE `quiz` +CREATE TABLE IF NOT EXISTS `quiz` ( `id` int NOT NULL AUTO_INCREMENT, `quiz_set_id` int NOT NULL, @@ -120,8 +112,7 @@ CREATE TABLE `quiz` CONSTRAINT `fk_quiz_src_document_id` FOREIGN KEY (`source_document_id`) REFERENCES `document` (`id`) ); -DROP TABLE IF EXISTS `question`; -CREATE TABLE `question` +CREATE TABLE IF NOT EXISTS `question` ( `id` int NOT NULL AUTO_INCREMENT, `quiz_id` int NOT NULL, @@ -136,10 +127,7 @@ CREATE TABLE `question` CONSTRAINT `fk_questions_quiz_id` FOREIGN KEY (`quiz_id`) REFERENCES `quiz` (`id`) ); - - -DROP TABLE IF EXISTS `attempt`; -CREATE TABLE `attempt` +CREATE TABLE IF NOT EXISTS `attempt` ( `id` int NOT NULL AUTO_INCREMENT, `quiz_id` int NOT NULL, @@ -151,8 +139,7 @@ CREATE TABLE `attempt` CONSTRAINT `fk_attempts_quiz_id` FOREIGN KEY (`quiz_id`) REFERENCES `quiz` (`id`) ); -DROP TABLE IF EXISTS `attempt_detail`; -CREATE TABLE `attempt_detail` +CREATE TABLE IF NOT EXISTS `attempt_detail` ( `id` int NOT NULL AUTO_INCREMENT, `attempt_id` int NOT NULL, From 12f91ee6eb8cb4a2ad3e16af2df24acb5a86c3e9 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 10 Dec 2025 00:27:04 +1100 Subject: [PATCH 54/63] test(note): reorganise test structure --- pom.xml | 12 ++++++++---- .../controller/BaseIntegration.java} | 4 ++-- .../controller/NoteControllerIntegrationTest.java | 4 ++-- .../repository/DocumentRepositoryTest.java | 3 ++- .../{ => unit}/service/NoteServiceTest.java | 4 +++- 5 files changed, 17 insertions(+), 10 deletions(-) rename src/test/java/com/be08/smart_notes/{controller/Base.java => integration/controller/BaseIntegration.java} (95%) rename src/test/java/com/be08/smart_notes/{ => integration}/controller/NoteControllerIntegrationTest.java (99%) rename src/test/java/com/be08/smart_notes/{ => integration}/repository/DocumentRepositoryTest.java (99%) rename src/test/java/com/be08/smart_notes/{ => unit}/service/NoteServiceTest.java (99%) diff --git a/pom.xml b/pom.xml index 2518a42..bbf615a 100644 --- a/pom.xml +++ b/pom.xml @@ -265,10 +265,10 @@ ${skip.unit.tests} - **/*Test.java + **/unit/**/*Test.java - **/*IntegrationTest.java + **/integration/** plain @@ -298,8 +298,11 @@ ${skip.integration.tests} - **/*IntegrationTest.java + **/integration/**/*Test.java + + **/unit/** + plain @@ -351,7 +354,8 @@ unit-test - test + false + true diff --git a/src/test/java/com/be08/smart_notes/controller/Base.java b/src/test/java/com/be08/smart_notes/integration/controller/BaseIntegration.java similarity index 95% rename from src/test/java/com/be08/smart_notes/controller/Base.java rename to src/test/java/com/be08/smart_notes/integration/controller/BaseIntegration.java index 94b7794..6f3eea2 100644 --- a/src/test/java/com/be08/smart_notes/controller/Base.java +++ b/src/test/java/com/be08/smart_notes/integration/controller/BaseIntegration.java @@ -1,4 +1,4 @@ -package com.be08.smart_notes.controller; +package com.be08.smart_notes.integration.controller; import lombok.Getter; import org.junit.jupiter.api.BeforeAll; @@ -9,7 +9,7 @@ import org.testcontainers.utility.DockerImageName; @Getter -public class Base { +public class BaseIntegration { private static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.0.36") .withDatabaseName("testdb") .withUsername("test") diff --git a/src/test/java/com/be08/smart_notes/controller/NoteControllerIntegrationTest.java b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java similarity index 99% rename from src/test/java/com/be08/smart_notes/controller/NoteControllerIntegrationTest.java rename to src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java index 0949d78..3c703cd 100644 --- a/src/test/java/com/be08/smart_notes/controller/NoteControllerIntegrationTest.java +++ b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java @@ -1,4 +1,4 @@ -package com.be08.smart_notes.controller; +package com.be08.smart_notes.integration.controller; import com.be08.smart_notes.dto.request.NoteUpsertRequest; import com.be08.smart_notes.helper.DocumentDataBuilder; @@ -35,7 +35,7 @@ @Transactional @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) @DisplayName("Note Controller Integration Test") -public class NoteControllerIntegrationTest extends Base { +public class NoteControllerIntegrationTest extends BaseIntegration { @Autowired MockMvc mockMvc; @Autowired diff --git a/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java similarity index 99% rename from src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java rename to src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java index 4bde439..b44220f 100644 --- a/src/test/java/com/be08/smart_notes/repository/DocumentRepositoryTest.java +++ b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java @@ -1,7 +1,8 @@ -package com.be08.smart_notes.repository; +package com.be08.smart_notes.integration.repository; import com.be08.smart_notes.helper.DocumentDataBuilder; import com.be08.smart_notes.model.Document; +import com.be08.smart_notes.repository.DocumentRepository; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; diff --git a/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java similarity index 99% rename from src/test/java/com/be08/smart_notes/service/NoteServiceTest.java rename to src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java index 58ef9d5..c5de2ee 100644 --- a/src/test/java/com/be08/smart_notes/service/NoteServiceTest.java +++ b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java @@ -1,4 +1,4 @@ -package com.be08.smart_notes.service; +package com.be08.smart_notes.unit.service; import com.be08.smart_notes.helper.DocumentDataBuilder; import com.be08.smart_notes.dto.request.NoteUpsertRequest; @@ -10,6 +10,8 @@ import com.be08.smart_notes.model.Document; import com.be08.smart_notes.model.User; import com.be08.smart_notes.repository.DocumentRepository; +import com.be08.smart_notes.service.AuthorizationService; +import com.be08.smart_notes.service.NoteService; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; From 70ae97e8beafd201362e0635de288cdaa72f4996 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 10 Dec 2025 12:03:28 +1100 Subject: [PATCH 55/63] test(note): add comments and documentation for existing tests --- README.md | 14 +++-- .../helper/DocumentDataBuilder.java | 21 ++++--- .../smart_notes/helper/UserDataBuilder.java | 30 +++++++++ .../NoteControllerIntegrationTest.java | 14 ++--- .../repository/DocumentRepositoryTest.java | 62 +++++++++---------- .../unit/service/NoteServiceTest.java | 43 ++++++------- src/test/resources/application.properties | 18 +++--- 7 files changed, 121 insertions(+), 81 deletions(-) create mode 100644 src/test/java/com/be08/smart_notes/helper/UserDataBuilder.java diff --git a/README.md b/README.md index 0b3cdfb..eec3ed7 100644 --- a/README.md +++ b/README.md @@ -50,18 +50,20 @@ DB_PASSWORD= ``` ## How to test -For Windows, run one of below commands +For Windows, run one of below commands. -```bash -# Run all tests -./mvnw.cmd test +Replace `test` with `clean test` to rebuild if there are changes in test code, similar with `verify`. +```bash # Run specific test ./mvnw.cmd test -Dtest=NoteServiceTest -# Run unit test only +# Run all unit tests ./mvnw.cmd test -Punit-test -# Run integration test only +# Run all integration tests ./mvnw.cmd verify -Pintegration-test + +# Run all tests (unit and integration) +./mvnw.cmd verify -Ptest ``` \ No newline at end of file diff --git a/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java b/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java index a37b44c..fec700e 100644 --- a/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java +++ b/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java @@ -3,16 +3,17 @@ import com.be08.smart_notes.dto.response.NoteResponse; import com.be08.smart_notes.enums.DocumentType; import com.be08.smart_notes.model.Document; -import com.be08.smart_notes.model.User; import java.time.LocalDateTime; public class DocumentDataBuilder { - public static User.UserBuilder createUser(int userId) { - return User.builder().id(userId); - } - - public static Document.DocumentBuilder createSampleNote(int userId) { + /** + * Create a sample mock document (note) with given information. + * This method should not be used with real database interaction + * @param userId mock user ID + * @return Document.DocumentBuilder + */ + public static Document.DocumentBuilder createMockNote(int userId) { return Document.builder() .userId(userId).type(DocumentType.NOTE) .title("Sample Note").content("Sample Note Content") @@ -20,7 +21,13 @@ public static Document.DocumentBuilder createSampleNote(int userId) { .updatedAt(LocalDateTime.now()); } - public static NoteResponse.NoteResponseBuilder createNoteResponse(Document note) { + /** + * Create a sample mock user object with given information. + * This method should not be used with real database interaction + * @param note a note object to be parsed to note response + * @return NoteResponse.NoteResponseBuilder + */ + public static NoteResponse.NoteResponseBuilder createMockNoteResponse(Document note) { return NoteResponse.builder().id(note.getId()) .title(note.getTitle()).content(note.getContent()) .createdAt(note.getCreatedAt()) diff --git a/src/test/java/com/be08/smart_notes/helper/UserDataBuilder.java b/src/test/java/com/be08/smart_notes/helper/UserDataBuilder.java new file mode 100644 index 0000000..98bbff3 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/helper/UserDataBuilder.java @@ -0,0 +1,30 @@ +package com.be08.smart_notes.helper; + +import com.be08.smart_notes.model.User; + +import java.time.LocalDateTime; + +public class UserDataBuilder { + /** + * Create a sample mock user object with given information. + * This method should not be used with real database interaction + * @param userId mock user ID + * @return User.UserBuilder + */ + public static User.UserBuilder createMockUser(int userId) { + return User.builder().id(userId); + } + + /** + * Create a user object with all required fields to be saved in database. + * Use the returned builder object to override any custom value (if needed), + * then chain .build() to build the object + * @param email unique email for new user + * @return User.UserBuilder + */ + public static User.UserBuilder createUser(String email) { + return User.builder() + .name("Test User").email(email) + .password("123456").createdAt(LocalDateTime.now()); + } +} diff --git a/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java index 3c703cd..b9883f2 100644 --- a/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java +++ b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java @@ -2,6 +2,7 @@ import com.be08.smart_notes.dto.request.NoteUpsertRequest; import com.be08.smart_notes.helper.DocumentDataBuilder; +import com.be08.smart_notes.helper.UserDataBuilder; import com.be08.smart_notes.model.Document; import com.be08.smart_notes.model.User; import com.be08.smart_notes.repository.DocumentRepository; @@ -21,7 +22,6 @@ import org.springframework.transaction.annotation.Transactional; import java.security.KeyPair; -import java.time.LocalDateTime; import static com.be08.smart_notes.helper.JwtBuilder.jwtWithUserId; import static org.hamcrest.Matchers.hasSize; @@ -33,7 +33,7 @@ @SpringBootTest @AutoConfigureMockMvc @Transactional -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@DisplayNameGeneration(DisplayNameGenerator.Standard.class) @DisplayName("Note Controller Integration Test") public class NoteControllerIntegrationTest extends BaseIntegration { @Autowired @@ -52,7 +52,7 @@ public class NoteControllerIntegrationTest extends BaseIntegration { @MockitoBean KeyPair signingKeyPair; - static final String BASE_URI = "/api/documents/notes"; + final String BASE_URI = "/api/documents/notes"; int TEST_USER_ID; int OTHER_USER_ID; Document existingNote; @@ -63,14 +63,14 @@ void setUpEach() { userRepository.deleteAll(); documentRepository.deleteAll(); - User testUser1 = userRepository.save(User.builder().name("Test User").email("example-user@gmail.com").password("123456").createdAt(LocalDateTime.now()).build()); - User testUser2 = userRepository.save(User.builder().name("Test User").email("another-user@gmail.com").password("123456").createdAt(LocalDateTime.now()).build()); + User testUser1 = userRepository.save(UserDataBuilder.createUser("example-user@gmail.com").build()); + User testUser2 = userRepository.save(UserDataBuilder.createUser("another-user@gmail.com").build()); TEST_USER_ID = testUser1.getId(); OTHER_USER_ID = testUser2.getId(); - existingNote = documentRepository.save(DocumentDataBuilder.createSampleNote(TEST_USER_ID).build()); - anotherNote = documentRepository.save(DocumentDataBuilder.createSampleNote(OTHER_USER_ID).build()); + existingNote = documentRepository.save(DocumentDataBuilder.createMockNote(TEST_USER_ID).build()); + anotherNote = documentRepository.save(DocumentDataBuilder.createMockNote(OTHER_USER_ID).build()); } @Nested diff --git a/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java index b44220f..d2fb0be 100644 --- a/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java +++ b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java @@ -15,7 +15,7 @@ import static org.junit.jupiter.api.Assertions.*; @DataJpaTest -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@DisplayNameGeneration(DisplayNameGenerator.Standard.class) @DisplayName("Document Repository Test") public class DocumentRepositoryTest { @Autowired @@ -33,10 +33,10 @@ public class DocumentRepositoryTest { void setUp() { documentRepository.deleteAll(); - document1 = documentRepository.save(DocumentDataBuilder.createSampleNote(FIRST_USER_ID).build()); - document2 = documentRepository.save(DocumentDataBuilder.createSampleNote(FIRST_USER_ID).build()); - document3 = documentRepository.save(DocumentDataBuilder.createSampleNote(FIRST_USER_ID).build()); - documentAnotherUser = documentRepository.save(DocumentDataBuilder.createSampleNote(SECOND_USER_ID).build()); + document1 = documentRepository.save(DocumentDataBuilder.createMockNote(FIRST_USER_ID).build()); + document2 = documentRepository.save(DocumentDataBuilder.createMockNote(FIRST_USER_ID).build()); + document3 = documentRepository.save(DocumentDataBuilder.createMockNote(FIRST_USER_ID).build()); + documentAnotherUser = documentRepository.save(DocumentDataBuilder.createMockNote(SECOND_USER_ID).build()); } // --- findAllByUserId --- // @@ -44,7 +44,7 @@ void setUp() { @DisplayName("findAllByUserId(): List") class FindAllByUserIdTest { @Test - void findAllByUserId_whenUserHasDocuments_shouldReturnAllDocuments() { + void shouldReturnAllDocumentsWhenUserHasDocuments() { // Act List result = documentRepository.findAllByUserId(FIRST_USER_ID); @@ -55,7 +55,7 @@ void findAllByUserId_whenUserHasDocuments_shouldReturnAllDocuments() { } @Test - void findAllByUserId_whenUserHasNoDocuments_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenUserHasNoDocuments() { // Arrange int nonExistentUserId = 999; @@ -68,7 +68,7 @@ void findAllByUserId_whenUserHasNoDocuments_shouldReturnEmptyList() { } @Test - void findAllByUserId_shouldNotReturnOtherUsersDocuments() { + void shouldNotReturnOtherUsersDocuments() { // Act List result = documentRepository.findAllByUserId(FIRST_USER_ID); @@ -83,7 +83,7 @@ void findAllByUserId_shouldNotReturnOtherUsersDocuments() { @DisplayName("findByIdAndUserId(): Optional") class FindByIdAndUserIdTest { @Test - void findByIdAndUserId_whenDocumentExists_shouldReturnDocument() { + void shouldReturnDocumentWhenDocumentExists() { // Act Optional result = documentRepository.findByIdAndUserId(document1.getId(), FIRST_USER_ID); @@ -94,7 +94,7 @@ void findByIdAndUserId_whenDocumentExists_shouldReturnDocument() { } @Test - void findByIdAndUserId_whenDocumentNotExists_shouldReturnEmpty() { + void shouldReturnEmptyWhenDocumentNotExists() { // Arrange int nonExistentId = 999; @@ -106,7 +106,7 @@ void findByIdAndUserId_whenDocumentNotExists_shouldReturnEmpty() { } @Test - void findByIdAndUserId_whenDocumentExistsButWrongUser_shouldReturnEmpty() { + void shouldReturnEmptyWhenDocumentExistsButWrongUser() { // Act Optional result = documentRepository.findByIdAndUserId(document1.getId(), SECOND_USER_ID); @@ -115,7 +115,7 @@ void findByIdAndUserId_whenDocumentExistsButWrongUser_shouldReturnEmpty() { } @Test - void findByIdAndUserId_whenUserNotExists_shouldReturnEmpty() { + void shouldReturnEmptyWhenUserNotExists() { // Arrange int nonExistentUserId = 999; @@ -132,7 +132,7 @@ void findByIdAndUserId_whenUserNotExists_shouldReturnEmpty() { @DisplayName("findFirstByTitleAndUserId(): Optional") class FindFirstByTitleAndUserIdTest { @Test - void findFirstByTitleAndUserId_whenDocumentExists_shouldReturnDocument() { + void shouldReturnDocumentWhenDocumentExists() { // Arrange String existingTitle = document1.getTitle(); @@ -146,7 +146,7 @@ void findFirstByTitleAndUserId_whenDocumentExists_shouldReturnDocument() { } @Test - void findFirstByTitleAndUserId_whenTitleNotExists_shouldReturnEmpty() { + void shouldReturnEmptyWhenTitleNotExists() { // Arrange String nonExistentTitle = "Non Existent Title"; @@ -158,14 +158,14 @@ void findFirstByTitleAndUserId_whenTitleNotExists_shouldReturnEmpty() { } @Test - void findFirstByTitleAndUserId_whenMultipleUsersHaveSameTitle_shouldReturnOnlyRequestedUser() { + void shouldReturnOnlyRequestedUserWhenMultipleUsersHaveSameTitle() { // Arrange String sharedTitle = "Shared Title"; Document doc1 = documentRepository.save( - DocumentDataBuilder.createSampleNote(FIRST_USER_ID).title(sharedTitle).build() + DocumentDataBuilder.createMockNote(FIRST_USER_ID).title(sharedTitle).build() ); Document doc2 = documentRepository.save( - DocumentDataBuilder.createSampleNote(SECOND_USER_ID).title(sharedTitle).build() + DocumentDataBuilder.createMockNote(SECOND_USER_ID).title(sharedTitle).build() ); // Act @@ -183,7 +183,7 @@ void findFirstByTitleAndUserId_whenMultipleUsersHaveSameTitle_shouldReturnOnlyRe @DisplayName("findAllByIdIn(): List") class FindAllByIdInTest { @Test - void findAllByIdIn_whenAllIdsExist_shouldReturnAllDocuments() { + void shouldReturnAllDocumentsWhenAllIdsExist() { // Arrange List ids = Arrays.asList(document1.getId(), document2.getId()); @@ -198,7 +198,7 @@ void findAllByIdIn_whenAllIdsExist_shouldReturnAllDocuments() { } @Test - void findAllByIdIn_whenSomeIdsNotExist_shouldReturnOnlyExistingDocuments() { + void shouldReturnOnlyExistingDocumentsWhenSomeIdsNotExist() { // Arrange List ids = Arrays.asList(document1.getId(), 999, 998); @@ -212,7 +212,7 @@ void findAllByIdIn_whenSomeIdsNotExist_shouldReturnOnlyExistingDocuments() { } @Test - void findAllByIdIn_whenNoIdsExist_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenNoIdsExist() { // Arrange List ids = Arrays.asList(999, 998, 997); @@ -225,7 +225,7 @@ void findAllByIdIn_whenNoIdsExist_shouldReturnEmptyList() { } @Test - void findAllByIdIn_withEmptyIdList_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenGivenEmptyIdList() { // Arrange List emptyIds = Collections.emptyList(); @@ -238,7 +238,7 @@ void findAllByIdIn_withEmptyIdList_shouldReturnEmptyList() { } @Test - void findAllByIdIn_shouldReturnDocumentsFromMultipleUsers() { + void shouldReturnDocumentsFromMultipleUsers() { // Arrange List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); @@ -253,7 +253,7 @@ void findAllByIdIn_shouldReturnDocumentsFromMultipleUsers() { } @Test - void findAllByIdIn_withSingleId_shouldReturnSingleDocument() { + void shouldReturnSingleDocumentWhenGivenSingleId() { // Arrange List ids = Collections.singletonList(document1.getId()); @@ -272,7 +272,7 @@ void findAllByIdIn_withSingleId_shouldReturnSingleDocument() { @DisplayName("findAllByUserAndIdIn(): List") class FindAllByUserIdAndIdIn { @Test - void findAllByUserIdAndIdIn_whenAllIdsExistForUser_shouldReturnAllDocuments() { + void shouldReturnAllDocumentsWhenAllIdsExistForUser() { // Arrange List ids = Arrays.asList(document1.getId(), document2.getId()); @@ -286,7 +286,7 @@ void findAllByUserIdAndIdIn_whenAllIdsExistForUser_shouldReturnAllDocuments() { } @Test - void findAllByUserIdAndIdIn_whenSomeIdsNotExistForUser_shouldReturnOnlyExisting() { + void shouldReturnOnlyExistingWhenSomeIdsNotExistForUser() { // Arrange List ids = Arrays.asList(document1.getId(), 999); @@ -300,7 +300,7 @@ void findAllByUserIdAndIdIn_whenSomeIdsNotExistForUser_shouldReturnOnlyExisting( } @Test - void findAllByUserIdAndIdIn_whenNoIdsExistForUser_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenNoIdsExistForUser() { // Arrange List ids = Arrays.asList(999, 998); @@ -313,7 +313,7 @@ void findAllByUserIdAndIdIn_whenNoIdsExistForUser_shouldReturnEmptyList() { } @Test - void findAllByUserIdAndIdIn_withEmptyIdList_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenGivenEmptyIdList() { // Arrange List emptyIds = Collections.emptyList(); @@ -326,7 +326,7 @@ void findAllByUserIdAndIdIn_withEmptyIdList_shouldReturnEmptyList() { } @Test - void findAllByUserIdAndIdIn_whenDocumentsBelongToAnotherUser_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenDocumentsBelongToAnotherUser() { // Arrange - trying to get user 1's documents with user 2's ID List ids = Arrays.asList(document1.getId(), document2.getId()); @@ -339,7 +339,7 @@ void findAllByUserIdAndIdIn_whenDocumentsBelongToAnotherUser_shouldReturnEmptyLi } @Test - void findAllByUserIdAndIdIn_withMixedUserDocuments_shouldReturnOnlyRequestedUserDocuments() { + void shouldReturnOnlyRequestedUserDocumentsWhenGivenMixedUserDocuments() { // Arrange - mixing documents from different users List ids = Arrays.asList(document1.getId(), documentAnotherUser.getId()); @@ -354,7 +354,7 @@ void findAllByUserIdAndIdIn_withMixedUserDocuments_shouldReturnOnlyRequestedUser } @Test - void findAllByUserIdAndIdIn_withSingleId_shouldReturnSingleDocument() { + void shouldReturnSingleDocumentWhenGivenSingleId() { // Arrange List ids = Collections.singletonList(document1.getId()); @@ -368,7 +368,7 @@ void findAllByUserIdAndIdIn_withSingleId_shouldReturnSingleDocument() { } @Test - void findAllByUserIdAndIdIn_whenUserNotExists_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenUserNotExists() { // Arrange int nonExistentUserId = 999; List ids = Arrays.asList(document1.getId(), document2.getId()); diff --git a/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java index c5de2ee..62d837c 100644 --- a/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java +++ b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java @@ -6,6 +6,7 @@ import com.be08.smart_notes.enums.DocumentType; import com.be08.smart_notes.exception.AppException; import com.be08.smart_notes.exception.ErrorCode; +import com.be08.smart_notes.helper.UserDataBuilder; import com.be08.smart_notes.mapper.DocumentMapper; import com.be08.smart_notes.model.Document; import com.be08.smart_notes.model.User; @@ -29,7 +30,7 @@ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@DisplayNameGeneration(DisplayNameGenerator.Standard.class) @DisplayName("Note Service Test") public class NoteServiceTest { @Mock @@ -51,11 +52,11 @@ public class NoteServiceTest { @BeforeEach void setUp() { int userId = 100; - existingUser = DocumentDataBuilder.createUser(userId).build(); - existingNote = DocumentDataBuilder.createSampleNote(userId).id(1).userId(100).build(); - anotherExistingNote = DocumentDataBuilder.createSampleNote(userId).id(2).userId(100).build(); - existingNoteResponse = DocumentDataBuilder.createNoteResponse(existingNote).build(); - anotherExistingNoteResponse = DocumentDataBuilder.createNoteResponse(anotherExistingNote).build(); + existingUser = UserDataBuilder.createMockUser(userId).build(); + existingNote = DocumentDataBuilder.createMockNote(userId).id(1).userId(100).build(); + anotherExistingNote = DocumentDataBuilder.createMockNote(userId).id(2).userId(100).build(); + existingNoteResponse = DocumentDataBuilder.createMockNoteResponse(existingNote).build(); + anotherExistingNoteResponse = DocumentDataBuilder.createMockNoteResponse(anotherExistingNote).build(); when(authorizationService.getCurrentUserId()).thenReturn(existingUser.getId()); } @@ -65,7 +66,7 @@ void setUp() { @DisplayName("getNote(): NoteResponse") class GetNoteTest { @Test - void getNote_withNonExistentId_shouldThrowException() { + void shouldThrowExceptionWhenGivenNonExistentId() { // Arrange int nonExistentId = 999; int userId = existingUser.getId(); @@ -83,7 +84,7 @@ void getNote_withNonExistentId_shouldThrowException() { } @Test - void getNote_whenNoteExists_shouldReturnNoteResponse() { + void shouldReturnNoteResponseWhenNoteExists() { // Arrange int noteId = existingNote.getId(); int userId = existingUser.getId(); @@ -108,7 +109,7 @@ void getNote_whenNoteExists_shouldReturnNoteResponse() { @DisplayName("getAllNotes(): List") class GetAllNotesTest { @Test - void getAllNotes_whenEmpty_shouldReturnEmptyNoteResponseList() { + void shouldReturnEmptyNoteResponseListWhenListIsEmpty() { // Arrange int userId = existingUser.getId(); List emptyList = Collections.emptyList(); @@ -129,7 +130,7 @@ void getAllNotes_whenEmpty_shouldReturnEmptyNoteResponseList() { } @Test - void getAllNotes_whenNotEmpty_shouldReturnNoteResponseList() { + void shouldReturnNoteResponseListWhenListIsNotEmpty() { // Arrange int userId = existingUser.getId(); List noteList = List.of(existingNote, anotherExistingNote); @@ -152,7 +153,7 @@ void getAllNotes_whenNotEmpty_shouldReturnNoteResponseList() { } @Test - void getAllNotes_withSingleNote_shouldReturnSingleNoteResponseList() { + void shouldReturnSingleNoteResponseListWhenGivenSingleNote() { // Arrange int userId = existingUser.getId(); List noteList = List.of(existingNote); @@ -179,7 +180,7 @@ void getAllNotes_withSingleNote_shouldReturnSingleNoteResponseList() { @DisplayName("createNote(): NoteResponse") class CreateNoteTest { @Test - void createNote_withValidInput_shouldCreateSuccessfully() { + void shouldCreateSuccessfullyWhenGivenValidInput() { // Arrange int newId = 2; String newTitle = "Test New Note"; @@ -212,7 +213,7 @@ void createNote_withValidInput_shouldCreateSuccessfully() { @DisplayName("updateNote(): NoteResponse") class UpdateNoteTest { @Test - void updateNote_whenNoteExists_shouldUpdateSuccessfully() { + void shouldUpdateSuccessfullyWhenNoteExists() { // Arrange int noteId = existingNote.getId(); int userId = existingUser.getId(); @@ -243,7 +244,7 @@ void updateNote_whenNoteExists_shouldUpdateSuccessfully() { } @Test - void updateNote_whenNoteNotFound_shouldThrowException() { + void shouldThrowExceptionWhenNoteNotFound() { // Arrange int noteId = 999; int userId = existingUser.getId(); @@ -272,7 +273,7 @@ void updateNote_whenNoteNotFound_shouldThrowException() { @DisplayName("deleteNote(): void") class DeleteNoteTest { @Test - void deleteNote_whenNoteExists_shouldDeleteSuccessfully() { + void shouldDeleteSuccessfullyWhenNoteExists() { // Arrange int noteId = existingNote.getId(); int userId = existingUser.getId(); @@ -289,7 +290,7 @@ void deleteNote_whenNoteExists_shouldDeleteSuccessfully() { } @Test - void deleteNote_whenNoteNotFound_shouldThrowException() { + void shouldThrowExceptionWhenNoteNotFound() { // Arrange int noteId = 999; int userId = existingUser.getId(); @@ -313,7 +314,7 @@ void deleteNote_whenNoteNotFound_shouldThrowException() { @DisplayName("getAllNotesByUserIdAndIds(): List") class GetAllNotesByUserIdAndIdsTest { @Test - void getAllNotesByUserIdAndIds_whenNotesExist_shouldReturnMatchingNotes() { + void shouldReturnMatchingNotesWhenNotesExist() { // Arrange int userId = existingUser.getId(); List noteIds = List.of(existingNote.getId(), anotherExistingNote.getId()); @@ -333,7 +334,7 @@ void getAllNotesByUserIdAndIds_whenNotesExist_shouldReturnMatchingNotes() { } @Test - void getAllNotesByUserIdAndIds_whenNoMatches_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenNoMatches() { // Arrange int userId = existingUser.getId(); List noteIds = List.of(998, 999); @@ -351,7 +352,7 @@ void getAllNotesByUserIdAndIds_whenNoMatches_shouldReturnEmptyList() { } @Test - void getAllNotesByUserIdAndIds_withEmptyIdList_shouldReturnEmptyList() { + void shouldReturnEmptyListWhenGivenEmptyIdList() { // Arrange int userId = existingUser.getId(); List emptyIds = Collections.emptyList(); @@ -369,7 +370,7 @@ void getAllNotesByUserIdAndIds_withEmptyIdList_shouldReturnEmptyList() { } @Test - void getAllNotesByUserIdAndIds_withSingleId_shouldReturnSingleNote() { + void shouldReturnSingleNoteWhenGivenSingleId() { // Arrange int userId = existingUser.getId(); List noteIds = List.of(existingNote.getId()); @@ -389,7 +390,7 @@ void getAllNotesByUserIdAndIds_withSingleId_shouldReturnSingleNote() { } @Test - void getAllNotesByUserIdAndIds_withPartialMatches_shouldReturnOnlyMatchingNotes() { + void shouldReturnOnlyMatchingNotesWhenGivenPartialMatchedInputs() { // Arrange int userId = existingUser.getId(); List noteIds = List.of(existingNote.getId(), 999); // Only ID 1 exists diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index c2caac1..fa3b89d 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,19 +1,19 @@ ## REDIS Configuration -spring.data.redis.host=${REDIS_HOST:localhost} -spring.data.redis.port=${REDIS_PORT:6379} +spring.data.redis.host=localhost +spring.data.redis.port=6379 ## Frontend URL for Local Development app.frontend.url=http://localhost:3000 ## AI Inference Configuration -ai.api.url=${API_URL} -ai.api.token=${API_TOKEN} -ai.api.model=${MODEL} +ai.api.url=placeholder-url +ai.api.token=placeholder-token +ai.api.model=placeholder-model -ai.api.user-role=${AI_API_USER_ROLE:user} -ai.api.system-role=${AI_API_SYSTEM_ROLE:system} -ai.api.temperature=${AI_API_TEMPERATURE:0.7} -ai.api.top-p=${AI_API_TOP_P:0.9} +ai.api.user-role=user +ai.api.system-role=system +ai.api.temperature=0.7 +ai.api.top-p=0.9 # JWT Configuration jwt.access-token.expiration-minutes=15 From 784b9227f9f6ba2adbd869762a15dd60bea58813 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 10 Dec 2025 14:11:06 +1100 Subject: [PATCH 56/63] docs: update README --- README.md | 152 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 125 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index eec3ed7..670fbf1 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,83 @@ # SmartNotes Backend -Personal project for smart note taking application +Backend service for **SmartNotes**, a personal project that enables smart note-taking with AI integration to +help individuals have a better experience in note-taking, organizing, and revising knowledge. + +**SmartNotes Frontend Repository:** [https://github.com/pvdev1805/SmartNotes](https://github.com/pvdev1805/SmartNotes) + +## Table of Contents + +- [Project Info](#project-info) + - [Branching Strategy](#branching-strategy) + - [Tech Stack](#tech-stack) +- [Supported Features](#supported-features) + - [User Authentication](#1-user-authentication) + - [AI-Powered Quiz Generation](#2-ai-powered-quiz-generation) + - [CRUD Management Features](#3-crud-management-features) +- [Project Setup](#project-setup) + - [Prerequisites](#prerequisites) + - [HuggingFace API](#huggingface-api) + - [Environment Variables](#environment-variables) +- [How to Test](#how-to-test) + - [Test Commands](#test-commands-windows) + - [Test Structure](#test-structure) + - [Test Notes](#test-notes) +- [Contributors](#contributors) + +## Project Info +### Branching Strategy +- **main**: stable, production‑ready code +- **develop**: integration branch with the latest completed features +- **feature/***: branches for individual features or fixes, merged into `develop` via pull requests + +### Tech Stack +- **Java 17** + **Spring Boot 3.5.5** for backend services +- **Maven** for build and dependency management +- **HuggingFace API** for AI quiz generation +- **MySQL** for database and persistence +- **Redis** for caching and session management +- **Docker** for containerization +- **Cloud and Deployment**: AWS (planned) + +[Back to top](#smartnotes-backend) ## Supported Features -| Module | Method | API | Description | -| -------- | -------- | -------- | ------- | -| Authentication | POST | `/api/auth/register` | Register new user | -| | POST | `/api/auth/login` | Login with your account | -| | POST | `/api/auth/logout` | Logout of current session | -| | POST | `/api/auth/refresh` | Refresh your authentication token | -| | GET | `/api/users/me` | Get your information (logged in) | -| Document | GET | `/api/documents` | List all available documents | -| | DELETE | `/api/documents/{id}` | Delete document using its id | -| Note | POST | `/api/documents/notes` | Create new note | -| | GET | `/api/documents/notes/{id}` | Get note content using its id | -| | PUT | `/api/documents/notes/{id}` | Update note content using its id | -| | DELETE | `/api/documents/notes/{id}` | Delete note content using its id | -| AI | GET | `/api/ai/generateQuiz/sample` | Get a sample quiz from sample response, this API does not require API TOKEN | -| | GET | `/api/ai/generateQuiz/{noteId}` | Generate relevant quizzes based on 1 note, this feature **requires HuggingFace API TOKEN** to run | +### 1. User Authentication +- Register, login, logout, and refresh tokens +- JWT-based secure authentication +- Secure access to user profile information + +### 2. AI-Powered Quiz Generation +- Generate quizzes from notes using HuggingFace models +- Sample endpoint for testing without authentication + +### 3. CRUD Management Features +**Document Management (CRUD)**: All types of documents including notes, flashcards, and quizzes: +- List all documents by user, or delete specific document + +**Note Management**: +- Create, update, retrieve, and delete notes + +**Flashcard & Flashcard Sets Management**: +- Create, update, retrieve, and delete flashcards +- Organize flashcards into sets + +**Quiz & Quiz Sets Management**: +- Create, update, retrieve, and delete quizzes +- Organize quizzes into sets +- Track quiz attempts and scores + +[Back to top](#smartnotes-backend) ## Project Setup +### Prerequisites +- **Java 17** or higher +- **MySQL** +- **Docker** + - For integration tests with Testcontainers + - For Redis (optional for local development on Windows) +- **HuggingFace Account** (for AI features) + ### HuggingFace API - Create a HuggingFace account - Create Access Token: @@ -33,6 +91,7 @@ Personal project for smart note taking application ### Environment variables In project root directory, create `.env` file and paste your **ACCESS TOKEN** here ``` +# AI INFERENCE API_TOKEN= API_URL= MODEL= @@ -41,29 +100,68 @@ AI_API_USER_ROLE=user AI_API_SYSTEM_ROLE=system AI_API_TEMPERATURE= AI_API_TOP_P= - + # Database DB_PORT= DB_NAME= DB_USERNAME= DB_PASSWORD= + +# JWT Config +JWT_KEYSTORE_PASSWORD= +JWT_KEY_PASSWORD= + +# Redis Config +REDIS_HOST= +REDIS_PORT= ``` -## How to test -For Windows, run one of below commands. +[Back to top](#smartnotes-backend) -Replace `test` with `clean test` to rebuild if there are changes in test code, similar with `verify`. +## How to test +### Test commands (Windows) ```bash # Run specific test ./mvnw.cmd test -Dtest=NoteServiceTest -# Run all unit tests -./mvnw.cmd test -Punit-test +# Run all unit tests (service tests with mocks) +./mvnw.cmd test -Punit-tests + +# Run all integration tests (repository + controller with containers) +./mvnw.cmd verify -Pintegration-tests + +# Run all tests (unit + integration) +./mvnw.cmd verify +``` + +### Test Structure +``` +src/test/java/ +├── unit/ # Fast tests (Surefire) +│ └── service/ # Service unit tests with mocked dependencies +│ +└── integration/ # Slower tests (Failsafe) + ├── repository/ # Repository tests with H2 + └── controller/ # Full-stack tests with MySQL + Redis containers +``` + +### Test Notes +- **Unit tests** run with Surefire and use mocked dependencies +- **Integration tests** run with Failsafe and use Testcontainers +- Docker must be running for integration tests (uses MySQL and Redis containers) +- First integration test run may take longer (downloads container images) + +[Back to top](#smartnotes-backend) + +## Contributors +**Project Maintainers:** This project (both frontend and backend) is developed and maintained by: + +- **Alice Tat** ([@TUT888](https://github.com/TUT888)) +- **Phu Vo** ([@pvdev1805](https://github.com/pvdev1805)) -# Run all integration tests -./mvnw.cmd verify -Pintegration-test +**Project Repositories:** +- Backend: [SmartNotes Backend](https://github.com/TUT888/SmartNotes) +- Frontend: [SmartNotes Frontend](https://github.com/pvdev1805/SmartNotes) -# Run all tests (unit and integration) -./mvnw.cmd verify -Ptest -``` \ No newline at end of file +[Back to top](#smartnotes-backend) \ No newline at end of file From 56f9b41ff7ca66afbdadb9cd2e349ebba742b2f9 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 16 Dec 2025 20:54:00 +1100 Subject: [PATCH 57/63] test(note): add junit tag for feature-based testing --- README.md | 25 +++++++++++++++---- .../NoteControllerIntegrationTest.java | 1 + .../repository/DocumentRepositoryTest.java | 2 ++ .../unit/service/NoteServiceTest.java | 1 + 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 670fbf1..406c6a8 100644 --- a/README.md +++ b/README.md @@ -120,19 +120,34 @@ REDIS_PORT= ## How to test ### Test commands (Windows) +Run all tests +```bash +# Run all tests (unit + integration) +./mvnw.cmd verify +``` +Run specific type of test ```bash -# Run specific test +# Run specific test file ./mvnw.cmd test -Dtest=NoteServiceTest # Run all unit tests (service tests with mocks) -./mvnw.cmd test -Punit-tests +./mvnw.cmd test -Punit-test # Run all integration tests (repository + controller with containers) -./mvnw.cmd verify -Pintegration-tests +./mvnw.cmd verify -Pintegration-test +``` -# Run all tests (unit + integration) -./mvnw.cmd verify +Run specific test group with tag, use **boolean expression** for tags combination +```bash +# Run test with "note" tag +./mvnw.cmd test -Dgroups="note" + +# Run tests with "note" or "document" tags +./mvnw.cmd test -Dgroups="note | document" + +# Combine with test type: run all unit tests with "note" tag +./mvnw.cmd test -Punit-test -Dgroups="note | document" ``` ### Test Structure diff --git a/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java index b9883f2..6810a23 100644 --- a/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java +++ b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java @@ -35,6 +35,7 @@ @Transactional @DisplayNameGeneration(DisplayNameGenerator.Standard.class) @DisplayName("Note Controller Integration Test") +@Tag("note") public class NoteControllerIntegrationTest extends BaseIntegration { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java index d2fb0be..04014cf 100644 --- a/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java +++ b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java @@ -17,6 +17,8 @@ @DataJpaTest @DisplayNameGeneration(DisplayNameGenerator.Standard.class) @DisplayName("Document Repository Test") +@Tag("document") +@Tag("note") public class DocumentRepositoryTest { @Autowired DocumentRepository documentRepository; diff --git a/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java index 62d837c..532ce38 100644 --- a/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java +++ b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java @@ -32,6 +32,7 @@ @MockitoSettings(strictness = Strictness.LENIENT) @DisplayNameGeneration(DisplayNameGenerator.Standard.class) @DisplayName("Note Service Test") +@Tag("note") public class NoteServiceTest { @Mock DocumentRepository documentRepository; From 2b47d24df41eba309550843a4d16654bd8f03f37 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 16 Dec 2025 21:09:31 +1100 Subject: [PATCH 58/63] feature(workflow): add auto test run on note feature branch --- .github/scripts/detect-branch-name.sh | 10 ++++++ .github/workflows/README.md | 18 ++++++++++ .github/workflows/_reusable-test.yml | 47 +++++++++++++++++++++++++++ .github/workflows/feature-test.yml | 34 +++++++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 .github/scripts/detect-branch-name.sh create mode 100644 .github/workflows/README.md create mode 100644 .github/workflows/_reusable-test.yml create mode 100644 .github/workflows/feature-test.yml diff --git a/.github/scripts/detect-branch-name.sh b/.github/scripts/detect-branch-name.sh new file mode 100644 index 0000000..a63d919 --- /dev/null +++ b/.github/scripts/detect-branch-name.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Exit immediately if any command fails +set -e + +echo "Detecting Branch Name..." + +if [[ "$BRANCH_NAME" == *"note"* ]]; then + echo "test_tag=note" >> $GITHUB_OUTPUT +fi \ No newline at end of file diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..a62a922 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,18 @@ +# CI/CD workflows + +## Convention and Standard +### Naming +- Each workflow should have a unique name and filename. + - Name the **main** workflows as `workflow-filename.yml` + - Name the **reusable** workflows as `_reusable-workflow.yml` (with `_` prefix) +- A workflow’s name should **not** be changed once it has been committed to the repository, as this may cause history mismatches and unexpected errors. +Therefore, it is recommended to consider changes carefully before modifying existing workflows. + +### Directory structure +- `.github/workflows`: All **runnable workflows** must be placed in this directory, as required by the official GitHub Actions documentation. Otherwise, they may not execute correctly. +- `.github/scripts`: Place **reusable or lengthy bash scripts** in this directory so they can be referenced and reused in workflows. + +## Reference +- Official documentation: [GitHub Actions Docs - How To](https://docs.github.com/en/actions/how-tos) +- Advanced customization: [Mert Mengü - Guide to GitHub Actions for Advanced CI/CD Workflows](https://medium.com/@mertmengu/guide-to-github-actions-for-advanced-ci-cd-workflows-1e494271ac22) +- GitHub Actions with Spring Boot: [Pudding Entertainment - GitHub Actions: Spring Boot Application Build and Deployment](https://pudding-entertainment.medium.com/github-actions-spring-boot-application-build-and-deployment-c52a2a233cb9) \ No newline at end of file diff --git a/.github/workflows/_reusable-test.yml b/.github/workflows/_reusable-test.yml new file mode 100644 index 0000000..1ec35ba --- /dev/null +++ b/.github/workflows/_reusable-test.yml @@ -0,0 +1,47 @@ +name: Reusable Test Workflow + +on: + # Workflow only runs when being called by others + workflow_call: + inputs: + level: # select between [unit, integration, all] + required: true + type: string + test-tag: # not required, but will automatically run all tests if left empty + required: false + type: string + +jobs: + build-and-test: + name: Build and Run Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Java JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: 17 + + - name: Build with Maven + run: mvn clean install + + - name: Run All Unit Tests + run: | + if [ -n "${{ inputs.test-tag }}" ]; then + mvn test -Punit-test -Dgroups="${{ inputs.test-tag }}" + else + mvn test -Punit-test + fi + + - name: Run All Integration Tests + if: inputs.level != 'unit' + run: | + if [ -n "${{ inputs.test-tag }}" ]; then + mvn verify -Pintegration-test -Dgroups="${{ inputs.test-tag }}" + else + mvn verify -Pintegration-test + fi \ No newline at end of file diff --git a/.github/workflows/feature-test.yml b/.github/workflows/feature-test.yml new file mode 100644 index 0000000..eb4fe5c --- /dev/null +++ b/.github/workflows/feature-test.yml @@ -0,0 +1,34 @@ +name: Feature Branch Test + +on: + # Workflow runs on any changes on the target feature branch + push: + branches: + - "feature/*note*" + - "fix/*note*" + +jobs: + # Detect feature changes based on branch name + detect-changes: + name: Detect Feature Changes + runs-on: ubuntu-latest + outputs: + test_tag: ${{ steps.detect.outputs.test_tag }} + steps: + - name: Detect Changes + id: detect + env: + BRANCH_NAME: ${{ github.ref_name }} + run: | + chmod +x .github/scripts/detect-branch-name.sh + ./.github/scripts/detect-branch-name.sh + + # Build and run test based on the branch detection result + build-and-test: + name: Build and Run Tests + needs: detect-changes + uses: ./.github/workflows/_reusable-test.yml + secrets: inherit + with: + level: 'unit' + test-tag: ${{ needs.detect-changes.outputs.test_tag }} \ No newline at end of file From 7bd8d78c0ced21b0dd79eed67f9c3ab5930d01bc Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Tue, 16 Dec 2025 21:12:18 +1100 Subject: [PATCH 59/63] feature(workflow): fix missing checkout repository --- .github/workflows/feature-test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/feature-test.yml b/.github/workflows/feature-test.yml index eb4fe5c..7d60a4e 100644 --- a/.github/workflows/feature-test.yml +++ b/.github/workflows/feature-test.yml @@ -15,6 +15,9 @@ jobs: outputs: test_tag: ${{ steps.detect.outputs.test_tag }} steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Detect Changes id: detect env: From 6c3ec3c56dbf76db4e5a1c21237b66f36bf42982 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 17 Dec 2025 00:06:55 +1100 Subject: [PATCH 60/63] feature(workflow): add workflow for develop branch --- .github/workflows/README.md | 18 ++++++++++++++++- .github/workflows/_reusable-test.yml | 4 ++-- .github/workflows/ci-cd-develop.yml | 29 ++++++++++++++++++++++++++++ .github/workflows/feature-test.yml | 6 ++++-- 4 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci-cd-develop.yml diff --git a/.github/workflows/README.md b/.github/workflows/README.md index a62a922..486d2ce 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -12,7 +12,23 @@ Therefore, it is recommended to consider changes carefully before modifying exis - `.github/workflows`: All **runnable workflows** must be placed in this directory, as required by the official GitHub Actions documentation. Otherwise, they may not execute correctly. - `.github/scripts`: Place **reusable or lengthy bash scripts** in this directory so they can be referenced and reused in workflows. +### Workflows (Planned) +- Changes on `feature` branch: Run unit tests, linting, security check, etc +- PR on `develop` branch (for integration/staging): + - Unit + Integration tests (API, DB, UI, etc) + - Build and push staging images + - Deploy to staging environment and test (optional) +- PR on `main` branch (for production): Final build verification +- Completed merge on `main` branch (for production): + - Build and push production-ready images with version tags + - Deploy to production environment + ## Reference +### GitHub Actions Workflows - Official documentation: [GitHub Actions Docs - How To](https://docs.github.com/en/actions/how-tos) - Advanced customization: [Mert Mengü - Guide to GitHub Actions for Advanced CI/CD Workflows](https://medium.com/@mertmengu/guide-to-github-actions-for-advanced-ci-cd-workflows-1e494271ac22) -- GitHub Actions with Spring Boot: [Pudding Entertainment - GitHub Actions: Spring Boot Application Build and Deployment](https://pudding-entertainment.medium.com/github-actions-spring-boot-application-build-and-deployment-c52a2a233cb9) \ No newline at end of file +- GitHub Actions with Spring Boot: [Pudding Entertainment - GitHub Actions: Spring Boot Application Build and Deployment](https://pudding-entertainment.medium.com/github-actions-spring-boot-application-build-and-deployment-c52a2a233cb9) + +### Branching Strategy +- Different branching strategies, including using develop and main branch: [Devtron - Branching Strategy for CI/CD](https://devtron.ai/blog/best-branching-strategy-for-ci-cd/) +- Run tests on feature and master branch: [TeamCity - Branching Strategy for CI/CD](https://www.jetbrains.com/teamcity/ci-cd-guide/concepts/branching-strategy/) \ No newline at end of file diff --git a/.github/workflows/_reusable-test.yml b/.github/workflows/_reusable-test.yml index 1ec35ba..91c4eed 100644 --- a/.github/workflows/_reusable-test.yml +++ b/.github/workflows/_reusable-test.yml @@ -4,7 +4,7 @@ on: # Workflow only runs when being called by others workflow_call: inputs: - level: # select between [unit, integration, all] + test-level: # select between [unit, integration, all] required: true type: string test-tag: # not required, but will automatically run all tests if left empty @@ -38,7 +38,7 @@ jobs: fi - name: Run All Integration Tests - if: inputs.level != 'unit' + if: inputs.test-level != 'unit' run: | if [ -n "${{ inputs.test-tag }}" ]; then mvn verify -Pintegration-test -Dgroups="${{ inputs.test-tag }}" diff --git a/.github/workflows/ci-cd-develop.yml b/.github/workflows/ci-cd-develop.yml new file mode 100644 index 0000000..cbcadf5 --- /dev/null +++ b/.github/workflows/ci-cd-develop.yml @@ -0,0 +1,29 @@ +name: CI/CD on Develop Branch + +on: + # Workflow runs whenever a new PR to develop is created + pull_request: + branches: + - develop + +jobs: + # Build and run test based on the branch detection result + build-and-test: + name: Build and Run Tests + uses: ./.github/workflows/_reusable-test.yml + secrets: inherit + with: + test-level: 'all' + + # Build and push image (placeholder only since the app is not completed yet) + build-images: + name: Build Images and Deploy to Docker Hub + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build and push + run: | + echo "Building images..." + echo "Published to Docker Hub!" \ No newline at end of file diff --git a/.github/workflows/feature-test.yml b/.github/workflows/feature-test.yml index 7d60a4e..8e5a739 100644 --- a/.github/workflows/feature-test.yml +++ b/.github/workflows/feature-test.yml @@ -1,11 +1,13 @@ name: Feature Branch Test on: - # Workflow runs on any changes on the target feature branch + # Workflow runs whenever the source code changes on the target feature branch push: branches: - "feature/*note*" - "fix/*note*" + paths: + - "src/**" jobs: # Detect feature changes based on branch name @@ -33,5 +35,5 @@ jobs: uses: ./.github/workflows/_reusable-test.yml secrets: inherit with: - level: 'unit' + test-level: 'unit' test-tag: ${{ needs.detect-changes.outputs.test_tag }} \ No newline at end of file From 270887ae78a8475a9951563cfe726221043ae19e Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 17 Dec 2025 00:17:20 +1100 Subject: [PATCH 61/63] feature(workflow): fix ci develop requiring successful tests before publishing images --- .github/workflows/ci-cd-develop.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-cd-develop.yml b/.github/workflows/ci-cd-develop.yml index cbcadf5..1301563 100644 --- a/.github/workflows/ci-cd-develop.yml +++ b/.github/workflows/ci-cd-develop.yml @@ -9,7 +9,7 @@ on: jobs: # Build and run test based on the branch detection result build-and-test: - name: Build and Run Tests + name: Build and Run All Tests uses: ./.github/workflows/_reusable-test.yml secrets: inherit with: @@ -18,6 +18,7 @@ jobs: # Build and push image (placeholder only since the app is not completed yet) build-images: name: Build Images and Deploy to Docker Hub + needs: build-and-test runs-on: ubuntu-latest steps: - name: Checkout repository From 4907df58473f416859822bf969f92fdf5e860cea Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 17 Dec 2025 11:02:12 +1100 Subject: [PATCH 62/63] feat(workflow): change trigger condition of feature test workflow --- .github/workflows/feature-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/feature-test.yml b/.github/workflows/feature-test.yml index 8e5a739..bad1d8b 100644 --- a/.github/workflows/feature-test.yml +++ b/.github/workflows/feature-test.yml @@ -4,8 +4,8 @@ on: # Workflow runs whenever the source code changes on the target feature branch push: branches: - - "feature/*note*" - - "fix/*note*" + - "feature/**" + - "fix/**" paths: - "src/**" From 18dfe3e89153b20dead589a809b83819189a4b62 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Wed, 17 Dec 2025 11:43:27 +1100 Subject: [PATCH 63/63] docs: update main README with CI/CD info --- README.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 406c6a8..c270833 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ # SmartNotes Backend +> **Active development** happens on [develop](https://github.com/TUT888/SmartNotes/tree/develop) branch. +> The [main](https://github.com/TUT888/SmartNotes/tree/main) branch contains **stable release code**. Backend service for **SmartNotes**, a personal project that enables smart note-taking with AI integration to help individuals have a better experience in note-taking, organizing, and revising knowledge. @@ -8,8 +10,8 @@ help individuals have a better experience in note-taking, organizing, and revisi ## Table of Contents - [Project Info](#project-info) - - [Branching Strategy](#branching-strategy) - [Tech Stack](#tech-stack) + - [Branching Strategy with CI/CD](#branching-strategy-with-cicd) - [Supported Features](#supported-features) - [User Authentication](#1-user-authentication) - [AI-Powered Quiz Generation](#2-ai-powered-quiz-generation) @@ -25,11 +27,6 @@ help individuals have a better experience in note-taking, organizing, and revisi - [Contributors](#contributors) ## Project Info -### Branching Strategy -- **main**: stable, production‑ready code -- **develop**: integration branch with the latest completed features -- **feature/***: branches for individual features or fixes, merged into `develop` via pull requests - ### Tech Stack - **Java 17** + **Spring Boot 3.5.5** for backend services - **Maven** for build and dependency management @@ -38,6 +35,14 @@ help individuals have a better experience in note-taking, organizing, and revisi - **Redis** for caching and session management - **Docker** for containerization - **Cloud and Deployment**: AWS (planned) +- **CI/CD**: GitHub Actions + +### Branching Strategy with CI/CD +> For detail documentation about CI/CD process, please refer to another [dedicated README](./.github/workflows/README.md) + +- **main**: stable, production‑ready code +- **develop**: integration branch with the latest completed features +- **feature/***: branches for individual features or fixes, merged into `develop` via pull requests [Back to top](#smartnotes-backend)