diff --git a/README.md b/README.md index 4f51dba..c7d6be5 100644 --- a/README.md +++ b/README.md @@ -35,4 +35,15 @@ sudo reboot ```bash docker compose up ``` -- Visit localhost:4200 to play the quiz! \ No newline at end of file +- Visit localhost:4200 to play the quiz! + + +## Benchmarking +- Go to the /scripts folder and run the LoadTestMultiple.sh script +- Inside you can change how many sessions should be created (set to 6 by default) +- In the /scripts/benchmarks folder you can find some benchmarks I've done + +## Notes +- If the data is not present, it will try to query it from the external API, which takes significantly longer (only 1 request per 5 seconds) +- When running it locally, give the backend a few minutes to gather data in bulk (should be completely done in ~7 minutes) +- It refreshes this data every night in the background, so it stays up to date \ No newline at end of file diff --git a/Reflection.md b/Reflection.md new file mode 100644 index 0000000..2e902ef --- /dev/null +++ b/Reflection.md @@ -0,0 +1,38 @@ +# Issues + +## Issues during testing +### Scheduler +I don't know if it was the scheduler specifically, but whenever I try to test it, both the cache and the queue seem to become empty after the first function is completed. +I don't know how to solve this yet + + +## Caching solution +### Lack of data +Not being able to gather blocks of 50 is a huge issue. The idea of reusing blocks becomes less viable if I can just get a big chunk every time for others to use +I can't force a big chunk, because it might just return a status 1, and then the user gets nothing. + +Perhaps I can separate the questions myself and make a collection, that i can build the requests from. However, for every quest tracking if someone has already seen is a huge pain. + + +## Possible solution +Cache all data, 50 at a time +Store it in a database +When its done, let the db make blocks for quick cache access + +Then randomize when someone asks for an item from the cache +Or return a failure if there are not enough items (a la the api itself) + +Every night, create a new db in the background and start filling it (should take ~34 minutes for all 20k items the external API has) +When it's full, replace the old database with the new one, and delete the old one. +Then start replacing the cache items one at a time. + +Users shouldn't feel this at all, because the active sessions are stored in their own cache items + +# Progress so far +There only seem to be ~4100 items, making the collecting phase only last around 7 minutes. + +Swapping dbs is replaced with swapping tables, seems to work just as fine, yet much simpler to set up + +TODO: Add a caching state, and start filling up each unique combination with the items from the db +This should lead to performance gains + diff --git a/backend/.devcontainer/Dockerfile b/backend/.devcontainer/Dockerfile deleted file mode 100644 index deef895..0000000 --- a/backend/.devcontainer/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -# Use an official OpenJDK as a parent image -FROM openjdk:17-jdk-slim - -# Set the working directory -WORKDIR /app - -# Install Maven -RUN apt-get update && apt-get install -y \ - maven=3.3.9-4 \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -RUN mvn clean package - -CMD ["tail","-f","/dev/null"] \ No newline at end of file diff --git a/backend/.devcontainer/devcontainer.json b/backend/.devcontainer/devcontainer.json index 3075d71..22891ae 100644 --- a/backend/.devcontainer/devcontainer.json +++ b/backend/.devcontainer/devcontainer.json @@ -2,13 +2,19 @@ "name": "Quiz Backend", "build": { - "context": ".", - "dockerfile": "Dockerfile", + "context": "..", + "dockerfile": "../Dockerfile", "args": { "WORKDIR": "/app" - } + // "UID": "${localEnv:USER_UID}", + // "GID": "${localEnv:USER_GID}" + }, + "target": "development" }, - "runArgs": ["--name=quiz-backend-dev-cont"], + "runArgs": [ + "--name=quiz-backend-dev-cont", + "--net=host" // Allows it to be opened on the host network + ], "workspaceFolder": "/app", "mounts": [ "source=.,target=/app,type=bind" @@ -17,6 +23,9 @@ "vscode": { "settings": {}, "extensions": [ + "vscjava.vscode-java-pack", + "Oracle.oracle-java", + "scala-lang.scala" ] } } diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..14463aa --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,34 @@ +FROM openjdk:17-jdk-slim AS base +WORKDIR /app +RUN apt-get update && apt install -y \ + maven \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +#---Development +FROM base AS development +# Switching user so it doesn't use root +# If root adds files, it takes ownership, making them unremovable on the host except by using sudo +# node tranfers ownership to the host user +ENV UID=1000 +ENV GID=1000 +ENV USER=dev +RUN groupadd -g ${GID} ${USER} \ + && useradd -m -u ${UID} -g ${USER} ${USER} +USER ${USER} +EXPOSE 9090 +CMD ["tail","-f","/dev/null"] + +#---Building +FROM base AS build +COPY . . +RUN mvn clean package +# Add -DskipTests for slightly faster build, but no testing + +#---Production +# Pulled again to get rid of maven, to decrease the size +FROM openjdk:17-jdk-slim AS production +WORKDIR /app +COPY --from=build /app/target/*.jar app.jar +EXPOSE 9090 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/backend/pom.xml b/backend/pom.xml index 80df2a1..2de204e 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -28,6 +28,8 @@ 17 + 1.18.34 + 3.1.8 @@ -55,6 +57,26 @@ spring-boot-starter-test test + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + + + org.projectlombok + lombok + ${lombok.version} + provided + + + org.springframework.boot + spring-boot-starter-validation + + + org.apache.commons + commons-text + 1.10.0 + diff --git a/backend/src/main/java/com/ninjaws/quiz/QuizApplication.java b/backend/src/main/java/com/ninjaws/quiz/QuizApplication.java index 722a567..809b4e1 100644 --- a/backend/src/main/java/com/ninjaws/quiz/QuizApplication.java +++ b/backend/src/main/java/com/ninjaws/quiz/QuizApplication.java @@ -2,8 +2,10 @@ package com.ninjaws.quiz; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class QuizApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/ninjaws/quiz/components/StartupRunner.java b/backend/src/main/java/com/ninjaws/quiz/components/StartupRunner.java new file mode 100644 index 0000000..129f975 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/components/StartupRunner.java @@ -0,0 +1,40 @@ +package com.ninjaws.quiz.components; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import com.ninjaws.quiz.services.ApiService; +import com.ninjaws.quiz.services.StateManagementService; + + +@Component +public class StartupRunner implements CommandLineRunner { + + @Autowired + private StateManagementService stateManagementService; + @Autowired + private ApiService apiService; + + @Override + public void run(String... args) { + + /** + * On startup: + * - Set ProcessingState to Startup + * - Store the categories + * - Get a sessiontoken + * + * * Inside the SchedulerService + * - Start filling the question table + * + * * Inside the Response Handling Service + * - No more questions? Stop + * - Set ProcessingState to Normal + */ + + stateManagementService.setCurrentProcessingState(StateManagementService.ProcessingState.STARTUP); + apiService.getCategories(); + apiService.getSessionToken(); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/config/AppConfig.java b/backend/src/main/java/com/ninjaws/quiz/config/AppConfig.java new file mode 100644 index 0000000..eef023d --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/config/AppConfig.java @@ -0,0 +1,14 @@ +package com.ninjaws.quiz.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class AppConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/config/WebConfig.java b/backend/src/main/java/com/ninjaws/quiz/config/WebConfig.java new file mode 100644 index 0000000..691039f --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/config/WebConfig.java @@ -0,0 +1,20 @@ +package com.ninjaws.quiz.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@EnableWebMvc +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") // Allow all endpoints + .allowedOrigins("http://localhost:4200") // Allow specific origin + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // Allow specific methods + .allowedHeaders("*") // Allow all headers + .allowCredentials(true); // Allow credentials + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java new file mode 100644 index 0000000..7d7d114 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java @@ -0,0 +1,82 @@ +package com.ninjaws.quiz.controllers; + +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.ninjaws.quiz.entities.Category; +import com.ninjaws.quiz.models.Answers; +import com.ninjaws.quiz.models.QuizSettings; +import com.ninjaws.quiz.models.Session; +import com.ninjaws.quiz.services.DataService; +import com.ninjaws.quiz.services.QuizService; + +@RestController +@RequestMapping("/quiz") +public class QuizController { + + @Autowired + private QuizService quizService; + @Autowired + private DataService dataService; + + + /** + * Start a session + * @param amount The amount of questsions + * @param category The desired category (or all) + * @param difficulty The desired difficulty (or all) + * @param type The desired type (or both) + * @return The sessionId, or statusCode 5 when the queue is full + */ + @GetMapping("/questions") + public String startSession( + @RequestParam(required = true) Integer amount, + @RequestParam(required = false) Integer category, + @RequestParam(required = false) String difficulty, + @RequestParam(required = false) String type + ) { + QuizSettings quizSettings = new QuizSettings(amount, category, difficulty, type); + Optional result = quizService.createSession(quizSettings); + if (result.isPresent()) { + String jsonString = String.format("{\"statusCode\":0,\"sessionId\":\"%s\"}", result.get()); + return jsonString; + } + String jsonString = String.format("{\"statusCode\":5}"); + return jsonString; + } + + /** + * Checks if the questions have been retrieved + * @param sessionId The session that holds the questions + * @return The Session, or nothing if they are not ready yet + */ + @GetMapping("/status/{sessionId}") + public Optional checkStatus(@PathVariable String sessionId) { + return quizService.checkSession(sessionId); + } + + /** + * Receive the chosen answers, calculate the score, and return it + * @param answers The chosen answers + * @return The score + */ + @PostMapping("/score") + public String checkAnswers(@RequestBody Answers answers) { + String jsonString = String.format("{\"score\":\"%s\"}", quizService.getScore(answers)); + return jsonString; + } + + @GetMapping("/categories") + public List getCategories(){ + return dataService.getCategories(); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/entities/BaseQuestionEntity.java b/backend/src/main/java/com/ninjaws/quiz/entities/BaseQuestionEntity.java new file mode 100644 index 0000000..bd4f7a7 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/entities/BaseQuestionEntity.java @@ -0,0 +1,34 @@ +package com.ninjaws.quiz.entities; + +import java.util.List; +import java.util.UUID; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; + +@MappedSuperclass +@Getter +@Setter +public class BaseQuestionEntity { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne + @JoinColumn(name = "category", nullable = false) + private Category category; + + private String difficulty; + private String type; + private String question; + + private String correctAnswer; + // @ElementCollection // This stores it in a separate table, it's just a set of strings so not required right now + private List incorrectAnswers; +} diff --git a/backend/src/main/java/com/ninjaws/quiz/entities/Category.java b/backend/src/main/java/com/ninjaws/quiz/entities/Category.java new file mode 100644 index 0000000..3e20685 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/entities/Category.java @@ -0,0 +1,25 @@ +package com.ninjaws.quiz.entities; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "category") +@Getter +@Setter +public class Category { + @Id + private Integer id; + private String name; + + public Category() {} + + public Category(int id, String name) { + this.id = id; + this.name = name; + } + +} diff --git a/backend/src/main/java/com/ninjaws/quiz/entities/QuestionCollectingEntity.java b/backend/src/main/java/com/ninjaws/quiz/entities/QuestionCollectingEntity.java new file mode 100644 index 0000000..bc5370e --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/entities/QuestionCollectingEntity.java @@ -0,0 +1,10 @@ +package com.ninjaws.quiz.entities; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "question_collecting") +public class QuestionCollectingEntity extends BaseQuestionEntity{ + +} diff --git a/backend/src/main/java/com/ninjaws/quiz/entities/QuestionEntity.java b/backend/src/main/java/com/ninjaws/quiz/entities/QuestionEntity.java new file mode 100644 index 0000000..24aece9 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/entities/QuestionEntity.java @@ -0,0 +1,10 @@ +package com.ninjaws.quiz.entities; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "question") +public class QuestionEntity extends BaseQuestionEntity { + +} diff --git a/backend/src/main/java/com/ninjaws/quiz/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/ninjaws/quiz/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..c684ebc --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/exception/GlobalExceptionHandler.java @@ -0,0 +1,20 @@ +package com.ninjaws.quiz.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +/** + * Determines behaviour when certain types of Exceptions are encountered + */ +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(e.getMessage()); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ninjaws/quiz/models/Answers.java b/backend/src/main/java/com/ninjaws/quiz/models/Answers.java new file mode 100644 index 0000000..781cfbd --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/models/Answers.java @@ -0,0 +1,19 @@ +package com.ninjaws.quiz.models; + +import java.util.List; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Answers { + private String sessionId; + private List answers; + + public Answers() {} + + public Answers(String sessionId) { + this.sessionId = sessionId; + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/models/ApiResponse.java b/backend/src/main/java/com/ninjaws/quiz/models/ApiResponse.java new file mode 100644 index 0000000..9715b25 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/models/ApiResponse.java @@ -0,0 +1,21 @@ +package com.ninjaws.quiz.models; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.Setter; + + +/** + * The response sent by the external API + */ +@Getter +@Setter +public class ApiResponse { + @JsonProperty("response_code") + private int responseCode; + @JsonProperty("results") + private List results; +} diff --git a/backend/src/main/java/com/ninjaws/quiz/models/CategoryResponse.java b/backend/src/main/java/com/ninjaws/quiz/models/CategoryResponse.java new file mode 100644 index 0000000..2644a4b --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/models/CategoryResponse.java @@ -0,0 +1,14 @@ +package com.ninjaws.quiz.models; + +import java.util.List; + +import com.ninjaws.quiz.entities.Category; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CategoryResponse { + List trivia_categories; +} diff --git a/backend/src/main/java/com/ninjaws/quiz/models/QuestionDTO.java b/backend/src/main/java/com/ninjaws/quiz/models/QuestionDTO.java new file mode 100644 index 0000000..4127152 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/models/QuestionDTO.java @@ -0,0 +1,55 @@ +package com.ninjaws.quiz.models; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class QuestionDTO { + + private String category; + private String difficulty; + private String type; + private String question; + + //* Hidden from the user */ + private String correctAnswer; + private List incorrectAnswers; + + @JsonProperty("answers") + public List getAnswers() { + List possibleAnswers = new ArrayList<>(incorrectAnswers); + possibleAnswers.add(correctAnswer); + Collections.shuffle(possibleAnswers); + return possibleAnswers; + } + + @JsonProperty("correct_answer") + public void setCorrectAnswer(String correctAnswer) { + this.correctAnswer = correctAnswer; + } + + @JsonProperty("incorrect_answers") + public void setIncorrectAnswer(List incorrectAnswers) { + this.incorrectAnswers = incorrectAnswers; + } + + @JsonProperty("correct_answer") + @JsonIgnore + public String getCorrectAnswer() { + return correctAnswer; + } + + @JsonProperty("incorrect_answers") + @JsonIgnore + public List getIncorrectAnswers() { + return incorrectAnswers; + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java new file mode 100644 index 0000000..4693f8a --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java @@ -0,0 +1,62 @@ +package com.ninjaws.quiz.models; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class QuizSettings { + + @Min(1) + @Max(50) + private int amount = 10; + private Integer category; + private String difficulty; + private String type; + + public QuizSettings(){} + + /** Inverse of toApiQuery */ + public QuizSettings(int amount, Integer category, String difficulty, String type) { + this.amount = amount; + this.category = category; + this.difficulty = difficulty; + this.type = type; + // this.category = category == null ? Category.ANY : Category.values()[category]; + // this.difficulty = difficulty == null ? Difficulty.ANY : Difficulty.valueOf(difficulty.toUpperCase()); + // this.type = type == null ? Type.ANY : type.equals("boolean") ? Type.BOOLEAN : Type.MULTIPLE; + } + + /** + * Ensures the value is between 1 and 50 + * @Valid doesn't seem to be enforcing it, so this ensures the value is legal + */ + public void setAmount(@Valid int amount) { + this.amount = Math.max(1, Math.min(amount, 50)); + } + + /** + * Converts the class to a format that can be provided as arguments for the api request + * @return The resulting request arguments as String + */ + public String toApiQuery() { + StringBuilder query = new StringBuilder("?amount=" + amount); + + if (category != null) { + query.append("&category=").append(category); + } + + if (difficulty != null ) { + query.append("&difficulty=").append(difficulty); + } + + if (type != null ) { + query.append("&type=").append(type); + } + + return query.toString(); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/models/Request.java b/backend/src/main/java/com/ninjaws/quiz/models/Request.java new file mode 100644 index 0000000..d04c3bf --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/models/Request.java @@ -0,0 +1,14 @@ +package com.ninjaws.quiz.models; + +import lombok.Getter; + +@Getter +public class Request { + private final String sessionId; + private final QuizSettings quizSettings; + + public Request(String sessionId, QuizSettings quizSettings) { + this.sessionId = sessionId; + this.quizSettings = quizSettings; + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/models/Session.java b/backend/src/main/java/com/ninjaws/quiz/models/Session.java new file mode 100644 index 0000000..2916d6c --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/models/Session.java @@ -0,0 +1,19 @@ +package com.ninjaws.quiz.models; + +import java.util.List; +import java.util.UUID; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Session { + private final String id; + private int statusCode = 0; + private List questions; + + public Session() { + this.id = UUID.randomUUID().toString(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ninjaws/quiz/models/SessionTokenDTO.java b/backend/src/main/java/com/ninjaws/quiz/models/SessionTokenDTO.java new file mode 100644 index 0000000..193a48e --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/models/SessionTokenDTO.java @@ -0,0 +1,15 @@ +package com.ninjaws.quiz.models; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class SessionTokenDTO { + @JsonProperty("response_code") + private int responseCode; + @JsonProperty("token") + private String token; +} diff --git a/backend/src/main/java/com/ninjaws/quiz/repositories/CategoryRepository.java b/backend/src/main/java/com/ninjaws/quiz/repositories/CategoryRepository.java new file mode 100644 index 0000000..e9bf67d --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/repositories/CategoryRepository.java @@ -0,0 +1,11 @@ +package com.ninjaws.quiz.repositories; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.ninjaws.quiz.entities.Category; + +public interface CategoryRepository extends JpaRepository{ + Optional findByName(String name); +} diff --git a/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionCollectingRepository.java b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionCollectingRepository.java new file mode 100644 index 0000000..ae6214c --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionCollectingRepository.java @@ -0,0 +1,14 @@ +package com.ninjaws.quiz.repositories; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.ninjaws.quiz.entities.QuestionCollectingEntity; + +/** + * This table is used for collecting questions from the external API + * When it finishes, the data from this repository will replace the data in the QuestionRepository, to ensure it stays fresh + * This should be scheduled once every day + */ +public interface QuestionCollectingRepository extends JpaRepository{ + +} diff --git a/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java new file mode 100644 index 0000000..afcc321 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java @@ -0,0 +1,39 @@ +package com.ninjaws.quiz.repositories; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.ninjaws.quiz.entities.QuestionEntity; + +import jakarta.transaction.Transactional; + +public interface QuestionRepository extends JpaRepository{//, JpaSpecificationExecutor { + + // @Query(value = "SELECT * FROM question AS q " + + // "WHERE (?1 IS NULL OR q.category = ?1) " + + // "AND (?2 IS NULL OR q.difficulty = ?2) " + + // "AND (?3 IS NULL OR q.type = ?3) " + + // "ORDER BY RAND() LIMIT ?4", nativeQuery = true) + @Query(value = "SELECT * FROM question AS q " + + "WHERE (:categoryId IS NULL OR q.category = :categoryId) " + + "AND (:difficulty IS NULL OR q.difficulty = :difficulty) " + + "AND (:type IS NULL OR q.type = :type) " + + "ORDER BY RAND() LIMIT :limit;", nativeQuery = true) + List lookupQuestions( + @Param("categoryId") Integer categoryId, + @Param("difficulty") String difficulty, + @Param("type") String type, + @Param("limit") int limit); + + /** + * Moves all data from one table to another + */ + @Modifying + @Transactional + @Query(value="INSERT INTO question SELECT * FROM question_collecting;", nativeQuery=true) + void moveCollection(); +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java new file mode 100644 index 0000000..e4ce492 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java @@ -0,0 +1,110 @@ +package com.ninjaws.quiz.services; + +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import com.ninjaws.quiz.models.ApiResponse; +import com.ninjaws.quiz.models.CategoryResponse; +import com.ninjaws.quiz.models.QuizSettings; +import com.ninjaws.quiz.models.Request; +import com.ninjaws.quiz.models.SessionTokenDTO; + +import jakarta.validation.Valid; + +@Service +public class ApiService { + + @Autowired + private CleanerService cleanerService; + @Autowired + private ResponseHandlingService responseHandler; + @Autowired + private RestTemplate restTemplate; + + private static final System.Logger logger = System.getLogger(ApiService.class.getName()); + + private final String apiUrl = "https://opentdb.com"; + private String sessionToken; + + public ApiService() { + } + + private String buildRequestString(QuizSettings quizSettings) { + return apiUrl + "/api.php" + quizSettings.toApiQuery(); + } + + public void handleRequest(@Valid Request request) { + String url = buildRequestString(request.getQuizSettings()); + + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + String json = response.getBody(); + ApiResponse apiResponse = cleanerService.extractApiResponse(json); + responseHandler.handleResponse(request.getSessionId(), apiResponse); + + } catch (RestClientException | IOException | IllegalStateException e) { + logger.log(System.Logger.Level.ERROR, "Critical failure", e); + } + } + + /** + * Collect a chuck of 50 items + * Store them in the DataService + */ + public void collectBulk() { + String tokenStr = sessionToken == null ? "" : "&token="+sessionToken; + String url = apiUrl + "/api.php?amount=50" + tokenStr; + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + String json = response.getBody(); + ApiResponse apiResponse = cleanerService.extractApiResponse(json); + responseHandler.handleResponse(apiResponse); + + /** + * A bit dirty, but otherwise it causes circular dependencies + * Makes sure the categories are refreshed after finishing to replace the questions, to make sure the ids match + * And resets the sessionToken, it will be remade during the next REFRESH + */ + if(apiResponse.getResponseCode() == 4) { + getCategories(); + sessionToken = null; + } + + } catch (RestClientException | IOException e) { + logger.log(System.Logger.Level.ERROR, "Critical failure", e); + } + + } + + public void getSessionToken() { + String url = apiUrl + "/api_token.php?command=request"; + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + String json = response.getBody(); + SessionTokenDTO tokenDto = cleanerService.extractSessionToken(json); + sessionToken = tokenDto.getToken(); + + } catch (RestClientException | IOException e) { + logger.log(System.Logger.Level.ERROR, "Critical failure", e); + } + } + + public void getCategories() { + String url = apiUrl + "/api_category.php"; + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + String json = response.getBody(); + CategoryResponse categoryResponse = cleanerService.extractCategoryResponse(json); + responseHandler.handleCategoryResponse(categoryResponse); + + } catch (RestClientException | IOException e) { + logger.log(System.Logger.Level.ERROR, "Critical failure", e); + } + + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/CacheService.java b/backend/src/main/java/com/ninjaws/quiz/services/CacheService.java new file mode 100644 index 0000000..fdb754b --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/CacheService.java @@ -0,0 +1,38 @@ +package com.ninjaws.quiz.services; + +import java.util.concurrent.TimeUnit; + +import org.springframework.stereotype.Service; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.ninjaws.quiz.models.Session; + +@Service +public class CacheService { + + /** + * Stores all active sessions + * Gets added to when a user requests data + * Gets removed from when a user requests their score (by sending their answers) + */ + private final Cache sessions; + + public CacheService() { + sessions = (Caffeine.newBuilder() + .expireAfterWrite(1,TimeUnit.HOURS) + .build()); + } + + public void addToCache(String key, Session session) { + sessions.put(key, session); + } + + public Session getFromCache(String sessionId) { + return sessions.getIfPresent(sessionId); + } + + public void removeFromCache(String sessionId) { + sessions.invalidate(sessionId); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java b/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java new file mode 100644 index 0000000..7b26710 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java @@ -0,0 +1,123 @@ +package com.ninjaws.quiz.services; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.commons.lang3.StringEscapeUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ninjaws.quiz.entities.Category; +import com.ninjaws.quiz.entities.QuestionCollectingEntity; +import com.ninjaws.quiz.entities.QuestionEntity; +import com.ninjaws.quiz.models.ApiResponse; +import com.ninjaws.quiz.models.CategoryResponse; +import com.ninjaws.quiz.models.QuestionDTO; +import com.ninjaws.quiz.models.SessionTokenDTO; +import com.ninjaws.quiz.repositories.CategoryRepository; + +/** + * Deals with conversions from one form of data to another + * Json to Object + * Entity to DTO + * etc + */ +@Service +public class CleanerService { + + @Autowired + private ObjectMapper objectMapper; + @Autowired + private CategoryRepository categoryRepository; + + public CleanerService() { + } + + public ApiResponse extractApiResponse(String json) throws IOException { + return objectMapper.readValue(json, ApiResponse.class); + } + + public CategoryResponse extractCategoryResponse(String json) throws IOException { + return objectMapper.readValue(json, CategoryResponse.class); + } + + public List questionListEntitytoDTO(List entities) { + List dtos = new ArrayList<>(); + for (int i = 0; i < entities.size(); i++) { + QuestionEntity entity = entities.get(i); + dtos.add(questionEntityToDTO(entity)); + } + return dtos; + } + + public SessionTokenDTO extractSessionToken(String json) throws IOException { + return objectMapper.readValue(json, SessionTokenDTO.class); + } + + public QuestionDTO questionEntityToDTO(QuestionEntity entity) { + QuestionDTO dto = new QuestionDTO(); + dto.setQuestion(entity.getQuestion()); + dto.setCorrectAnswer(entity.getCorrectAnswer()); + dto.setIncorrectAnswer(entity.getIncorrectAnswers()); + dto.setCategory(entity.getCategory().getName()); + dto.setType(entity.getType()); + dto.setDifficulty(entity.getDifficulty()); + return dto; + } + + public List questionListDTOtoEntity(List dtos) { + List entities = new ArrayList<>(); + for (int i = 0; i < dtos.size(); i++) { + QuestionDTO dto = dtos.get(i); + entities.add(questionDTOtoEntity(dto)); + } + return entities; + } + + public QuestionEntity questionDTOtoEntity(QuestionDTO dto) { + QuestionEntity entity = new QuestionEntity(); + Category category = categoryRepository.findByName(decodeString(dto.getCategory())).orElseThrow(() -> new IllegalStateException("Category not in DB!")); + entity.setCategory(category); + entity.setDifficulty(dto.getDifficulty()); + entity.setType(dto.getType()); + entity.setQuestion(dto.getQuestion()); + entity.setCorrectAnswer(dto.getCorrectAnswer()); + entity.setIncorrectAnswers(dto.getIncorrectAnswers()); + return entity; + } + + + // TODO: Find a simpler way to convert from one table to another if the data is identical so this nonsense isn't necessary + public List questionListDTOtoCollectingEntity(List dtos) { + List entities = new ArrayList<>(); + for (int i = 0; i < dtos.size(); i++) { + QuestionDTO dto = dtos.get(i); + entities.add(questionDTOtoCollectingEntity(dto)); + } + return entities; + } + + public QuestionCollectingEntity questionDTOtoCollectingEntity(QuestionDTO dto) { + QuestionCollectingEntity entity = new QuestionCollectingEntity(); + Category category = categoryRepository.findByName(decodeString(dto.getCategory())).orElseThrow(() -> new IllegalStateException("Category not in DB!")); + entity.setCategory(category); + entity.setDifficulty(dto.getDifficulty()); + entity.setType(dto.getType()); + entity.setQuestion(dto.getQuestion()); + entity.setCorrectAnswer(dto.getCorrectAnswer()); + entity.setIncorrectAnswers(dto.getIncorrectAnswers()); + return entity; + } + + + /** + * For some reason, the categories come with special characters, + * But the categories from the questions dont + * So this has to be manually fixed if they are to be stored in the db (otherwise namematching doesnt work) + */ + private String decodeString(String text){ + return StringEscapeUtils.unescapeHtml4(text); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/DataService.java b/backend/src/main/java/com/ninjaws/quiz/services/DataService.java new file mode 100644 index 0000000..717fdc8 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/DataService.java @@ -0,0 +1,70 @@ +package com.ninjaws.quiz.services; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.ninjaws.quiz.entities.Category; +import com.ninjaws.quiz.entities.QuestionCollectingEntity; +import com.ninjaws.quiz.entities.QuestionEntity; +import com.ninjaws.quiz.models.QuizSettings; +import com.ninjaws.quiz.repositories.CategoryRepository; +import com.ninjaws.quiz.repositories.QuestionCollectingRepository; +import com.ninjaws.quiz.repositories.QuestionRepository; + +import jakarta.transaction.Transactional; + +@Service +public class DataService { + + @Autowired + private QuestionRepository questionRepository; + @Autowired + private QuestionCollectingRepository collectingRepository; + @Autowired + private CategoryRepository categoryRepository; + + public List getAllQuestions() { + return questionRepository.findAll(); + } + + public QuestionEntity saveQuestion(QuestionEntity question) { + return questionRepository.save(question); + } + + public List saveQuestions(List questions) { + return questionRepository.saveAll(questions); + } + + /** + * Tries to get questions that match the specified QuizSettings + * + * @return The questions if the query was possible, and an empty array if not + */ + public List lookupQuestions(QuizSettings quizSettings) { + return questionRepository.lookupQuestions(quizSettings.getCategory(), quizSettings.getDifficulty(), quizSettings.getType(), quizSettings.getAmount()); + } + + public List saveCategories(List categories) { + return categoryRepository.saveAll(categories); + } + + public List getCategories(){ + return categoryRepository.findAll(); + } + /** + * Moving the collected questions to the questions list + * + */ + @Transactional + public void migrateQuestions() { + questionRepository.deleteAll(); + questionRepository.moveCollection(); + collectingRepository.deleteAll(); + } + + public List collectQuestions(List questions) { + return collectingRepository.saveAll(questions); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/QueueService.java b/backend/src/main/java/com/ninjaws/quiz/services/QueueService.java new file mode 100644 index 0000000..4568b69 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/QueueService.java @@ -0,0 +1,31 @@ +package com.ninjaws.quiz.services; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import org.springframework.stereotype.Service; + +import com.ninjaws.quiz.models.Request; + +@Service +public class QueueService { + + /** + * Stores all user requests for data + * Executes them in order, one every 5 seconds (the limit of the external API) + * Removes them from the Queue when the request was succesful + */ + private final BlockingQueue requests = new LinkedBlockingQueue<>(5); + + /** + * Offer automatically doesn't add it to the queue if it is full, so no Exceptions thrown + * @return Whether it's possible to add the request to the queue + */ + public boolean addToQueue(Request request) { + return requests.offer(request); + } + + public Request popFromQueue() { + return requests.poll(); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java new file mode 100644 index 0000000..4bb1484 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java @@ -0,0 +1,123 @@ +package com.ninjaws.quiz.services; + +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.ninjaws.quiz.entities.QuestionEntity; +import com.ninjaws.quiz.models.Answers; +import com.ninjaws.quiz.models.QuizSettings; +import com.ninjaws.quiz.models.Request; +import com.ninjaws.quiz.models.Session; + + +@Service +public class QuizService { + + @Autowired + private CacheService cacheService; + @Autowired + private QueueService queueService; + @Autowired + private DataService dataService; + @Autowired + private CleanerService cleanerService; + @Autowired + private StateManagementService stateManager; + + /** + * Actions: + * - Creates a session with sessionId + * - Adds session to the cache + * - Creates a request and adds it to the queue + * @param quizSettings The values set by the user, determining the kind of quiz they want + * @return The sessionId + */ + public Optional createSession(QuizSettings quizSettings) { + Session session = new Session(); + + /** First, we check if there are already enough items in our db */ + List questionsRetrieved = dataService.lookupQuestions(quizSettings); + /** If the items are not matching the required amount, and the db is not currently being filled up, then they simply don't exist */ + if (questionsRetrieved.size() < quizSettings.getAmount() && stateManager.getCurrentProcessingState() != StateManagementService.ProcessingState.STARTUP) { + session.setStatusCode(1); + cacheService.addToCache(session.getId(), session); + return Optional.of(session.getId()); + } + /** All items are collected in from the database, it doesn't need to be added to the queue */ + if(!questionsRetrieved.isEmpty() && questionsRetrieved.size() == quizSettings.getAmount()) { + session.setQuestions(cleanerService.questionListEntitytoDTO(questionsRetrieved)); + cacheService.addToCache(session.getId(), session); + return Optional.of(session.getId()); + } + + cacheService.addToCache(session.getId(), session); + boolean addedToQueue = queueService.addToQueue(new Request(session.getId(), quizSettings)); + /** In this order, because doing the queue before the cache could lead to race conditions (request completion immediately calls cache, but then the item doesn't exist) */ + if(!addedToQueue) { + cacheService.removeFromCache(session.getId()); + return Optional.empty(); + } + + return Optional.of(session.getId()); + } + + /** + * Actions: + * - Looks up the session in the cache + * - Checks if the data from the request is stored + * @param sessionId + * @return The data if it's available, Optional.empty() if not, and an Exception if the session is invalid + */ + public Optional checkSession(String sessionId) throws IllegalArgumentException { + Session session = cacheService.getFromCache(sessionId); + + if(session == null) { + throw new IllegalArgumentException("Invalid session ID: " + sessionId); + } + + /** Anything other than statuscode 0 doesn't result in active sessions, so remove them from the cache after reporting to the frontend */ + if(session.getStatusCode() != 0) { + cacheService.removeFromCache(session.getId()); + return Optional.of(session); + } + + /** Still waiting for a response */ + if (session.getQuestions() == null) { + return Optional.empty(); + } + + + return Optional.of(session); + } + + /** + * Receives: sessionId and answers + * + * Actions: + * - Retrieves session from cache + * - Deletes session from cache + * - Matches answers answers to correctAnswers + * - Calculates score + * Returns: calculated score + * */ + public int getScore(Answers answers) { + Session session = cacheService.getFromCache(answers.getSessionId()); + cacheService.removeFromCache(session.getId()); + return calculateScore(session, answers); + } + + private int calculateScore(Session session, Answers answers) { + int amountCorrect = 0; + for(int i = 0; i < session.getQuestions().size(); i++) { + String correctAnswer = session.getQuestions().get(i).getCorrectAnswer(); + String userAnswer = answers.getAnswers().get(i); + if (correctAnswer.equalsIgnoreCase(userAnswer)) { + amountCorrect++; + } + } + return amountCorrect; + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java new file mode 100644 index 0000000..4d07c9f --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java @@ -0,0 +1,110 @@ +package com.ninjaws.quiz.services; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.ninjaws.quiz.models.ApiResponse; +import com.ninjaws.quiz.models.CategoryResponse; +import com.ninjaws.quiz.models.Session; + +@Service +public class ResponseHandlingService { + + @Autowired + private StateManagementService stateManager; + @Autowired + private CacheService cacheService; + @Autowired + private DataService dataService; + @Autowired + private CleanerService cleanerService; + + /** + * If there is no sessionId, then this is a request from the scheduler + * purely to stock in the database No Cache call is required + */ + public void handleResponse(String sessionId, ApiResponse response) throws IllegalStateException { + + Session session = cacheService.getFromCache(sessionId); + if (session == null) { + // Critical system failure: Should never occur + throw new IllegalStateException("Session should be defined!"); + } + + switch (response.getResponseCode()) { + case 0: + // Success + updateSessionInCache(session, response); + /** Managing the token gets difficult since there is a timelimit, so simply dont add these, let the bulk collector do it */ + // dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults())); + break; + case 1: + // Not enough questions to satisfy the request. + updateSessionInCache(session, response); + break; + case 2: + // Invalid parameters + updateSessionInCache(session, response); + break; + case 3: + // If it expired. Shouldn't happen since the backup database is full within 40 minutes, and the code lasts for 6 hours + // Return a statuscode 1 to signal an issue, just in case a user hits this situation (should not happen) + session.setStatusCode(1); + updateSessionInCache(session, response); + case 4: + // It returns nothing, the previous batch was the last one + // Return a statuscode 1 to the user + session.setStatusCode(1); + updateSessionInCache(session, response); + break; + case 5: + // Should be prevented by the scheduler + default: + break; + } + } + + /** + * If the request was made by the scheduler during queue-downtime, there is no session + */ + public void handleResponse(ApiResponse response) { + switch (response.getResponseCode()) { + case 0: + if(stateManager.getCurrentProcessingState() == StateManagementService.ProcessingState.STARTUP) { + /** Initial saving during the startup */ + dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults())); + } else if (stateManager.getCurrentProcessingState() == StateManagementService.ProcessingState.REFRESH) { + /** Background collecting during the night */ + dataService.collectQuestions(cleanerService.questionListDTOtoCollectingEntity(response.getResults())); + } + break; + case 4: + // Signal DataService that we have extracted all the data. Stop gathering, + // TODO: Switch to caching-mode + + /** + * If it was in the REFRESH state, move the data from collecting to the used questions + * Otherwise simply switch to the NORMAL state + */ + if (stateManager.getCurrentProcessingState() == StateManagementService.ProcessingState.REFRESH) { + dataService.migrateQuestions(); + } + stateManager.setCurrentProcessingState(StateManagementService.ProcessingState.NORMAL); + break; + case 5: + // Should be prevented by the scheduler + default: + break; + } + } + + private void updateSessionInCache(Session session, ApiResponse response) { + session.setStatusCode(response.getResponseCode()); + session.setQuestions(response.getResults()); + cacheService.addToCache(session.getId(), session); + } + + public void handleCategoryResponse(CategoryResponse categoryResponse) { + dataService.saveCategories(categoryResponse.getTrivia_categories()); + } +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java b/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java new file mode 100644 index 0000000..a8f474a --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java @@ -0,0 +1,49 @@ +package com.ninjaws.quiz.services; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import com.ninjaws.quiz.models.Request; + +@Service +public class SchedulerService { + + @Autowired + private QueueService queueService; + @Autowired + private ApiService apiService; + @Autowired + private StateManagementService stateService; + + public SchedulerService() {} + + /** + * Every 5 seconds: + * - Check if there is a request in queue + * - Yes: Send it to the apiService + * - No: Have the apiService do bulk gathering for the DB + */ + @Scheduled(fixedDelay = 5000) + protected void processQueue() { + Request request = queueService.popFromQueue(); + + if (request != null) { + apiService.handleRequest(request); + } else { + if (stateService.getCurrentProcessingState() == StateManagementService.ProcessingState.STARTUP || stateService.getCurrentProcessingState() == StateManagementService.ProcessingState.REFRESH) { + apiService.collectBulk(); + } + } + } + + /** + * Starts the renewing of the questions, to ensure they stay fresh + */ + @Scheduled(cron = "0 0 1 * * ?") + protected void StartDbRefresh() { + apiService.getSessionToken(); + stateService.setCurrentProcessingState(StateManagementService.ProcessingState.REFRESH); + } + +} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/StateManagementService.java b/backend/src/main/java/com/ninjaws/quiz/services/StateManagementService.java new file mode 100644 index 0000000..b1b8c49 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/StateManagementService.java @@ -0,0 +1,29 @@ +package com.ninjaws.quiz.services; + +import org.springframework.stereotype.Service; + +@Service +public class StateManagementService { + + /** + * STARTUP: When starting the app + * REFRESH: Scheduled at night, so the database stays fresh + * NORMAL: No special activities + */ + public enum ProcessingState { + STARTUP, + REFRESH, + NORMAL + } + + private ProcessingState currentProcessingState; + + public ProcessingState getCurrentProcessingState() { + return currentProcessingState; + } + + public void setCurrentProcessingState(ProcessingState currentProcessingState) { + this.currentProcessingState = currentProcessingState; + } + +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index b3b3a2f..451ac85 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1 +1,34 @@ spring.application.name=quiz +server.port=9090 + +# H2 Database configuration +spring.h2.console.enabled=true +# spring.h2.console.path=/console + + +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect + + +# # Primary database configuration +# spring.datasource.primary.url=jdbc:h2:mem:testdb +# # ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +# spring.datasource.primary.driver-class-name=org.h2.Driver +# spring.datasource.primary.username=sa +# spring.datasource.primary.password= +# spring.jpa.primary.hibernate.ddl-auto=create-drop +# spring.jpa.primary.database-platform=org.hibernate.dialect.H2Dialect + +# # Secondary database configuration +# spring.datasource.secondary.url=jdbc:h2:mem:secondarydb +# # ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +# spring.datasource.secondary.driver-class-name=org.h2.Driver +# spring.datasource.secondary.username=sa +# spring.datasource.secondary.password= +# spring.jpa.secondary.hibernate.ddl-auto=create-drop +# spring.jpa.secondary.database-platform=org.hibernate.dialect.H2Dialect \ No newline at end of file diff --git a/backend/src/main/resources/static/index.html b/backend/src/main/resources/static/index.html new file mode 100644 index 0000000..4b255a9 --- /dev/null +++ b/backend/src/main/resources/static/index.html @@ -0,0 +1 @@ +

Quiz backend

\ No newline at end of file diff --git a/backend/src/test/java/com/ninjaws/quiz/QuizApplicationTests.java b/backend/src/test/java/com/ninjaws/quiz/QuizApplicationTests.java index 5958ed5..bac272a 100644 --- a/backend/src/test/java/com/ninjaws/quiz/QuizApplicationTests.java +++ b/backend/src/test/java/com/ninjaws/quiz/QuizApplicationTests.java @@ -1,13 +1,12 @@ package com.ninjaws.quiz; -import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class QuizApplicationTests { - @Test - void contextLoads() { - } + // @Test + // void contextLoads() { + // } } diff --git a/backend/src/test/java/com/ninjaws/quiz/scala/QuestionStatusSimulation.scala b/backend/src/test/java/com/ninjaws/quiz/scala/QuestionStatusSimulation.scala new file mode 100644 index 0000000..13900dc --- /dev/null +++ b/backend/src/test/java/com/ninjaws/quiz/scala/QuestionStatusSimulation.scala @@ -0,0 +1,22 @@ +package com.ninjaws.quiz.scala; +import io.gatling.core.Predef._ +import io.gatling.http.Predef._ +import scala.concurrent.duration._ + +/** Does not work, use the LoadTestMultiple.sh in the /scripts folder in the root instead */ +class QuestionStatusSimulation extends Simulation { + val httpProtocol = http.baseUrl("http://localhost:9090").acceptHeader("application/json") + + val requestQuestions = exec(http("Request Questions").get("/questions/bla").check(status.is(200)).check(jsonPath("$.id").saveAs("sessionId"))) + + val checkStatus = exec(http("Check Status").get("/status/${sessionId}").check(status.is(200),jsonPath("$.status").notNull)) + + val scn = scenario("Check Questions Status Completion").exec(requestQuestions).pause(1).asLongAsDuring(session => session("completed").asOption[Boolean].getOrElse(false) == false, 30.seconds) { + exec(checkStatus).pause(1).exec{ session => val status = session("status").asOption[String] + session.set("completed", status.isDefined && status.get != "null") + } + } + setUp( + scn.inject(atOnceUsers(10)) + ).protocols(httpProtocol); +} \ No newline at end of file diff --git a/backend/src/test/java/com/ninjaws/quiz/services/CacheServiceTest.java b/backend/src/test/java/com/ninjaws/quiz/services/CacheServiceTest.java new file mode 100644 index 0000000..63570e8 --- /dev/null +++ b/backend/src/test/java/com/ninjaws/quiz/services/CacheServiceTest.java @@ -0,0 +1,59 @@ +package com.ninjaws.quiz.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.ninjaws.quiz.models.Session; + +public class CacheServiceTest { + + private CacheService cacheService; + + @BeforeEach + public void setUp() { + cacheService = new CacheService(); + } + + @Test + public void testAddToCache() { + String key = "session1"; + Session session = new Session(); + + cacheService.addToCache(key, session); + + assertNotNull(cacheService.getFromCache(key)); + assertEquals(session, cacheService.getFromCache(key)); + } + + @Test + public void testGetFromCache() { + String key = "session1"; + Session session = new Session(); + + cacheService.addToCache(key, session); + + Session retrievedSession = cacheService.getFromCache(key); + assertNotNull(retrievedSession); + assertEquals(session, retrievedSession); + } + + @Test + public void testRemoveFromCache() { + String key = "session1"; + Session session = new Session(); + + cacheService.addToCache(key, session); + cacheService.removeFromCache(key); + + assertNull(cacheService.getFromCache(key)); + } + + @Test + public void testGetFromCacheWithNonExistentKey() { + assertNull(cacheService.getFromCache("nonExistentKey")); + } +} diff --git a/backend/src/test/java/com/ninjaws/quiz/services/QueueServiceTest.java b/backend/src/test/java/com/ninjaws/quiz/services/QueueServiceTest.java new file mode 100644 index 0000000..e921c04 --- /dev/null +++ b/backend/src/test/java/com/ninjaws/quiz/services/QueueServiceTest.java @@ -0,0 +1,63 @@ +package com.ninjaws.quiz.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.ninjaws.quiz.models.QuizSettings; +import com.ninjaws.quiz.models.Request; + +public class QueueServiceTest { + + private QueueService queueService; + + @BeforeEach + public void setUp() { + queueService = new QueueService(); + } + + @Test + public void testAddToQueue() { + QuizSettings quizSettings = new QuizSettings(); + Request request = new Request("session1", quizSettings); + + boolean added = queueService.addToQueue(request); + assertTrue(added); + + for (int i = 0; i < 4; i++) { + added = queueService.addToQueue(new Request("session" + (i + 2), new QuizSettings())); + assertTrue(added); + } + + added = queueService.addToQueue(new Request("session6", new QuizSettings())); + assertFalse(added); + } + + @Test + public void testPopFromQueue() { + QuizSettings quizSettings1 = new QuizSettings(); + QuizSettings quizSettings2 = new QuizSettings(); + + Request request1 = new Request("session1", quizSettings1); + Request request2 = new Request("session2", quizSettings2); + + queueService.addToQueue(request1); + queueService.addToQueue(request2); + + Request poppedRequest = queueService.popFromQueue(); + assertEquals(request1, poppedRequest); + + poppedRequest = queueService.popFromQueue(); + assertEquals(request2, poppedRequest); + + assertNull(queueService.popFromQueue()); + } + + @Test + public void testPopFromEmptyQueue() { + assertNull(queueService.popFromQueue()); + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..16dd208 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + target: production + image: quiz-frontend-prod + container_name: quiz-frontend-prod-cont + ports: + - "4200:80" + backend: + build: + context: ./backend + dockerfile: Dockerfile + target: production + image: quiz-backend-prod + container_name: quiz-backend-prod-cont + ports: + - "9090:9090" \ No newline at end of file diff --git a/frontend/.devcontainer/Dockerfile b/frontend/.devcontainer/Dockerfile deleted file mode 100644 index 6b60469..0000000 --- a/frontend/.devcontainer/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM node:22-bookworm-slim - -ARG WORKDIR -WORKDIR ${WORKDIR} - -RUN apt-get update && \ - apt-get install -y git && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* - -# Install Angular CLI globally -RUN npm install -g @angular/cli - -# Switching user so it doesn't use root -# If root adds files, it takes ownership, making them unremovable on the host except by using sudo -# node tranfers ownership to the host user -USER node - -EXPOSE 4200 - -CMD ["tail","-f","/dev/null"] \ No newline at end of file diff --git a/frontend/.devcontainer/devcontainer.json b/frontend/.devcontainer/devcontainer.json index 0ef6dbc..2d77981 100644 --- a/frontend/.devcontainer/devcontainer.json +++ b/frontend/.devcontainer/devcontainer.json @@ -2,11 +2,12 @@ "name": "Quiz Frontend", "build": { - "context": ".", - "dockerfile": "Dockerfile", + "context": "..", + "dockerfile": "../Dockerfile", "args": { - "WORKDIR": "/app" - } + // "WORKDIR": "/app" + }, + "target": "development" }, "runArgs": [ "--name=quiz-frontend-dev-cont", @@ -24,7 +25,10 @@ "Angular.ng-template", "dbaeumer.vscode-eslint", "Zignd.html-css-class-completion", - "esbenp.prettier-vscode" + "esbenp.prettier-vscode", + "bradlc.vscode-tailwindcss", + "ecmel.vscode-html-css", + "pranaygp.vscode-css-peek" ] } } diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..e8a6b1c --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,9 @@ +node_modules/ +.git/ +.gitignore +README.md +LICENSE +.vscode/ +Dockerfile* +docker-compose* +npm-debug.log \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b59de8b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,31 @@ +FROM node:22-bookworm-slim AS base +WORKDIR /app +ENV NG_CLI_ANALYTICS=ci +RUN npm install -g @angular/cli + +#--- Development +FROM base AS development +RUN apt-get update && \ + apt-get install -y git && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* +# Switching user so it doesn't use root +# If root adds files, it takes ownership, making them unremovable on the host except by using sudo +# node tranfers ownership to the host user +USER node +EXPOSE 4200 +CMD ["tail","-f","/dev/null"] + +#--- Building +FROM base AS build +COPY package*.json ./ +RUN npm install +# Copied here, since the the Development setup uses a bind volume instead +COPY . . +RUN ng build --configuration production + +#--- Production +FROM nginx:alpine AS production +COPY --from=build /app/dist/quiz/browser /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/angular.json b/frontend/angular.json index 6479c1a..c9654d3 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -50,6 +50,12 @@ "maximumError": "4kB" } ], + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], "outputHashing": "all" }, "development": { @@ -98,5 +104,8 @@ } } } + }, + "cli": { + "analytics": false } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a245bc3..4cd7d76 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,15 +25,31 @@ "@angular/cli": "^18.2.10", "@angular/compiler-cli": "^18.2.0", "@types/jasmine": "~5.1.0", + "autoprefixer": "^10.4.20", "jasmine-core": "~5.2.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", "typescript": "~5.5.2" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -193,6 +209,35 @@ } } }, + "node_modules/@angular-devkit/build-angular/node_modules/postcss": { + "version": "8.4.41", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", + "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", @@ -450,6 +495,36 @@ "typescript": ">=5.4 <5.6" } }, + "node_modules/@angular/compiler-cli/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@angular/compiler-cli/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@angular/core": { "version": "18.2.9", "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.9.tgz", @@ -4962,6 +5037,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -4989,6 +5071,13 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5474,6 +5563,16 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001673", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001673.tgz", @@ -5520,19 +5619,41 @@ "license": "MIT" }, "node_modules/chokidar": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", - "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 8.10.0" }, "funding": { "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" } }, "node_modules/chownr": { @@ -6322,6 +6443,20 @@ "dev": true, "license": "MIT" }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/dns-packet": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", @@ -8605,31 +8740,6 @@ "source-map-support": "^0.5.5" } }, - "node_modules/karma/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/karma/node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -8649,19 +8759,6 @@ "dev": true, "license": "MIT" }, - "node_modules/karma/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/karma/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -8672,32 +8769,6 @@ "node": ">=8" } }, - "node_modules/karma/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/karma/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/karma/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8924,6 +8995,16 @@ } } }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -9780,6 +9861,18 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -10210,6 +10303,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", @@ -10716,6 +10819,16 @@ "node": ">=6" } }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/piscina": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.6.1.tgz", @@ -10743,9 +10856,9 @@ } }, "node_modules/postcss": { - "version": "8.4.41", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", - "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -10764,13 +10877,100 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/postcss-loader": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz", @@ -10873,6 +11073,32 @@ "postcss": "^8.1.0" } }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, "node_modules/postcss-selector-parser": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", @@ -11054,6 +11280,26 @@ "node": ">= 0.8" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/read-cache/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -11070,17 +11316,29 @@ } }, "node_modules/readdirp": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", - "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14.16.0" + "node": ">=8.6" }, "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/reflect-metadata": { @@ -11513,70 +11771,6 @@ } } }, - "node_modules/sass/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/sass/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/sass/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/sass/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -12410,6 +12604,86 @@ "node": ">=6" } }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -12446,6 +12720,44 @@ "node": ">=0.10" } }, + "node_modules/tailwindcss": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz", + "integrity": "sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -12637,6 +12949,29 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/thingies": { "version": "1.21.0", "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", @@ -12720,6 +13055,13 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tslib": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", @@ -13525,35 +13867,6 @@ "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/vite/node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.0", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/void-elements": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", @@ -13752,31 +14065,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/webpack-dev-server/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/webpack-dev-server/node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -13798,19 +14086,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/webpack-dev-server/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/webpack-dev-server/node_modules/http-proxy-middleware": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", @@ -13852,32 +14127,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/webpack-dev-server/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/webpack-dev-server/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/webpack-dev-server/node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", @@ -14190,6 +14439,19 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index d16ef44..975ced5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,12 +27,15 @@ "@angular/cli": "^18.2.10", "@angular/compiler-cli": "^18.2.0", "@types/jasmine": "~5.1.0", + "autoprefixer": "^10.4.20", "jasmine-core": "~5.2.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", "typescript": "~5.5.2" } } diff --git a/frontend/public/question.ico b/frontend/public/question.ico new file mode 100644 index 0000000..a27a197 Binary files /dev/null and b/frontend/public/question.ico differ diff --git a/frontend/src/app/Quiz/models/answers.ts b/frontend/src/app/Quiz/models/answers.ts new file mode 100644 index 0000000..ab90e14 --- /dev/null +++ b/frontend/src/app/Quiz/models/answers.ts @@ -0,0 +1,4 @@ +export interface Answers { + sessionId: string; + answers: string[]; +} \ No newline at end of file diff --git a/frontend/src/app/Quiz/models/category.ts b/frontend/src/app/Quiz/models/category.ts new file mode 100644 index 0000000..dae9760 --- /dev/null +++ b/frontend/src/app/Quiz/models/category.ts @@ -0,0 +1,4 @@ +export interface Category { + id: number; + name: string; +} \ No newline at end of file diff --git a/frontend/src/app/Quiz/models/question.ts b/frontend/src/app/Quiz/models/question.ts new file mode 100644 index 0000000..fc74e84 --- /dev/null +++ b/frontend/src/app/Quiz/models/question.ts @@ -0,0 +1,11 @@ +export interface Question { + question: string; + answers: string[]; + category: string; + type: string; + difficulty: string; + + /** To make special characters visible */ + question_decoded?: string; + answers_decoded?: string[]; +} diff --git a/frontend/src/app/Quiz/models/quiz-params.ts b/frontend/src/app/Quiz/models/quiz-params.ts new file mode 100644 index 0000000..7b8fff8 --- /dev/null +++ b/frontend/src/app/Quiz/models/quiz-params.ts @@ -0,0 +1,8 @@ +import { Category } from "./category"; + +export interface QuizParams { + category: number; + type: string; + amount: number; + difficulty: string; + } \ No newline at end of file diff --git a/frontend/src/app/Quiz/models/score.ts b/frontend/src/app/Quiz/models/score.ts new file mode 100644 index 0000000..9230a22 --- /dev/null +++ b/frontend/src/app/Quiz/models/score.ts @@ -0,0 +1,4 @@ +export interface Score { + amount_questions: number; + amount_correct: number; + } \ No newline at end of file diff --git a/frontend/src/app/Quiz/models/session.ts b/frontend/src/app/Quiz/models/session.ts new file mode 100644 index 0000000..64801c1 --- /dev/null +++ b/frontend/src/app/Quiz/models/session.ts @@ -0,0 +1,7 @@ +import { Question } from "./question"; + +export interface Session { + id: string; + statusCode: number; + questions: Question[]; +} \ No newline at end of file diff --git a/frontend/src/app/Quiz/quiz-routing.module.ts b/frontend/src/app/Quiz/quiz-routing.module.ts new file mode 100644 index 0000000..16851f2 --- /dev/null +++ b/frontend/src/app/Quiz/quiz-routing.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { SetupComponent } from './setup/setup.component'; +import { QuizComponent } from './quiz/quiz.component'; +import { ScoreComponent } from './score/score.component'; + +const routes: Routes = [ + { path: '', component: SetupComponent }, + { path: 'quiz', component: QuizComponent }, + { path: 'score', component: ScoreComponent }, + { path: '**', redirectTo: '' } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class QuizRoutingModule { } diff --git a/frontend/src/app/Quiz/quiz.module.ts b/frontend/src/app/Quiz/quiz.module.ts new file mode 100644 index 0000000..9605ca3 --- /dev/null +++ b/frontend/src/app/Quiz/quiz.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { QuizRoutingModule } from './quiz-routing.module'; + + +@NgModule({ + declarations: [], + imports: [ + CommonModule, + QuizRoutingModule + ], + providers: [] +}) +export class QuizModule { } diff --git a/frontend/src/app/Quiz/quiz/quiz.component.html b/frontend/src/app/Quiz/quiz/quiz.component.html new file mode 100644 index 0000000..64d6bfe --- /dev/null +++ b/frontend/src/app/Quiz/quiz/quiz.component.html @@ -0,0 +1,19 @@ +
+
+
+

{{ question.question_decoded }}

+
+ +
+
+ + +
+
\ No newline at end of file diff --git a/frontend/src/app/Quiz/quiz/quiz.component.scss b/frontend/src/app/Quiz/quiz/quiz.component.scss new file mode 100644 index 0000000..ddfa0aa --- /dev/null +++ b/frontend/src/app/Quiz/quiz/quiz.component.scss @@ -0,0 +1,3 @@ +.no-scrollbar { + scrollbar-width: none; + } \ No newline at end of file diff --git a/frontend/src/app/Quiz/quiz/quiz.component.ts b/frontend/src/app/Quiz/quiz/quiz.component.ts new file mode 100644 index 0000000..06029a9 --- /dev/null +++ b/frontend/src/app/Quiz/quiz/quiz.component.ts @@ -0,0 +1,61 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { Score } from '../models/score'; +import { Question } from '../models/question'; +import { QuizService } from '../services/quiz.service'; + +@Component({ + selector: 'app-quiz', + standalone: true, + imports: [CommonModule], + templateUrl: './quiz.component.html', + styleUrl: './quiz.component.scss' +}) +export class QuizComponent { + questions: Question[] = []; + selectedAnswers: { [key: number]: string } = {}; + + constructor(private router: Router, private quizService: QuizService) { + const navigation = this.router.getCurrentNavigation(); + if (navigation?.extras?.state) { + this.questions = navigation.extras.state['questions']; + }else { + // You didn't come here through the proper channels, byebye + this.router.navigate(['']); + } + } + + selectAnswer(questionIndex: number, answer: string) { + this.selectedAnswers[questionIndex] = answer; + } + + allQuestionsAnswered(): boolean { + return Object.keys(this.selectedAnswers).length === this.questions.length; + } + + getScore() { + const answers: string[] = [] + for(let i = 0; i < Object.keys(this.selectedAnswers).length; i++) { + answers.push(this.selectedAnswers[i]); + } + this.quizService.getScore(answers) + .then((result:number) => { + const score: Score = { + amount_correct:result, + amount_questions: Object.keys(this.selectedAnswers).length + } + this.router.navigate(['/score'], { state: { score: score },skipLocationChange:true }); + }) + .catch((e) => { + alert("Something went wrong"); + }); + + // const sampleScore: Score = { + // amount_questions: 10, + // amount_correct: 7 + // } + + } +} \ No newline at end of file diff --git a/frontend/src/app/Quiz/score/score.component.html b/frontend/src/app/Quiz/score/score.component.html new file mode 100644 index 0000000..751e3d0 --- /dev/null +++ b/frontend/src/app/Quiz/score/score.component.html @@ -0,0 +1,11 @@ +
+

Your Score

+

+ {{ score.amount_correct }} / {{ score.amount_questions }} +

+ +
\ No newline at end of file diff --git a/frontend/src/app/Quiz/score/score.component.scss b/frontend/src/app/Quiz/score/score.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/Quiz/score/score.component.ts b/frontend/src/app/Quiz/score/score.component.ts new file mode 100644 index 0000000..bfe4e1b --- /dev/null +++ b/frontend/src/app/Quiz/score/score.component.ts @@ -0,0 +1,29 @@ +import { Component } from '@angular/core'; +import { Router } from '@angular/router'; +import { Score } from '../models/score'; + +@Component({ + selector: 'app-score', + standalone: true, + imports: [], + templateUrl: './score.component.html', + styleUrl: './score.component.scss' +}) +export class ScoreComponent { + + score !: Score; + + constructor(private router: Router) { + const navigation = this.router.getCurrentNavigation(); + if (navigation?.extras?.state) { + this.score = navigation.extras.state['score']; + }else { + // You didn't come here through the proper channels, byebye + this.router.navigate(['']); + } + } + + playAgain() { + this.router.navigate(['']); + } +} diff --git a/frontend/src/app/Quiz/services/cleaner.service.ts b/frontend/src/app/Quiz/services/cleaner.service.ts new file mode 100644 index 0000000..8dbf81b --- /dev/null +++ b/frontend/src/app/Quiz/services/cleaner.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { Question } from '../models/question'; + +@Injectable({ + providedIn: 'root' +}) +export class CleanerService { + + constructor() { } + + /** + * Sets decoded strings for the questions and the answers, so the special characters will be shown instead of their codes + */ + public cleanQuestionStrings(questions: Question[]): Question[] { + for(let i = 0; i < questions.length; i++) { + questions[i].question_decoded = this.decodeString(questions[i].question); + questions[i].answers_decoded = []; + for(let j = 0; j < questions[i].answers.length; j++){ + questions[i].answers_decoded!.push(this.decodeString(questions[i].answers[j])); + } + } + return questions; + } + + /** Puts special characters back into the string, like ' and " */ + private decodeString(text: string) { + const txt = document.createElement("textarea"); + txt.innerHTML = text; + const decodedValue = txt.value; + txt.remove(); // Clean up the temporary element + return decodedValue; + } +} diff --git a/frontend/src/app/Quiz/services/quiz.service.ts b/frontend/src/app/Quiz/services/quiz.service.ts new file mode 100644 index 0000000..e9c141a --- /dev/null +++ b/frontend/src/app/Quiz/services/quiz.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from '@angular/core'; +import { QuizParams } from '../models/quiz-params'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { + catchError, + filter, + firstValueFrom, + interval, + lastValueFrom, + of, + startWith, + switchMap, + take, + takeWhile, +} from 'rxjs'; +import { Question } from '../models/question'; +import { Answers } from '../models/answers'; +import { StorageService } from './storage.service'; +import { CleanerService } from './cleaner.service'; +import { ResponseHandlerService } from './response-handler.service'; +import { environment } from '../../../environments/environment'; +import { Category } from '../models/category'; + +@Injectable({ + providedIn: 'root', +}) +export class QuizService { + private apiUrl = environment.apiUrl + '/quiz'; + + constructor( + private http: HttpClient, + private storageService: StorageService, + private cleanerService: CleanerService, + private reponseHandler: ResponseHandlerService) {} + + public async startSession(quizParams: QuizParams): Promise { + const sessionIdObj = await firstValueFrom(this.http.get(this.apiUrl + '/questions' + this.convertParamsToUrl(quizParams))); + /** No need to poll if the session is not created. Likely means the queue is full */ + if(sessionIdObj.statusCode !== 0) { + return this.reponseHandler.handleResponse(sessionIdObj.statusCode); + } + this.storageService.setSession(sessionIdObj.sessionId); + const sessionObj = await lastValueFrom(this.pollQuestions(sessionIdObj.sessionId)); + return this.reponseHandler.handleResponse(sessionObj.statusCode, sessionObj); + } + + public pollQuestions(sessionId: string) { + return interval(2000).pipe( + startWith(0), + switchMap(() => this.http.get(this.apiUrl + `/status/${sessionId}`)), + // takeWhile((response) => response === null), + filter((response) => response !== null), + take(1) + ); + } + + public async getScore(answers: string[]) { + const answersJson: Answers = { + sessionId: this.storageService.getSession()!, + answers: answers + } + + const headers = new HttpHeaders({'Content-Type': 'application/json'}); + const scoreObj = await firstValueFrom(this.http.post(this.apiUrl+'/score', answersJson, { headers })); + return scoreObj.score; + } + + private convertParamsToUrl(quizParams: QuizParams) { + let url = `?amount=${quizParams.amount}`; + const catIndex = quizParams.category; + url += catIndex !== 0 ? `&category=${catIndex}` : ''; + + const diff = quizParams.difficulty.toLowerCase(); + url += diff !== 'any difficulty' ? `&difficulty=${diff}` : ''; + + const typeIndex = this.getTypes().indexOf(quizParams.type); + url += + typeIndex !== 0 + ? typeIndex === 1 + ? '&type=multiple' + : '&type=boolean' + : ''; + return url; + } + + public async getCategories(): Promise { + let categories: Category[] = await firstValueFrom(this.http.get(this.apiUrl + '/categories')); + categories.push({id:0,name:"Any Category"}); + categories.sort((a,b) => a.id - b.id); + return categories; + } + + public getDifficulties(): string[] { + return ['Any Difficulty', 'Easy', 'Medium', 'Hard']; + } + + public getTypes(): string[] { + return ['Any Type', 'Multiple choice', 'True/False']; + } +} diff --git a/frontend/src/app/Quiz/services/response-handler.service.ts b/frontend/src/app/Quiz/services/response-handler.service.ts new file mode 100644 index 0000000..2c5493b --- /dev/null +++ b/frontend/src/app/Quiz/services/response-handler.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core'; +import { Session } from '../models/session'; +import { CleanerService } from './cleaner.service'; +import { Question } from '../models/question'; + +@Injectable({ + providedIn: 'root', +}) +export class ResponseHandlerService { + constructor(private cleanerService: CleanerService) {} + + /** + * Session is optional, except for the success state (0) + */ + public handleResponse(statusCode: number, session?: Session): Question[] | null { + switch (statusCode) { + case 0: + // Success + session!.questions = this.cleanerService.cleanQuestionStrings(session!.questions); + return session!.questions; + case 1: + // No Results (requests too much data for this combination of parameters) + alert('Not enough data for this request, please try a different combination'); + return null; + case 2: + // Invalid parameter (should be handled by the backend) + alert('Invalid input'); + return null; + case 5: + // Queue is full, try again later + alert('The server is experiencing a lot of traffic, please try again later'); + return null; + } + return null; + } +} \ No newline at end of file diff --git a/frontend/src/app/Quiz/services/storage.service.ts b/frontend/src/app/Quiz/services/storage.service.ts new file mode 100644 index 0000000..61186b2 --- /dev/null +++ b/frontend/src/app/Quiz/services/storage.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from "@angular/core"; + +@Injectable({ + providedIn: 'root', + }) + export class StorageService { + constructor() { + } + + public setSession(sessionId: string): void { + localStorage.setItem("sessionId", sessionId); + } + + public getSession(): string | null { + return localStorage.getItem("sessionId"); + } +} \ No newline at end of file diff --git a/frontend/src/app/Quiz/setup/setup.component.html b/frontend/src/app/Quiz/setup/setup.component.html new file mode 100644 index 0000000..26d99e3 --- /dev/null +++ b/frontend/src/app/Quiz/setup/setup.component.html @@ -0,0 +1,35 @@ +
+
+

Quiz App

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
\ No newline at end of file diff --git a/frontend/src/app/Quiz/setup/setup.component.scss b/frontend/src/app/Quiz/setup/setup.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/Quiz/setup/setup.component.ts b/frontend/src/app/Quiz/setup/setup.component.ts new file mode 100644 index 0000000..bf28d90 --- /dev/null +++ b/frontend/src/app/Quiz/setup/setup.component.ts @@ -0,0 +1,66 @@ +import { HttpClient } from '@angular/common/http'; +import { Component } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { QuizService } from '../services/quiz.service'; +import { QuizParams } from '../models/quiz-params'; +import { SpinnerService } from '../../Spinner/services/spinner.service'; +import { CommonModule } from '@angular/common'; +import { Question } from '../models/question'; +import { Category } from '../models/category'; + +@Component({ + selector: 'app-setup', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './setup.component.html', + styleUrl: './setup.component.scss', +}) +export class SetupComponent { + + categories!: Category[]; + category!: number; + difficulty: string = ""; + type: string = ""; + amount: number = 10; + + constructor( + private router: Router, + public quizService: QuizService, + private spinnerService: SpinnerService + ) { + quizService.getCategories().then((categories: Category[]) => { + /** Setting default values */ + this.categories = categories; + this.category = this.categories.find(item => item.id == 0)!.id; + this.difficulty = this.quizService.getDifficulties()[0]; + this.type = this.quizService.getTypes()[0]; + }); + } + + startQuiz() { + const params: QuizParams = { + category: this.category, + type: this.type, + amount: this.amount, + difficulty: this.difficulty, + }; + + this.spinnerService.setActive(true); + this.quizService.startSession(params).then((questions: Question[] | null) => { + if(questions !== null) { + this.gotoQuiz(questions); + } + }) + .catch((e) => {alert("Something went wrong, please try again");}) + .finally(() => {this.spinnerService.setActive(false);}); + + } + + gotoQuiz(questions: Question[]) { + this.router.navigate(['/quiz'], { + state: { questions: questions }, + skipLocationChange: true, + }); + } +} diff --git a/frontend/src/app/Spinner/services/spinner.service.ts b/frontend/src/app/Spinner/services/spinner.service.ts new file mode 100644 index 0000000..477a7ff --- /dev/null +++ b/frontend/src/app/Spinner/services/spinner.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class SpinnerService { + + private isActive = false; + constructor() { } + + toggleSpinner() { + this.isActive = !this.isActive; + } + + setActive(active: boolean) { + new Promise( + (resolve) => setTimeout(resolve, 0)).then( + _ =>this.isActive = active + ) + } + + isSpinnerActive() { + return this.isActive; + } +} diff --git a/frontend/src/app/Spinner/spinner.module.ts b/frontend/src/app/Spinner/spinner.module.ts new file mode 100644 index 0000000..2a26e39 --- /dev/null +++ b/frontend/src/app/Spinner/spinner.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + + + +@NgModule({ + declarations: [], + imports: [] +}) +export class SpinnerModule { } diff --git a/frontend/src/app/Spinner/spinner/spinner.component.html b/frontend/src/app/Spinner/spinner/spinner.component.html new file mode 100644 index 0000000..944a7cd --- /dev/null +++ b/frontend/src/app/Spinner/spinner/spinner.component.html @@ -0,0 +1,5 @@ +
+
+
+
+
diff --git a/frontend/src/app/Spinner/spinner/spinner.component.scss b/frontend/src/app/Spinner/spinner/spinner.component.scss new file mode 100644 index 0000000..7fdd1bf --- /dev/null +++ b/frontend/src/app/Spinner/spinner/spinner.component.scss @@ -0,0 +1,98 @@ +.lds-spinner { + color: green; + display: inline-block; + position: relative; + width: 80px; + height: 80px; + } + .lds-spinner div { + transform-origin: 40px 40px; + animation: lds-spinner 1.2s linear infinite; + } + .lds-spinner div:after { + content: " "; + display: block; + position: absolute; + top: 3px; + left: 37px; + width: 6px; + height: 18px; + border-radius: 20%; + background: #fff; + } + .lds-spinner div:nth-child(1) { + transform: rotate(0deg); + animation-delay: -1.1s; + } + .lds-spinner div:nth-child(2) { + transform: rotate(30deg); + animation-delay: -1s; + } + .lds-spinner div:nth-child(3) { + transform: rotate(60deg); + animation-delay: -0.9s; + } + .lds-spinner div:nth-child(4) { + transform: rotate(90deg); + animation-delay: -0.8s; + } + .lds-spinner div:nth-child(5) { + transform: rotate(120deg); + animation-delay: -0.7s; + } + .lds-spinner div:nth-child(6) { + transform: rotate(150deg); + animation-delay: -0.6s; + } + .lds-spinner div:nth-child(7) { + transform: rotate(180deg); + animation-delay: -0.5s; + } + .lds-spinner div:nth-child(8) { + transform: rotate(210deg); + animation-delay: -0.4s; + } + .lds-spinner div:nth-child(9) { + transform: rotate(240deg); + animation-delay: -0.3s; + } + .lds-spinner div:nth-child(10) { + transform: rotate(270deg); + animation-delay: -0.2s; + } + .lds-spinner div:nth-child(11) { + transform: rotate(300deg); + animation-delay: -0.1s; + } + .lds-spinner div:nth-child(12) { + transform: rotate(330deg); + animation-delay: 0s; + } + @keyframes lds-spinner { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } + } + + .spinner { + position: fixed; /* Stay in place */ + z-index: var(--z-spinner); + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + overflow: auto; /* Enable scroll if needed */ + background-color: rgb(0,0,0); /* Fallback color */ + background-color: rgba(0,0,0,0.4); /* Black w/ opacity */ +} + +.spinner-content { + width:100%; + height:100%; + display:flex; + align-items: center; + justify-content: center; +} \ No newline at end of file diff --git a/frontend/src/app/Spinner/spinner/spinner.component.ts b/frontend/src/app/Spinner/spinner/spinner.component.ts new file mode 100644 index 0000000..0962b59 --- /dev/null +++ b/frontend/src/app/Spinner/spinner/spinner.component.ts @@ -0,0 +1,22 @@ +import { animate, style, transition, trigger } from '@angular/animations'; +import { Component } from '@angular/core'; +import { SpinnerService } from '../services/spinner.service'; +import { CommonModule } from '@angular/common'; +import { BrowserAnimationsModule, NoopAnimationsModule } from '@angular/platform-browser/animations'; + +@Component({ + selector: 'app-spinner', + standalone: true, + imports: [CommonModule], + templateUrl: './spinner.component.html', + styleUrl: './spinner.component.scss', + styles: [], + animations: [ + trigger('fadeInOut', [ + transition(':enter', [style({ opacity:0}), animate("300ms ease-in-out")]), + transition(':leave', [animate("300ms ease-in-out", style({ opacity: 0}))]), + ])] +}) +export class SpinnerComponent { + constructor(public spinnerService: SpinnerService) { } +} diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 60599ac..363b1a0 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -1 +1,2 @@ -

Quiz App

\ No newline at end of file + + \ No newline at end of file diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index b759ba8..57d5d9b 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,10 +1,11 @@ import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { SpinnerComponent } from './Spinner/spinner/spinner.component'; @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet], + imports: [RouterOutlet, SpinnerComponent], templateUrl: './app.component.html', styleUrl: './app.component.scss' }) diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index a1e7d6f..746ac02 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -2,7 +2,11 @@ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; +import { provideHttpClient } from '@angular/common/http'; export const appConfig: ApplicationConfig = { - providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)] + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + provideHttpClient()] }; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index dc39edb..e9f8430 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,3 +1,7 @@ import { Routes } from '@angular/router'; -export const routes: Routes = []; +export const routes: Routes = [ + { path: '', loadChildren: () => import('./Quiz/quiz.module').then(m => m.QuizModule) }, // Lazy load QuizModule + // { path: '**', redirectTo: '/quiz' } // Handle unmatched routes + +]; diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts new file mode 100644 index 0000000..b605977 --- /dev/null +++ b/frontend/src/environments/environment.prod.ts @@ -0,0 +1,6 @@ +export const environment = { + production: true, + /** Connecting through the docker compose network is not working for now, so I'll have to use the localhost */ + // apiUrl: 'http://backend:9090', + apiUrl: 'http://localhost:9090', + }; \ No newline at end of file diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts new file mode 100644 index 0000000..53a7aba --- /dev/null +++ b/frontend/src/environments/environment.ts @@ -0,0 +1,4 @@ +export const environment = { + production: false, + apiUrl: 'http://localhost:9090' + }; \ No newline at end of file diff --git a/frontend/src/index.html b/frontend/src/index.html index d51364b..195d95b 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -5,7 +5,7 @@ Quiz - + diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 90d4ee0..7b1e05d 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -1 +1,27 @@ /* You can add global styles to this file, and also import other style files */ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +html, body { + height: 100%; + width: 100%; + margin: 0; + padding: 0; + display: flex; + } + + app-root { + flex: 1; /* Makes the AppComponent fill the available space */ + // display:flex; + // justify-content: center; + // align-items: center; + } + + :root { + // --z-base: 0; + // --z-dropdown: 100; + // --z-modal: 200; + // --z-tooltip: 300; + --z-spinner: 1800; +} \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..8314e1b --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./src/**/*.{html,ts}" + ], + theme: { + extend: {}, + }, + plugins: [], +} + diff --git a/scripts/LoadTestMultiple.sh b/scripts/LoadTestMultiple.sh new file mode 100755 index 0000000..0692369 --- /dev/null +++ b/scripts/LoadTestMultiple.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +start_time=$(date +%s) + +for i in {1..6}; do + ./LoadTestOne-Silent.sh & +# sleep 0.1 +done + +wait + +end_time=$(date +%s) + +elapsed_time=$((end_time - start_time)) +echo "Done!" +echo "Total time taken: $elapsed_time seconds" diff --git a/scripts/LoadTestOne-Silent.sh b/scripts/LoadTestOne-Silent.sh new file mode 100755 index 0000000..a76f8b3 --- /dev/null +++ b/scripts/LoadTestOne-Silent.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Step 1: Get the sessionId from the questions endpoint +sessionIdJson=$(curl -s "http://localhost:9090/quiz/questions?amount=10&category=23&difficulty=medium&type=multiple") + +# Extract the status code +statusCode=$(echo "$sessionIdJson" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') + +if [ "$statusCode" -ne 0 ]; then + exit 1 +fi + +# Extract the session ID +sessionId=$(echo "$sessionIdJson" | grep -o '"sessionId":"[^"]*"' | sed 's/"sessionId":"//;s/"//') + +if [ -z "$sessionId" ]; then + exit 1 +fi + +# Step 2: Poll the status endpoint every 2 seconds +while true; do + status_response=$(curl -s "http://localhost:9090/quiz/status/$sessionId") + + if [ "$status_response" != "null" ]; then + # Extract the status code from the response + statusCode=$(echo "$status_response" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') + + if [ "$statusCode" -ne 0 ]; then + exit 1 + fi + + # Check if the response is JSON (starts with '{') + if [[ $status_response == \{* ]]; then + break + fi + fi + + sleep 2 +done diff --git a/scripts/LoadTestOne-WithParams.sh b/scripts/LoadTestOne-WithParams.sh new file mode 100755 index 0000000..42d65f4 --- /dev/null +++ b/scripts/LoadTestOne-WithParams.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Step 1: Get the sessionId from the questions endpoint +sessionIdJson=$(curl -s "http://localhost:9090/quiz/questions?amount=20&category=27&difficulty=medium&type=multiple") + +statusCode=$(echo "$sessionIdJson" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') + +if [ "$statusCode" -ne 0 ]; then + echo "Exiting: statusCode is not 0." + exit 1 +fi + +sessionId=$(echo "$sessionIdJson" | grep -o '"sessionId":"[^"]*"' | sed 's/"sessionId":"//;s/"//') + +if [ -z "$sessionId" ]; then + echo "Failed to get sessionId" + exit 1 +fi + +echo "Received sessionId: $sessionId" + +# Step 2: Poll the status endpoint every 2 seconds +while true; do + status_response=$(curl -s http://localhost:9090/quiz/status/"$sessionId") + if [ "$status_response" != "null" ] && [ $? -eq 0 ]; then + + statusCode=$(echo "$status_response" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') + + if [ "$statusCode" -ne 0 ]; then + echo "Exiting: statusCode is $statusCode" + exit 1 + fi + + # Check if the response is JSON (starts with '{') + if [[ $status_response == \{* ]]; then + echo "$sessionId: done! status: $statusCode" + break + fi + else + echo "Status check failed, retrying..." + fi + + sleep 2 +done diff --git a/scripts/LoadTestOne.sh b/scripts/LoadTestOne.sh new file mode 100755 index 0000000..a85eab8 --- /dev/null +++ b/scripts/LoadTestOne.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Step 1: Get the sessionId from the questions endpoint +sessionIdJson=$(curl -s http://localhost:9090/quiz/questions?amount=5) + +statusCode=$(echo "$sessionIdJson" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') + +if [ "$statusCode" -ne 0 ]; then + echo "Exiting: statusCode is not 0." + exit 1 +fi + +sessionId=$(echo "$sessionIdJson" | grep -o '"sessionId":"[^"]*"' | sed 's/"sessionId":"//;s/"//') + +if [ -z "$sessionId" ]; then + echo "Failed to get sessionId" + exit 1 +fi + +echo "Received sessionId: $sessionId" + +# Step 2: Poll the status endpoint every 2 seconds +while true; do + status_response=$(curl -s http://localhost:9090/quiz/status/"$sessionId") + if [ "$status_response" != "null" ] && [ $? -eq 0 ]; then + + statusCode=$(echo "$status_response" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') + + if [ "$statusCode" -ne 0 ]; then + echo "Exiting: statusCode is $statusCode" + exit 1 + fi + + # Check if the response is JSON (starts with '{') + if [[ $status_response == \{* ]]; then + echo "$sessionId: done! status: $statusCode" + break + fi + else + echo "Status check failed, retrying..." + fi + + sleep 2 +done diff --git a/scripts/benchmarks/1. Queue - No params - 6 sessions.png b/scripts/benchmarks/1. Queue - No params - 6 sessions.png new file mode 100644 index 0000000..8d600d1 Binary files /dev/null and b/scripts/benchmarks/1. Queue - No params - 6 sessions.png differ diff --git a/scripts/benchmarks/2. DB and Queue - No Params - 6 sessions.png b/scripts/benchmarks/2. DB and Queue - No Params - 6 sessions.png new file mode 100644 index 0000000..d2a3811 Binary files /dev/null and b/scripts/benchmarks/2. DB and Queue - No Params - 6 sessions.png differ diff --git a/scripts/benchmarks/3. DB and Queue - All Params - 1000 and 10000 sessions.png b/scripts/benchmarks/3. DB and Queue - All Params - 1000 and 10000 sessions.png new file mode 100644 index 0000000..e9032bb Binary files /dev/null and b/scripts/benchmarks/3. DB and Queue - All Params - 1000 and 10000 sessions.png differ