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..486d2ce --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,34 @@ +# 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. + +### 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) + +### 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 new file mode 100644 index 0000000..91c4eed --- /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: + test-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.test-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/ci-cd-develop.yml b/.github/workflows/ci-cd-develop.yml new file mode 100644 index 0000000..1301563 --- /dev/null +++ b/.github/workflows/ci-cd-develop.yml @@ -0,0 +1,30 @@ +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 All 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 + needs: build-and-test + 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 new file mode 100644 index 0000000..bad1d8b --- /dev/null +++ b/.github/workflows/feature-test.yml @@ -0,0 +1,39 @@ +name: Feature Branch Test + +on: + # Workflow runs whenever the source code changes on the target feature branch + push: + branches: + - "feature/**" + - "fix/**" + paths: + - "src/**" + +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: Checkout repository + uses: actions/checkout@v4 + + - 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: + test-level: 'unit' + test-tag: ${{ needs.detect-changes.outputs.test_tag }} \ No newline at end of file diff --git a/README.md b/README.md index 5e0f32b..c270833 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,88 @@ # 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**. -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) + - [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) + - [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 +### 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) +- **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) ## Supported Features -| 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 | -| 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: @@ -25,6 +96,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= @@ -33,10 +105,83 @@ 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= -``` \ No newline at end of file + +# JWT Config +JWT_KEYSTORE_PASSWORD= +JWT_KEY_PASSWORD= + +# Redis Config +REDIS_HOST= +REDIS_PORT= +``` + +[Back to top](#smartnotes-backend) + +## 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 file +./mvnw.cmd test -Dtest=NoteServiceTest + +# Run all unit tests (service tests with mocks) +./mvnw.cmd test -Punit-test + +# Run all integration tests (repository + controller with containers) +./mvnw.cmd verify -Pintegration-test +``` + +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 +``` +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)) + +**Project Repositories:** +- Backend: [SmartNotes Backend](https://github.com/TUT888/SmartNotes) +- Frontend: [SmartNotes Frontend](https://github.com/pvdev1805/SmartNotes) + +[Back to top](#smartnotes-backend) \ No newline at end of file diff --git a/pom.xml b/pom.xml index d080d09..bbf615a 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,11 @@ 17 + 1.18.30 + 1.6.3 + 1.16.2 + false + false @@ -55,6 +60,14 @@ spring-boot-starter-test test + + + + + org.springframework.boot + spring-boot-starter-validation + 3.5.6 + @@ -69,9 +82,146 @@ org.projectlombok lombok - 1.18.30 + ${lombok.version} - + + + + + org.mapstruct + mapstruct + ${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 + + + + + + org.springframework.boot + spring-boot-starter-data-redis + 3.5.7 + + + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + + + com.h2database + h2 + 2.2.224 + 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 + + + + + + com.fasterxml.jackson.module + 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 + + + + + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + + @@ -79,7 +229,142 @@ 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} + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + me.fabriciorby + maven-surefire-junit5-tree-reporter + 1.5.1 + + + + + ${skip.unit.tests} + + **/unit/**/*Test.java + + + **/integration/** + + + 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} + + **/integration/**/*Test.java + + + **/unit/** + + + plain + + true + + + + + + + + integration-test + verify + + + + + + + dev + + true + + + dev + + + + test + + test + + false + false + + + + prod + + prod + + + + + + unit-test + + false + true + + + + integration-test + + false + true + + + + 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/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/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/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/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/RestClientConfig.java b/src/main/java/com/be08/smart_notes/config/RestClientConfig.java new file mode 100644 index 0000000..6a37a7f --- /dev/null +++ b/src/main/java/com/be08/smart_notes/config/RestClientConfig.java @@ -0,0 +1,33 @@ +package com.be08.smart_notes.config; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestClient; + +@Configuration +@RequiredArgsConstructor +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class RestClientConfig { + 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(properties.getUrl()) + .defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .defaultHeader("Authorization", "Bearer " + properties.getToken()) + .build(); + } +} 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..63f5e5e --- /dev/null +++ b/src/main/java/com/be08/smart_notes/config/SecurityConfig.java @@ -0,0 +1,151 @@ +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.beans.factory.annotation.Value; +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 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 +public class SecurityConfig { + + @Value("${app.frontend.url}") + private String frontendUrl; + + @Bean + public ObjectMapper objectMapper(){ + ObjectMapper mapper = new ObjectMapper(); + + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + return mapper; + } + + @Bean + 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 CORS settings + * @return CorsConfigurationSource + */ + @Bean + public CorsConfigurationSource corsConfigurationSource(){ + CorsConfiguration corsConfiguration = new CorsConfiguration(); + + corsConfiguration.setAllowedOrigins(Arrays.asList(frontendUrl)); + 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 + * @return Configured SecurityFilterChain + * @throws Exception + */ + @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) + + // 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 + .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/AIController.java b/src/main/java/com/be08/smart_notes/controller/AIController.java deleted file mode 100644 index ec080e1..0000000 --- a/src/main/java/com/be08/smart_notes/controller/AIController.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.be08.smart_notes.controller; - -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 com.be08.smart_notes.dto.ai.QuizResponse; -import com.be08.smart_notes.service.ai.QuizGenerationService; - -@RestController -@RequestMapping("/api/ai") -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 Long noteId) { - QuizResponse quizList = quizGenerationService.generateQuizFromNote(noteId); - return ResponseEntity.status(HttpStatus.OK).body(quizList); - } -} diff --git a/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java b/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java new file mode 100644 index 0000000..c6c70ba --- /dev/null +++ b/src/main/java/com/be08/smart_notes/controller/AIGenerationController.java @@ -0,0 +1,55 @@ +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; +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; + +@RestController +@RequestMapping("/api/ai/generation") +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class AIGenerationController { + QuizGenerationService quizGenerationService; + + @GetMapping("/quiz-sets/sample") + public ResponseEntity generateSampleQuizSet() { + QuizResponse quizResponse = quizGenerationService.generateSampleQuiz(); + ApiResponse apiResponse = ApiResponse.builder() + .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.CREATED).body(apiResponse); + } + + @PostMapping("/quiz-sets") + public ResponseEntity generateQuizSet(@Validated(MultipleDocument.class) @RequestBody QuizGenerationRequest quizGenerationRequest) { + QuizSetResponse quizSetResponse = quizGenerationService.generateQuizSet(quizGenerationRequest); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz set successfully generated") + .data(quizSetResponse) + .build(); + return ResponseEntity.status(HttpStatus.CREATED).body(apiResponse); + } +} 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..5dc05f4 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/controller/AttemptController.java @@ -0,0 +1,101 @@ +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; +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 result fetched 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); + } + + @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/controller/AuthenticationController.java b/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java new file mode 100644 index 0000000..4aabdb0 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/controller/AuthenticationController.java @@ -0,0 +1,85 @@ +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; +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; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +@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){ + 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(); + } + + /** + * 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/controller/DocumentController.java b/src/main/java/com/be08/smart_notes/controller/DocumentController.java index af0e004..6e269d9 100644 --- a/src/main/java/com/be08/smart_notes/controller/DocumentController.java +++ b/src/main/java/com/be08/smart_notes/controller/DocumentController.java @@ -2,25 +2,44 @@ 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; 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 com.be08.smart_notes.entity.DocumentEntity; +import com.be08.smart_notes.dto.response.ApiResponse; +import com.be08.smart_notes.model.Document; import com.be08.smart_notes.service.DocumentService; @RestController -@RequestMapping("/api/document") +@RequestMapping("/api/documents") +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class DocumentController { - @Autowired - private DocumentService documentService; + DocumentService documentService; - @GetMapping("/all") + @GetMapping public ResponseEntity getAllDocuments() { - List documentList = documentService.getAllDocuments(); - return ResponseEntity.status(HttpStatus.OK).body(documentList); + List documentList = documentService.getAllDocuments(); + 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); + 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/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/controller/NoteController.java b/src/main/java/com/be08/smart_notes/controller/NoteController.java index 01d836a..de5f08d 100644 --- a/src/main/java/com/be08/smart_notes/controller/NoteController.java +++ b/src/main/java/com/be08/smart_notes/controller/NoteController.java @@ -1,33 +1,73 @@ 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.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -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; - -import com.be08.smart_notes.entity.NoteEntity; +import org.springframework.web.bind.annotation.*; + +import com.be08.smart_notes.dto.request.NoteUpsertRequest; +import com.be08.smart_notes.dto.response.ApiResponse; import com.be08.smart_notes.service.NoteService; +import java.util.List; + @RestController -@RequestMapping("/api/note") +@RequestMapping("/api/documents/notes") +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class NoteController { - @Autowired - private NoteService noteService; + NoteService noteService; + + @PostMapping + public ResponseEntity createNote(@RequestBody @Valid NoteUpsertRequest noteCreationRequest) { + NoteResponse createdNote = noteService.createNote(noteCreationRequest); + ApiResponse apiResponse = ApiResponse.builder() + .message("Note created successfully") + .data(createdNote) + .build(); + return ResponseEntity.status(HttpStatus.CREATED).body(apiResponse); + } @GetMapping("/{id}") - public ResponseEntity getNote(@PathVariable Long id) { - NoteEntity note = noteService.getNote(id); - return ResponseEntity.status(HttpStatus.OK).body(note); + public ResponseEntity getNote(@PathVariable int id) { + NoteResponse note = noteService.getNote(id); + ApiResponse apiResponse = ApiResponse.builder() + .message("Note fetched successfully") + .data(note) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); } - - @PostMapping("/create") - public ResponseEntity createNote(@RequestBody NoteEntity note) { - NoteEntity createdNote = noteService.createNewNote(note); - return ResponseEntity.status(HttpStatus.CREATED).body(createdNote); + + @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); + } + + @PatchMapping("/{id}") + 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); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteNote(@PathVariable int id) { + noteService.deleteNote(id); + 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/controller/QuizController.java b/src/main/java/com/be08/smart_notes/controller/QuizController.java new file mode 100644 index 0000000..db2375a --- /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.QuizUpsertDTO; +import com.be08.smart_notes.dto.response.ApiResponse; +import com.be08.smart_notes.dto.response.QuizResponse; +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; +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(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.CREATED).body(apiResponse); + } + + @GetMapping("/{id}") + @JsonView(QuizView.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(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() + .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 new file mode 100644 index 0000000..eb1a205 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/controller/QuizSetController.java @@ -0,0 +1,99 @@ +package com.be08.smart_notes.controller; + +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.QuizView; +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; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/quiz-sets") +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class QuizSetController { + QuizSetService quizSetService; + + @PostMapping + @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.CREATED).body(apiResponse); + } + + @GetMapping + @JsonView(QuizView.Basic.class) + 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("/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) { + QuizSetResponse quizSetResponse = quizSetService.getQuizSetById(id); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz set fetched successfully") + .data(quizSetResponse) + .build(); + return ResponseEntity.status(HttpStatus.OK).body(apiResponse); + } + + @PutMapping("/{id}") + @JsonView(QuizView.Detail.class) + public ResponseEntity updateQuizSet(@PathVariable int id, @RequestBody QuizSetUpsertRequest request) { + QuizSetResponse quizSetResponse = quizSetService.updateQuizSet(id, request); + ApiResponse apiResponse = ApiResponse.builder() + .message("Quiz set updated successfully") + .data(quizSetResponse) + .build(); + 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); + 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/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/Quiz.java b/src/main/java/com/be08/smart_notes/dto/Quiz.java deleted file mode 100644 index fd8afc5..0000000 --- a/src/main/java/com/be08/smart_notes/dto/Quiz.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.be08.smart_notes.dto; - -public class Quiz { - public String question; - public String[] options; - public int correctIndex; - - public Quiz() { - this.question = ""; - this.options = new String[4]; - this.correctIndex = -1; - } - - public Quiz(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/QuizUpsertDTO.java b/src/main/java/com/be08/smart_notes/dto/QuizUpsertDTO.java new file mode 100644 index 0000000..5e5a25c --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/QuizUpsertDTO.java @@ -0,0 +1,56 @@ +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.*; +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 { + Integer sourceDocumentId; + + // If null in creation request, new quiz will be categorised in default set + @NotNull(groups = OnUpdate.class, message = "QUIZ_SET_ID_REQUIRED") + Integer quizSetId; + + @NotEmpty(groups = {OnCreate.class, OnUpdate.class}, message = "QUIZ_TITLE_REQUIRED") + @JsonProperty(value = "topic") + String title; + + // 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; + + // Static nested class + @Data + @NoArgsConstructor + @AllArgsConstructor + @FieldDefaults(level = AccessLevel.PRIVATE) + public static class Question { + @NotNull + @JsonProperty(value = "question") + String questionText; + + @NotNull + @Size(min = 4, max = 4) + @JsonProperty(value = "options") + String[] options; + + @NotNull + @Min(0) + @Max(3) + @JsonProperty(value = "correct_index") + 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 new file mode 100644 index 0000000..f18b797 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceRequest.java @@ -0,0 +1,31 @@ +package com.be08.smart_notes.dto.ai; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; +import lombok.experimental.FieldDefaults; + +@Data +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class AIInferenceRequest { + String model; + RequestMessage[] messages; + double temperature; + + @JsonProperty("top_p") + double topP; + + @JsonProperty("guided_json") + String guidedJson; + + // Static nested class + @Data + @NoArgsConstructor + @AllArgsConstructor + @FieldDefaults(level = AccessLevel.PRIVATE) + public static class RequestMessage { + 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 new file mode 100644 index 0000000..6998121 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/ai/AIInferenceResponse.java @@ -0,0 +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 { + String id; + String model; + String object; + ResponseChoice[] choices; + + // Static nested class + @Data + @AllArgsConstructor + @FieldDefaults(level = AccessLevel.PRIVATE) + public static class ResponseChoice { + ResponseMessage message; + + @Data + @AllArgsConstructor + @FieldDefaults(level = AccessLevel.PRIVATE) + public static class ResponseMessage { + String content; + 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 deleted file mode 100644 index c1ee9c5..0000000 --- a/src/main/java/com/be08/smart_notes/dto/ai/QuizResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.be08.smart_notes.dto.ai; - -import java.util.ArrayList; - -import com.be08.smart_notes.dto.Quiz; - -public class QuizResponse { - public String topic; - public ArrayList quizzes; -} 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/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/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/NoteUpsertRequest.java b/src/main/java/com/be08/smart_notes/dto/request/NoteUpsertRequest.java new file mode 100644 index 0000000..ac66847 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/request/NoteUpsertRequest.java @@ -0,0 +1,20 @@ +package com.be08.smart_notes.dto.request; + +import jakarta.validation.constraints.NotBlank; +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 + private String title = "Untitled Note"; + + @NotBlank(message = "NOTE_CONTENT_EMPTY") + private String content; +} 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 new file mode 100644 index 0000000..b01cd8b --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/request/QuizGenerationRequest.java @@ -0,0 +1,30 @@ +package com.be08.smart_notes.dto.request; + +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; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +@JsonIgnoreProperties(ignoreUnknown = true) +public class QuizGenerationRequest { + @NotNull(groups = SingleDocument.class, message = "DOCUMENT_ID_REQUIRED") + Integer docId; + + @NotNull(groups = MultipleDocument.class, message = "DOCUMENT_IDS_REQUIRED") + @Size(min = 1, max = 5, message = "QUIZ_DOCUMENT_SIZE_EXCEED") + List docIds; + + @Builder.Default + @Min(value = 1, message = "INVALID_QUIZ_SIZE") + @Max(value = 20, message = "INVALID_QUIZ_SIZE") + 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 new file mode 100644 index 0000000..1359f43 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/request/QuizSetUpsertRequest.java @@ -0,0 +1,19 @@ +package com.be08.smart_notes.dto.request; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +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) +@JsonIgnoreProperties(ignoreUnknown = true) +public class QuizSetUpsertRequest { + @NotBlank(message = "QUIZ_SET_TITLE_REQUIRED") + String title; +} 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 new file mode 100644 index 0000000..465fef6 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/request/UserCreationRequest.java @@ -0,0 +1,29 @@ +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; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +@ToString(exclude = {"password"}) +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/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/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/response/AuthenticationResponse.java b/src/main/java/com/be08/smart_notes/dto/response/AuthenticationResponse.java new file mode 100644 index 0000000..7aad9f4 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/response/AuthenticationResponse.java @@ -0,0 +1,15 @@ +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; + String accessToken; + String refreshToken; +} 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/dto/response/NoteResponse.java b/src/main/java/com/be08/smart_notes/dto/response/NoteResponse.java new file mode 100644 index 0000000..68b8a44 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/response/NoteResponse.java @@ -0,0 +1,25 @@ +package com.be08.smart_notes.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +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; + + @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/dto/response/QuizResponse.java b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java new file mode 100644 index 0000000..2254134 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/response/QuizResponse.java @@ -0,0 +1,54 @@ +package com.be08.smart_notes.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import com.be08.smart_notes.dto.view.QuizView; +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(QuizView.Basic.class) + Integer id; + + @JsonView(QuizView.Basic.class) + String title; + + @JsonView(QuizView.Basic.class) + Integer quizSetId; + + @JsonView(QuizView.Basic.class) + Integer sourceDocumentId; + + @JsonView(QuizView.Basic.class) + LocalDateTime createdAt; + + @JsonView(QuizView.Basic.class) + LocalDateTime updatedAt; + + @JsonView(QuizView.Detail.class) + List questions; + + // Static nested class + @Data + @NoArgsConstructor + @AllArgsConstructor + @FieldDefaults(level = AccessLevel.PRIVATE) + public static class Question { + 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 new file mode 100644 index 0000000..609cc0c --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/response/QuizSetResponse.java @@ -0,0 +1,37 @@ +package com.be08.smart_notes.dto.response; + +import com.be08.smart_notes.dto.view.QuizView; +import com.be08.smart_notes.enums.OriginType; +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; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) +public class QuizSetResponse { + @JsonView(QuizView.Basic.class) + Integer id; + + @JsonView(QuizView.Basic.class) + String title; + + @JsonView(QuizView.Basic.class) + OriginType originType; + + @JsonView(QuizView.Basic.class) + LocalDateTime createdAt; + + @JsonView(QuizView.Basic.class) + LocalDateTime updatedAt; + + @JsonView(QuizView.Detail.class) + List quizzes; +} 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..6b6c3f5 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/response/UserResponse.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 UserResponse { + int id; + String name; + String email; + String avatarUrl; + + LocalDateTime createdAt; + LocalDateTime updatedAt; +} 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..4534bbe --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/view/AttemptView.java @@ -0,0 +1,5 @@ +package com.be08.smart_notes.dto.view; + +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/View.java b/src/main/java/com/be08/smart_notes/dto/view/View.java new file mode 100644 index 0000000..7ca0729 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/dto/view/View.java @@ -0,0 +1,6 @@ +package com.be08.smart_notes.dto.view; + +public interface View { + public interface Basic {} + public interface Detail extends Basic {} +} diff --git a/src/main/java/com/be08/smart_notes/entity/DocumentEntity.java b/src/main/java/com/be08/smart_notes/entity/DocumentEntity.java deleted file mode 100644 index 70c801f..0000000 --- a/src/main/java/com/be08/smart_notes/entity/DocumentEntity.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.be08.smart_notes.entity; - -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; - -@Entity -@Table(name = "documents") -public class DocumentEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, name = "user_id") - private Long userId; - - @Column(nullable = false) - private String title; - - @Column(nullable = false) - private String type; - - @Column(nullable = false, name = "created_at") - private LocalDateTime createdAt; - - @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 deleted file mode 100644 index db3fd51..0000000 --- a/src/main/java/com/be08/smart_notes/entity/NoteEntity.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.be08.smart_notes.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; - -@Entity -@Table(name = "notes") -public class NoteEntity { - @Id - private Long id; - - @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/enums/DocumentType.java b/src/main/java/com/be08/smart_notes/enums/DocumentType.java new file mode 100644 index 0000000..04857da --- /dev/null +++ b/src/main/java/com/be08/smart_notes/enums/DocumentType.java @@ -0,0 +1,5 @@ +package com.be08.smart_notes.enums; + +public enum DocumentType { + NOTE, PDF; +} 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/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..ad3d444 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/exception/ErrorCode.java @@ -0,0 +1,86 @@ +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 { + // 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), + 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), + 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), + DOCUMENT_ID_REQUIRED(2202, "Single document ID required", HttpStatus.BAD_REQUEST), + DOCUMENT_IDS_REQUIRED(2203, "List of document IDs required", HttpStatus.BAD_REQUEST), + NOTE_CONTENT_EMPTY(2204, "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(2206, "This document cannot be deleted", HttpStatus.BAD_REQUEST), + + // 23xx - AI-related features + 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 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), + 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), + + // 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), + + // 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; + 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..965daf5 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/exception/GlobalExceptionHandler.java @@ -0,0 +1,63 @@ +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.MethodArgumentNotValidException; +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); + } + + @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/AttemptDetailMapper.java b/src/main/java/com/be08/smart_notes/mapper/AttemptDetailMapper.java new file mode 100644 index 0000000..64ce230 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/mapper/AttemptDetailMapper.java @@ -0,0 +1,30 @@ +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.*; + +@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); + + // 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") + @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(); + 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..110edeb --- /dev/null +++ b/src/main/java/com/be08/smart_notes/mapper/AttemptMapper.java @@ -0,0 +1,18 @@ +package com.be08.smart_notes.mapper; + +import com.be08.smart_notes.dto.response.AttemptResponse; +import com.be08.smart_notes.model.Attempt; +import org.mapstruct.*; + +import java.util.List; + +@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/DocumentMapper.java b/src/main/java/com/be08/smart_notes/mapper/DocumentMapper.java new file mode 100644 index 0000000..36740aa --- /dev/null +++ b/src/main/java/com/be08/smart_notes/mapper/DocumentMapper.java @@ -0,0 +1,13 @@ +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; + +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/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/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 new file mode 100644 index 0000000..3c2cab2 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/mapper/QuizMapper.java @@ -0,0 +1,25 @@ +package com.be08.smart_notes.mapper; + +import com.be08.smart_notes.dto.QuizUpsertDTO; +import com.be08.smart_notes.dto.response.QuizResponse; +import com.be08.smart_notes.model.Quiz; +import org.mapstruct.*; + +import java.util.List; + +@Mapper(componentModel = "spring", uses = QuestionMapper.class) +public interface QuizMapper { + // For update requests + @BeanMapping(ignoreByDefault = true) + @Mapping(target = "title", source = "title") + 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 <--> QuizUpsertDTO + Quiz toQuiz(QuizUpsertDTO dto); + List toQuizList(List dtoList); +} 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..7b484ce --- /dev/null +++ b/src/main/java/com/be08/smart_notes/mapper/QuizSetMapper.java @@ -0,0 +1,23 @@ +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.BeanMapping; +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 + @BeanMapping(ignoreByDefault = 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/mapper/UserMapper.java b/src/main/java/com/be08/smart_notes/mapper/UserMapper.java new file mode 100644 index 0000000..387e01e --- /dev/null +++ b/src/main/java/com/be08/smart_notes/mapper/UserMapper.java @@ -0,0 +1,18 @@ +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; + +@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/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/Document.java b/src/main/java/com/be08/smart_notes/model/Document.java new file mode 100644 index 0000000..89bfb18 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/model/Document.java @@ -0,0 +1,63 @@ +package com.be08.smart_notes.model; + +import java.time.LocalDateTime; + +import com.be08.smart_notes.enums.DocumentType; +import com.fasterxml.jackson.annotation.JsonInclude; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table(name = "document") +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Document { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false) + private Integer userId; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private DocumentType type; + + @Column(nullable = false, name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + // Information for note document + @Column(columnDefinition = "TEXT") + private String content; + + // Information for pdf document + @Column(name = "file_url") + private String fileUrl; + + @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/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/model/Question.java b/src/main/java/com/be08/smart_notes/model/Question.java new file mode 100644 index 0000000..f3643b7 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/model/Question.java @@ -0,0 +1,43 @@ +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 = "question") +@FieldDefaults(level = AccessLevel.PRIVATE) +public class Question { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Integer id; + + @Column(nullable = false, name = "question_text") + String questionText; + + @Column(nullable = false, name = "option_a") + String optionA; + + @Column(nullable = false, name = "option_b") + String optionB; + + @Column(nullable = false, name = "option_c") + String optionC; + + @Column(nullable = false, name = "option_d") + String optionD; + + @Column(nullable = false, name = "correct_answer") + Character correctAnswer; + + // Relationship + @JsonIgnore + @ManyToOne + @JoinColumn(name = "quiz_id", referencedColumnName = "id") + 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 new file mode 100644 index 0000000..3ad7d6b --- /dev/null +++ b/src/main/java/com/be08/smart_notes/model/Quiz.java @@ -0,0 +1,60 @@ +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.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table(name = "quiz") +@FieldDefaults(level = AccessLevel.PRIVATE) +public class Quiz { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Integer id; + + @Column(nullable = false, name = "created_at") + LocalDateTime createdAt; + + @Column(nullable = false, name = "updated_at") + LocalDateTime updatedAt; + + @Column(nullable = false) + String title; + + @Column(name = "source_document_id") + Integer sourceDocumentId; + + // Relationship: quiz - quiz set + @JsonIgnore + @ManyToOne + @JoinColumn(name = "quiz_set_id", referencedColumnName = "id") + QuizSet quizSet; + + // Relationship: quiz - question + @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(); + this.updatedAt = LocalDateTime.now(); + } + + @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 new file mode 100644 index 0000000..b90ea88 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/model/QuizSet.java @@ -0,0 +1,69 @@ +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.ArrayList; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity(name = "quiz_set") +@FieldDefaults(level = AccessLevel.PRIVATE) +public class QuizSet { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Integer id; + + @Column(nullable = false, name = "user_id") + Integer userId; + + @Column(nullable = false, name = "title") + String title; + + @Column(nullable = false, name = "origin_type") + @Enumerated(EnumType.STRING) + OriginType originType; + + @Column(nullable = false, name = "created_at") + LocalDateTime createdAt; + + @Column(nullable = false, name = "updated_at") + LocalDateTime updatedAt; + + // Relationship + @OneToMany(mappedBy = "quizSet", cascade = CascadeType.ALL, orphanRemoval = true) + List quizzes; + + public void addQuiz(Quiz quiz){ + if (this.quizzes == null) { + this.quizzes = new ArrayList<>(); + } + quizzes.add(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 + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} 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 66% 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 1dd7870..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; @@ -8,13 +8,21 @@ 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 { +@Table(name = "user") +public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + private int id; @Column(nullable = false, unique = true) private String email; @@ -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/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/repository/DocumentRepository.java b/src/main/java/com/be08/smart_notes/repository/DocumentRepository.java index 53c7849..d4045c2 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,20 @@ package com.be08.smart_notes.repository; +import java.util.List; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; -import com.be08.smart_notes.entity.DocumentEntity; +import com.be08.smart_notes.model.Document; +import org.springframework.stereotype.Repository; + +@Repository +public interface DocumentRepository extends JpaRepository { + List findAllByUserId(Integer userId); + + Optional findByIdAndUserId(int documentId, int userId); + Optional findFirstByTitleAndUserId(String title, int userId); -public interface DocumentRepository extends JpaRepository{ - + List findAllByIdIn(List ids); + List findAllByUserIdAndIdIn(Integer userId, List ids); } 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/repository/NoteRepository.java b/src/main/java/com/be08/smart_notes/repository/NoteRepository.java deleted file mode 100644 index bc7343c..0000000 --- a/src/main/java/com/be08/smart_notes/repository/NoteRepository.java +++ /dev/null @@ -1,11 +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.entity.NoteEntity; - -@Repository -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..66fdc12 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/repository/QuizRepository.java @@ -0,0 +1,11 @@ +package com.be08.smart_notes.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +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/repository/QuizSetRepository.java b/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java new file mode 100644 index 0000000..069fc13 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/repository/QuizSetRepository.java @@ -0,0 +1,16 @@ +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.List; +import java.util.Optional; + +public interface QuizSetRepository extends JpaRepository { + Optional findByUserIdAndOriginType(int userID, OriginType originType); + Optional findByIdAndUserId(int quizSetId, int userId); + + List findAllByUserId(int userId); + void deleteAllByUserId(int userId); +} 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..c3cd65e 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,12 @@ 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 { +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/AttemptService.java b/src/main/java/com/be08/smart_notes/service/AttemptService.java new file mode 100644 index 0000000..2b255a0 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/AttemptService.java @@ -0,0 +1,174 @@ +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; + + /** + * 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(); + + 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); + } + + /** + * 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(); + + Attempt attempt = getAttemptEntityByIdAndQuizId(quizId, attemptId, currentUserId); + + 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(); + + List attempts = attemptRepository.findByQuizIdAndQuiz_QuizSet_UserId(quizId, currentUserId); + + 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(); + + // 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); + } + + /** + * 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(); + + 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); + } + + /** + * 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(); + + Attempt attempt = getAttemptEntityByIdAndQuizId(quizId, attemptId, currentUserId); + + attemptRepository.delete(attempt); + } + + // ------ 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); + 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/AuthenticationService.java b/src/main/java/com/be08/smart_notes/service/AuthenticationService.java new file mode 100644 index 0000000..9e0f67a --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/AuthenticationService.java @@ -0,0 +1,128 @@ +package com.be08.smart_notes.service; + +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.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.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; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) +@Slf4j +public class AuthenticationService { + private final UserRepository userRepository; + 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}") + private String signingKeyAlias; + + /** + * Authenticate user and generate JWT tokens + * @param request + * @return AuthenticationResponse containing access and refresh tokens + */ + public AuthenticationResponse login(LoginRequest request) { + String email = request.getEmail(); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> { + log.warn("Login Failed: User with email {} not found", email); + return new AppException(ErrorCode.UNAUTHENTICATED); + }); + + 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); + } + + // 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; + + Jwt jwt; + + try { + // Decode and validate the refresh token + 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()); + } 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); + + // 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/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 1f57c81..0c30307 100644 --- a/src/main/java/com/be08/smart_notes/service/DocumentService.java +++ b/src/main/java/com/be08/smart_notes/service/DocumentService.java @@ -2,19 +2,72 @@ 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.entity.DocumentEntity; +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; - - public List getAllDocuments() { - List documentList = documentRepository.findAll(); - return documentList; + AuthorizationService authorizationService; + DocumentRepository documentRepository; + + private static final String SYSTEM_SOURCE_TITLE = "__SYSTEM_UNFILED_SOURCE__"; + + public List getAllDocuments() { + // Get current user + int currentUserId = authorizationService.getCurrentUserId(); + + return documentRepository.findAllByUserId(currentUserId); + } + + public void deleteDocument(int id) { + // Get current user + int currentUserId = authorizationService.getCurrentUserId(); + + // Get document + 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); + }); + + // 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.findFirstByTitleAndUserId(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/JwtService.java b/src/main/java/com/be08/smart_notes/service/JwtService.java new file mode 100644 index 0000000..bfc92a7 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/JwtService.java @@ -0,0 +1,90 @@ +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; +import java.util.UUID; + +@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(); + + // 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(); + + // 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/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/java/com/be08/smart_notes/service/NoteService.java b/src/main/java/com/be08/smart_notes/service/NoteService.java index a508769..2f1b243 100644 --- a/src/main/java/com/be08/smart_notes/service/NoteService.java +++ b/src/main/java/com/be08/smart_notes/service/NoteService.java @@ -1,23 +1,107 @@ package com.be08.smart_notes.service; -import org.springframework.beans.factory.annotation.Autowired; +import java.time.LocalDateTime; +import java.util.List; + +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.entity.NoteEntity; -import com.be08.smart_notes.repository.NoteRepository; +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; @Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@Slf4j public class NoteService { - @Autowired - private NoteRepository noteRepository; - - public NoteEntity getNote(long id) { - NoteEntity note = noteRepository.findById(id).orElse(null);; - return note; + AuthorizationService authorizationService; + DocumentRepository documentRepository; + DocumentMapper documentMapper; + + public NoteResponse getNote(int noteId) { + // Get current user id + int currentUserId = authorizationService.getCurrentUserId(); + + // Get note + 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); + }); + + return documentMapper.toNoteResponse(note); + } + + public NoteResponse createNote(NoteUpsertRequest newData) { + // Get current user id + int currentUserId = authorizationService.getCurrentUserId(); + + // Create new note + Document newNote = Document.builder() + .userId(currentUserId) + .title(newData.getTitle()) + .content(newData.getContent()) + .type(DocumentType.NOTE) + .build(); + + Document savedNote = documentRepository.save(newNote); + return documentMapper.toNoteResponse(savedNote); + } + + 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(); + + // Get note + 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); + }); + + note.setTitle(updateData.getTitle()); + note.setUpdatedAt(LocalDateTime.now());; + note.setContent(updateData.getContent()); + Document updatedNote = documentRepository.save(note); + return documentMapper.toNoteResponse(updatedNote); } - public NoteEntity createNewNote(NoteEntity newNote) { - NoteEntity note = noteRepository.save(newNote); - return note; + public void deleteNote(int noteId) { + // Get current user id + int currentUserId = authorizationService.getCurrentUserId(); + + // Get document + 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); + }); + + documentRepository.deleteById(noteId); } + + // ------ Methods that returns entities ------ // + public List getAllNotesByUserIdAndIds(int userId, List noteIds) { + return documentRepository.findAllByUserIdAndIdIn(userId, 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 new file mode 100644 index 0000000..3183856 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/QuizService.java @@ -0,0 +1,111 @@ +package com.be08.smart_notes.service; + +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; +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; +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 QuizService { + AuthorizationService authorizationService; + QuizSetService quizSetService; + 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(); + + Integer quizSetId = quizUpsertDTO.getQuizSetId(); + QuizSet existingQuizSet = null; + if (quizSetId != null) { + existingQuizSet = quizSetService.getQuizSetEntityById(quizSetId, currentUserId); + } + if (existingQuizSet == null) { + existingQuizSet = quizSetService.getOrCreateDefaultSet(currentUserId); + } + + Quiz newQuiz = quizMapper.toQuiz(quizUpsertDTO); + 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(); + + Quiz quiz = getQuizById(quizId, currentUserId); + + 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(); + + 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); + }); + + if (quizUpsertDTO.getQuizSetId() != null) { + QuizSet quizSet = quizSetService.getQuizSetEntityById(quizUpsertDTO.getQuizSetId(), currentUserId); + existingQuiz.setQuizSet(quizSet); + } + + quizMapper.updateQuiz(existingQuiz, quizUpsertDTO); + existingQuiz = quizRepository.save(existingQuiz); + 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(); + + 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); + }); + + 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); + }); + } +} 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..71128d4 --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/QuizSetService.java @@ -0,0 +1,180 @@ +package com.be08.smart_notes.service; + +import com.be08.smart_notes.common.AppConstants; +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; +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; + +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@Slf4j +@Transactional +public class QuizSetService { + AuthorizationService authorizationService; + QuizSetRepository quizSetRepository; + QuizMapper quizMapper; + QuizSetMapper quizSetMapper; + + /** + * 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 createQuizSet(QuizSetUpsertRequest request, OriginType originType) { + int currentUserId = authorizationService.getCurrentUserId(); + + 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 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 quizUpsertDTOList list of quizzes to be added together with new set + * @return response dto for saved quiz set + */ + public QuizSetResponse createQuizSet(String quizSetTitle, List quizUpsertDTOList, OriginType originType) { + int currentUserId = authorizationService.getCurrentUserId(); + + List newQuizzes = quizMapper.toQuizList(quizUpsertDTOList); + 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 quizSetMapper.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) { + 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); + }); + + return quizSetMapper.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 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 + * @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); + }); + + quizSetMapper.updateQuizSet(existingQuizSet, request); + existingQuizSet = quizSetRepository.save(existingQuizSet); + return quizSetMapper.toQuizSetResponse(existingQuizSet); + } + + /** + * Delete a QuizSet using given id + * @param quizSetId id of target quiz set + */ + public void deleteQuizSetById(int quizSetId) { + 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); + }); + + quizSetRepository.deleteById(quizSetId); + } + + /** + * Delete all QuizSet own by current user, together with its quizzes and questions + */ + public void deleteAllQuizSet() { + int currentUserId = authorizationService.getCurrentUserId(); + quizSetRepository.deleteAllByUserId(currentUserId); + } + + // ------ Methods that returns entities ------ // + /** + * 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(() -> { + QuizSet defaultSet = QuizSet.builder() + .userId(userId) + .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 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); + }); + + return quizSet; + } +} 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..c1b126d --- /dev/null +++ b/src/main/java/com/be08/smart_notes/service/UserService.java @@ -0,0 +1,83 @@ +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; +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; + +@Service +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@Slf4j +public class UserService { + UserRepository userRepository; + UserMapper userMapper; + + PasswordEncoder passwordEncoder; + + public UserResponse 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); + } + + 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); + } + + /** + * 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; + } +} 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..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 @@ -1,81 +1,87 @@ package com.be08.smart_notes.service.ai; -import java.io.IOException; -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 org.springframework.beans.factory.annotation.Value; +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.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 { - @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."); - System.out.println("Please check your .env file and try again."); - return false; - } - 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.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 (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()); - e.printStackTrace(); - } - return response; + AIInferenceProperties properties; + RestClient restClient; + + public AIService(AIInferenceProperties properties, RestClient restClient) { + this.properties = properties; + 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"); + throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); + } + + // Create inference JSON body + AIInferenceRequest info = AIInferenceRequest.builder() + .model(properties.getModel()) + .temperature(properties.getTemperature()) + .topP(properties.getTopP()) + .guidedJson(guidedSchema) + .messages(new AIInferenceRequest.RequestMessage[] { + new AIInferenceRequest.RequestMessage(properties.getSystemRole(), systemPrompt), + new AIInferenceRequest.RequestMessage(properties.getUserRole(), noteContent) + }) + .build(); + + // Fetch and return response from AI API + AIInferenceResponse inferenceResponse = fetchResponseFromInferenceProvider(info); + 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) + .retrieve() + .body(AIInferenceResponse.class); + return response; } + + // 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); +// +// 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 91de423..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 @@ -4,93 +4,190 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; +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; +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; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -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; @Service -public class QuizGenerationService extends AIService { - @Autowired - private NoteService noteService; - +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@Slf4j +public class QuizGenerationService { + String systemPrompt; + String quizResponseSchema; + + AIService aiService; + NoteService noteService; + QuizService quizService; + QuizSetService quizSetService; + AuthorizationService authorizationService; + 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; + try { + prompt = Files.readString( + Path.of(AppConstants.SYSTEM_PROMPT_TEMPLATE_PATH), + StandardCharsets.UTF_8 + ); + schema = Files.readString( + Path.of(AppConstants.QUIZ_RESPONSE_SCHEMA_PATH), + StandardCharsets.UTF_8 + ); + } catch (IOException e) { + log.error("Load resource for quiz generation failed with error: {}", e.getMessage()); + } + + systemPrompt = prompt; + 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 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"; - + // 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(); - 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; - } - - public QuizResponse generateQuizFromNote(long noteId) { - checkPermission(); - - NoteEntity selectedNote = noteService.getNote(noteId); - 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; - - // 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; + ObjectMapper objectMapper = new ObjectMapper(); 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); + QuizUpsertDTO quizUpsertDTO = objectMapper.readValue(generatedContent, QuizUpsertDTO.class); + + Quiz sampleQuizEntity = quizMapper.toQuiz(quizUpsertDTO); + return quizMapper.toQuizResponse(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 quizResponse; } + + /** + * 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."); + throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); + } + + // Extract required data from request + int totalQuestions = quizGenerationRequest.getSizeOfEachQuiz(); + String prompt = String.format(this.systemPrompt, totalQuestions); + int noteId = quizGenerationRequest.getDocId(); + + NoteResponse selectedNote = noteService.getNote(noteId); + QuizUpsertDTO quizUpsertDTO = generateQuizFromNote(selectedNote.getContent(), prompt); + + quizUpsertDTO.setSourceDocumentId(noteId); + 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."); + 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 quizUpsertDTOList = new ArrayList<>(); + for (Document note : noteList) { + try { + authorizationService.validateOwnership(note.getUserId()); + + 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 (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, quizUpsertDTOList, OriginType.AI); + } + + // ------ 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, + noteContent, + quizResponseSchema + ); + 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(); + QuizUpsertDTO quizUpsertDTO = null; + try { + quizUpsertDTO = objectMapper.readValue(generatedContent, QuizUpsertDTO.class); + } catch (Exception e) { + log.error("Could not map generated content to QuizUpsertDTO."); + throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); + } + if (quizUpsertDTO == null) { + log.error("Could not generate quiz because of invalid object mapping result."); + throw new AppException(ErrorCode.FAILED_INFERENCE_REQUEST); + } + return quizUpsertDTO; + } } 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/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 { + +} 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 { +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..0f327b6 --- /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.driver-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 8c3da34..0dbde87 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,11 +4,28 @@ 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 \ No newline at end of file +# Active Profile +spring.profiles.active=dev + +# 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=${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 \ No newline at end of file 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 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 diff --git a/src/main/resources/keys/jwt-keystore.jks b/src/main/resources/keys/jwt-keystore.jks new file mode 100644 index 0000000..0d32d14 Binary files /dev/null and b/src/main/resources/keys/jwt-keystore.jks differ diff --git a/src/main/resources/prompts/system_prompt.txt b/src/main/resources/prompts/system_prompt.txt index 37a3dcb..92ed37c 100644 --- a/src/main/resources/prompts/system_prompt.txt +++ b/src/main/resources/prompts/system_prompt.txt @@ -1,6 +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** @@ -10,5 +9,8 @@ Format your response as follows: **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 +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_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/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 342f6c1..954aa9c 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", @@ -16,10 +16,14 @@ "type": "array", "items": { "type": "string" - } + }, + "minLength": 4, + "maxLength": 4 }, - "correctIndex": { - "type": "integer" + "correct_index": { + "type": "number", + "minimum": 0, + "maximum": 3 } } } @@ -27,6 +31,6 @@ }, "required": [ "topic", - "quizzes" + "questions" ] } \ No newline at end of file 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 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 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..fec700e --- /dev/null +++ b/src/test/java/com/be08/smart_notes/helper/DocumentDataBuilder.java @@ -0,0 +1,36 @@ +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 java.time.LocalDateTime; + +public class DocumentDataBuilder { + /** + * 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") + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()); + } + + /** + * 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()) + .updatedAt(note.getUpdatedAt()); + } +} 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/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/BaseIntegration.java b/src/test/java/com/be08/smart_notes/integration/controller/BaseIntegration.java new file mode 100644 index 0000000..6f3eea2 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/integration/controller/BaseIntegration.java @@ -0,0 +1,50 @@ +package com.be08.smart_notes.integration.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.utility.DockerImageName; + +@Getter +public class BaseIntegration { + private static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.0.36") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test") + .withInitScript("init-db.sql") + .withReuse(true); + + private 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", () -> "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/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java new file mode 100644 index 0000000..6810a23 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/integration/controller/NoteControllerIntegrationTest.java @@ -0,0 +1,335 @@ +package com.be08.smart_notes.integration.controller; + +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; +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 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.Standard.class) +@DisplayName("Note Controller Integration Test") +@Tag("note") +public class NoteControllerIntegrationTest extends BaseIntegration { + @Autowired + MockMvc mockMvc; + @Autowired + ObjectMapper objectMapper; + @Autowired + DocumentRepository documentRepository; + @Autowired + UserRepository userRepository; + + @MockitoBean + JwtEncoder jwtEncoder; + @MockitoBean + JWKSource jwkSource; + @MockitoBean + KeyPair signingKeyPair; + + 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(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.createMockNote(TEST_USER_ID).build()); + anotherNote = documentRepository.save(DocumentDataBuilder.createMockNote(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/integration/repository/DocumentRepositoryTest.java b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java new file mode 100644 index 0000000..04014cf --- /dev/null +++ b/src/test/java/com/be08/smart_notes/integration/repository/DocumentRepositoryTest.java @@ -0,0 +1,386 @@ +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; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@DisplayNameGeneration(DisplayNameGenerator.Standard.class) +@DisplayName("Document Repository Test") +@Tag("document") +@Tag("note") +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.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 --- // + @Nested + @DisplayName("findAllByUserId(): List") + class FindAllByUserIdTest { + @Test + void shouldReturnAllDocumentsWhenUserHasDocuments() { + // 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 shouldReturnEmptyListWhenUserHasNoDocuments() { + // Arrange + int nonExistentUserId = 999; + + // Act + List result = documentRepository.findAllByUserId(nonExistentUserId); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void shouldNotReturnOtherUsersDocuments() { + // Act + List result = documentRepository.findAllByUserId(FIRST_USER_ID); + + // Assert + assertNotNull(result); + assertFalse(result.stream().anyMatch(doc -> doc.getUserId() == SECOND_USER_ID)); + } + } + + // --- findByIdAndUserId --- // + @Nested + @DisplayName("findByIdAndUserId(): Optional") + class FindByIdAndUserIdTest { + @Test + void shouldReturnDocumentWhenDocumentExists() { + // 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 shouldReturnEmptyWhenDocumentNotExists() { + // Arrange + int nonExistentId = 999; + + // Act + Optional result = documentRepository.findByIdAndUserId(nonExistentId, FIRST_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void shouldReturnEmptyWhenDocumentExistsButWrongUser() { + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), SECOND_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void shouldReturnEmptyWhenUserNotExists() { + // Arrange + int nonExistentUserId = 999; + + // Act + Optional result = documentRepository.findByIdAndUserId(document1.getId(), nonExistentUserId); + + // Assert + assertFalse(result.isPresent()); + } + } + + // --- findFirstByTitleAndUserId --- // + @Nested + @DisplayName("findFirstByTitleAndUserId(): Optional") + class FindFirstByTitleAndUserIdTest { + @Test + void shouldReturnDocumentWhenDocumentExists() { + // 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 shouldReturnEmptyWhenTitleNotExists() { + // Arrange + String nonExistentTitle = "Non Existent Title"; + + // Act + Optional result = documentRepository.findFirstByTitleAndUserId(nonExistentTitle, FIRST_USER_ID); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + void shouldReturnOnlyRequestedUserWhenMultipleUsersHaveSameTitle() { + // Arrange + String sharedTitle = "Shared Title"; + Document doc1 = documentRepository.save( + DocumentDataBuilder.createMockNote(FIRST_USER_ID).title(sharedTitle).build() + ); + Document doc2 = documentRepository.save( + DocumentDataBuilder.createMockNote(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 --- // + @Nested + @DisplayName("findAllByIdIn(): List") + class FindAllByIdInTest { + @Test + void shouldReturnAllDocumentsWhenAllIdsExist() { + // 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 shouldReturnOnlyExistingDocumentsWhenSomeIdsNotExist() { + // 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 shouldReturnEmptyListWhenNoIdsExist() { + // Arrange + List ids = Arrays.asList(999, 998, 997); + + // Act + List result = documentRepository.findAllByIdIn(ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void shouldReturnEmptyListWhenGivenEmptyIdList() { + // Arrange + List emptyIds = Collections.emptyList(); + + // Act + List result = documentRepository.findAllByIdIn(emptyIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void 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 shouldReturnSingleDocumentWhenGivenSingleId() { + // 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 --- // + @Nested + @DisplayName("findAllByUserAndIdIn(): List") + class FindAllByUserIdAndIdIn { + @Test + void shouldReturnAllDocumentsWhenAllIdsExistForUser() { + // 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 shouldReturnOnlyExistingWhenSomeIdsNotExistForUser() { + // 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 shouldReturnEmptyListWhenNoIdsExistForUser() { + // Arrange + List ids = Arrays.asList(999, 998); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, ids); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void shouldReturnEmptyListWhenGivenEmptyIdList() { + // Arrange + List emptyIds = Collections.emptyList(); + + // Act + List result = documentRepository.findAllByUserIdAndIdIn(FIRST_USER_ID, emptyIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void shouldReturnEmptyListWhenDocumentsBelongToAnotherUser() { + // 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 shouldReturnOnlyRequestedUserDocumentsWhenGivenMixedUserDocuments() { + // 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 shouldReturnSingleDocumentWhenGivenSingleId() { + // 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 shouldReturnEmptyListWhenUserNotExists() { + // 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/unit/service/NoteServiceTest.java b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java new file mode 100644 index 0000000..532ce38 --- /dev/null +++ b/src/test/java/com/be08/smart_notes/unit/service/NoteServiceTest.java @@ -0,0 +1,413 @@ +package com.be08.smart_notes.unit.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; +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; +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; +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) +@DisplayNameGeneration(DisplayNameGenerator.Standard.class) +@DisplayName("Note Service Test") +@Tag("note") +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() { + int userId = 100; + 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()); + } + + // --- Get note --- // + @Nested + @DisplayName("getNote(): NoteResponse") + class GetNoteTest { + @Test + void shouldThrowExceptionWhenGivenNonExistentId() { + // 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 shouldReturnNoteResponseWhenNoteExists() { + // 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 -- // + @Nested + @DisplayName("getAllNotes(): List") + class GetAllNotesTest { + @Test + void shouldReturnEmptyNoteResponseListWhenListIsEmpty() { + // 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 shouldReturnNoteResponseListWhenListIsNotEmpty() { + // 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 shouldReturnSingleNoteResponseListWhenGivenSingleNote() { + // 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 --- // + @Nested + @DisplayName("createNote(): NoteResponse") + class CreateNoteTest { + @Test + void shouldCreateSuccessfullyWhenGivenValidInput() { + // 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 --- // + @Nested + @DisplayName("updateNote(): NoteResponse") + class UpdateNoteTest { + @Test + void shouldUpdateSuccessfullyWhenNoteExists() { + // 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 shouldThrowExceptionWhenNoteNotFound() { + // 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 --- // + @Nested + @DisplayName("deleteNote(): void") + class DeleteNoteTest { + @Test + void shouldDeleteSuccessfullyWhenNoteExists() { + // 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 shouldThrowExceptionWhenNoteNotFound() { + // 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 ------ // + @Nested + @DisplayName("getAllNotesByUserIdAndIds(): List") + class GetAllNotesByUserIdAndIdsTest { + @Test + void shouldReturnMatchingNotesWhenNotesExist() { + // 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 shouldReturnEmptyListWhenNoMatches() { + // 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 shouldReturnEmptyListWhenGivenEmptyIdList() { + // 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 shouldReturnSingleNoteWhenGivenSingleId() { + // 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 shouldReturnOnlyMatchingNotesWhenGivenPartialMatchedInputs() { + // 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); + } + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..fa3b89d --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,29 @@ +## REDIS Configuration +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=placeholder-url +ai.api.token=placeholder-token +ai.api.model=placeholder-model + +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 +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..4bdab8c --- /dev/null +++ b/src/test/resources/init-db.sql @@ -0,0 +1,154 @@ +CREATE DATABASE IF NOT EXISTS `testdb`; +USE `testdb`; + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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)))) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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`) +); + +CREATE TABLE IF NOT EXISTS `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 diff --git a/target/classes/META-INF/MANIFEST.MF b/target/classes/META-INF/MANIFEST.MF deleted file mode 100644 index 4184143..0000000 --- a/target/classes/META-INF/MANIFEST.MF +++ /dev/null @@ -1,6 +0,0 @@ -Manifest-Version: 1.0 -Build-Jdk-Spec: 23 -Implementation-Title: smart-notes -Implementation-Version: 0.0.1-SNAPSHOT -Created-By: Maven Integration for Eclipse -