feat: review backend and frontend

- update to the latest version of Java/SpringBoot
- update to the latest version NuxtJS
- add account/password update
- add account creation
- add account password reset
- add bundle to regroup questions and add default questions on user creation
- add bundle creation
pull/3/head
Nicolas Doby 2024-07-03 15:55:34 +02:00
parent f86d794239
commit b6e86f0641
207 changed files with 5570 additions and 40453 deletions

14
.env_template Normal file
View File

@ -0,0 +1,14 @@
POSTGRES_USER=postgres
POSTGRES_PASSWORD=securePassword123
DATABASE_HOST=database
DATABASE_PORT=5432
DATABASE_NAME=pluss_db
MAIL_HOST=TO_FILL#exemple: mail.apes-hdf.org
MAIL_PORT=TO_FILL#exemple:587
MAIL_FROM=TO_FILL#exemple:ne-pas-repondre@apes-hdf.org
MAIL_USERNAME=TO_FILL#exemple:ne-pas-repondre@apes-hdf.org
MAIL_PASSWORD=TO_FILL
MAIL_ACTIVATE_DEBUG=false# set to true to debug mailing
FRONTEND_URL=http://localhost:8190/#exemple: https://pluss.apes.fr

3
.gitignore vendored
View File

@ -4,6 +4,9 @@
/frontend/.eslintcache
/frontend/dist
/frontend/coverage
/frontend/yarn.lock
**/.env
# Node dependencies
node_modules

View File

@ -1,15 +1,32 @@
# Boussole PLUSS
Le projet backend contient la partie backend de la Boussole PLUSS. Le projet frontend, le frontend communiquant avec le backend.
## Contribuer à la Boussole
Le projet actuel est prévu pour travailler sur le thème de la production locale.
Cet outil peut servir à évaluer des collectifs sur dautres thématiques. Vous êtes encouragé à y réfléchir et à y
travailler. La licence AGPL version 3 prévoit un partage des améliorations que vous porterez à loutil. Si vous
désirez faire un fork de loutil, contactez-nous : contact@apes-hdf.org
## Installation
En utilisant docker-compose:
Le projet backend contient la partie backend de la Boussole PLUSS. Le projet frontend, le site web communiquant avec le backend.
Copier/coller le fichier `.env_template` en `.env` et modifier les variables d'environnements.
En utilisant docker-compose :
`docker-compose build` en modifiant au préalable l'argument BACKEND_BASE_URL du fichier
docker-compose.yml pour spécifier l'URL sur laquelle pointera le backend.
`docker-compose up` pour lancer le frontend et backend.
Exemple de configuration Nginx compatible avec le docker-compose de ce projet (URL_EXTERNE est à remplacer
Au premier lancement, il faut initialiser la base de donnée. Pour ce faire :
- `docker-compose up -d database`
- `docker exec database psql -U postgres -c "CREATE DATABASE pluss_db ENCODING UTF8" postgres`
En suite, les autres services peuvent être lancés avec `docker-compose up -d`. La base de données sera remplie automatiquement par le service backend.
Le site est accessible via le service `frontend`, dans cet exemple du docker-compose.yml, depuis http://localhost:8190.
Voici un exemple de configuration Nginx compatible avec le docker-compose de ce projet (URL_EXTERNE est à remplacer
par l'URL publique) :
```
@ -46,59 +63,3 @@ server {
```
Ceci est un exemple à ne pas utiliser sur un environnement de production.
## Administration
Par la suite, `__URL__` est l'URL du backend, `__USER__` et `__PASSWORD__` le nom de compte et mot de passe à créer / utiliser.
### Créer un nouveau compte
```bash
curl --location --request POST '__URL__/api/auth/signup' \
--header 'Content-Type: application/json' \
--data-raw '{
"username": "__USER__",
"password": "__PASSWORD__"
}'
```
### Se connecter
```bash
curl --location --request POST '__URL__/api/auth/signin' \
--header 'Content-Type: application/json' \
--data-raw '{
"username": "__USER__",
"password": "__PASSWORD__"
}'
```
Cet appel renverra un token à réutiliser pour l'ensemble des requêtes. Par exemple :
```json
{
"token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJkZW1vIiwiaWF0IjoxNjY1NjY5NjUyLCJleHAiOjE2NjU2NzMyNTJ9.i426thZKL4-JvOt9ZeG2D1O6O-xlSiPoCMiKysHYzCkHVNrnxKetq8xKRNCTnbpLV-wagpOw2g-om34k2jtHIw",
"refreshToken": "de73adcb-a5a8-4675-8bb3-5536651be0f9",
"id": 11,
"username": "demo"
}
```
### Afficher les balises (axe)
```bash
curl --location --request GET '__URL__/api/axes' \
--header 'Authorization: Bearer TOKEN'
```
Le TOKEN est à remplacer par celui reçu.
### Ajouter une question à un axe
```bash
curl --location --request POST '__URL__/api/questions' \
--header 'Authorization: Bearer TOKEN' \
--header 'Content-Type: application/json' \
--data-raw '
{
"axe": "URL_BALISE_1",
"label": "Question 1",
"description": "Description .."
}
'
```
Il faut spécifier URL_BALISE_1 comme étant l'URL de la balise 1 récupérer dans la requête précédente.
Par exemple : `__URL__/axes/1`

14
backend/.env_template Normal file
View File

@ -0,0 +1,14 @@
POSTGRES_USER=postgres
POSTGRES_PASSWORD=LKG+gD96
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=pluss_db
MAIL_HOST=
MAIL_PORT=
MAIL_FROM=
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ACTIVATE_DEBUG=false
FRONTEND_URL=http://localhost:3000/

View File

@ -1,4 +1,4 @@
FROM maven:3.8.6-eclipse-temurin-17 as builder
FROM maven:3.9.8-eclipse-temurin-21 as builder
WORKDIR /src
COPY . /src
@ -8,7 +8,7 @@ WORKDIR /app
RUN cp /src/target/bousole-pluss-backend*.jar bousole-pluss-backend.jar
RUN java -Djarmode=layertools -jar bousole-pluss-backend.jar extract
FROM eclipse-temurin:17-jre-alpine
FROM eclipse-temurin:21-jre-alpine
EXPOSE 8080
USER root
@ -18,4 +18,4 @@ COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/application/ ./
ENTRYPOINT java org.springframework.boot.loader.JarLauncher
ENTRYPOINT java org.springframework.boot.loader.launch.JarLauncher

View File

@ -5,7 +5,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.2</version>
<version>3.2.7</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>fr.itsonus</groupId>
@ -14,9 +14,9 @@
<name>bousole-pluss-backend</name>
<description>Backend projet of Bousole PLUSS project</description>
<properties>
<java.version>17</java.version>
<java.version>21</java.version>
<jjwt.version>0.9.1</jjwt.version>
<jjwt.version>0.12.6</jjwt.version>
</properties>
<dependencies>
<dependency>
@ -31,10 +31,6 @@
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-data</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.hateoas</groupId>
<artifactId>spring-hateoas</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
@ -52,15 +48,33 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jjwt.version}</version>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>

View File

@ -2,6 +2,11 @@ package fr.itsonus.bousoleplussbackend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.SessionLocaleResolver;
import java.util.Locale;
@SpringBootApplication
public class Application {
@ -9,4 +14,11 @@ public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@Bean
public LocaleResolver localeResolver() {
var slr = new SessionLocaleResolver();
slr.setDefaultLocale(Locale.FRANCE);
return slr;
}
}

View File

@ -1,72 +0,0 @@
package fr.itsonus.bousoleplussbackend.configuration;
import fr.itsonus.bousoleplussbackend.security.jwt.AuthEntryPointJwt;
import fr.itsonus.bousoleplussbackend.security.jwt.AuthTokenFilter;
import fr.itsonus.bousoleplussbackend.security.services.UserDetailsServiceImpl;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import java.util.List;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@AllArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final UserDetailsServiceImpl userDetailsService;
private final AuthEntryPointJwt unauthorizedHandler;
@Bean
public AuthTokenFilter authenticationJwtTokenFilter() {
return new AuthTokenFilter();
}
@Override
public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
authenticationManagerBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors().configurationSource(request -> {
var configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("*"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowedHeaders(List.of("*"));
return configuration;
})
.and()
.csrf().disable()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests().antMatchers("/auth/**").permitAll()
.anyRequest().authenticated();
http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
}
}

View File

@ -0,0 +1,45 @@
package fr.itsonus.bousoleplussbackend.controllers;
import fr.itsonus.bousoleplussbackend.payload.request.NotifyPasswordResetRequest;
import fr.itsonus.bousoleplussbackend.payload.request.ResetPasswordRequest;
import fr.itsonus.bousoleplussbackend.payload.request.UpdateAccountRequest;
import fr.itsonus.bousoleplussbackend.payload.request.UpdatePasswordRequest;
import fr.itsonus.bousoleplussbackend.usecase.UserPasswordResetUseCase;
import fr.itsonus.bousoleplussbackend.usecase.UserUpdateUseCase;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/account")
@AllArgsConstructor
public class AccountController {
private final UserUpdateUseCase userUpdateUseCase;
private final UserPasswordResetUseCase userPasswordResetUseCase;
@PutMapping
public void update(@Valid @RequestBody UpdateAccountRequest request) {
userUpdateUseCase.update(request.username(), request.email());
}
@PutMapping("password")
public void update(@Valid @RequestBody UpdatePasswordRequest request) {
userUpdateUseCase.updatePassword(request.currentPassword(), request.newPassword(), request.confirmationPassword());
}
@PostMapping("password/notify-reset-request")
public void notifyForPasswordReset(@Valid @RequestBody NotifyPasswordResetRequest request) {
userPasswordResetUseCase.notifyForPasswordReset(request.email());
}
@PostMapping("password/reset")
public void resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
userPasswordResetUseCase.resetPassword(request.token(), request.email(), request.newPassword(), request.confirmationPassword());
}
}

View File

@ -1,101 +1,85 @@
package fr.itsonus.bousoleplussbackend.controllers;
import fr.itsonus.bousoleplussbackend.domain.auth.AuthenticationService;
import fr.itsonus.bousoleplussbackend.domain.auth.model.RefreshToken;
import fr.itsonus.bousoleplussbackend.domain.auth.model.User;
import fr.itsonus.bousoleplussbackend.exception.TokenRefreshException;
import fr.itsonus.bousoleplussbackend.models.RefreshToken;
import fr.itsonus.bousoleplussbackend.models.User;
import fr.itsonus.bousoleplussbackend.payload.request.LogOutRequest;
import fr.itsonus.bousoleplussbackend.payload.request.LoginRequest;
import fr.itsonus.bousoleplussbackend.payload.request.SignupRequest;
import fr.itsonus.bousoleplussbackend.payload.request.RegisterRequest;
import fr.itsonus.bousoleplussbackend.payload.request.TokenRefreshRequest;
import fr.itsonus.bousoleplussbackend.payload.response.JwtResponse;
import fr.itsonus.bousoleplussbackend.payload.response.MessageResponse;
import fr.itsonus.bousoleplussbackend.payload.response.TokenRefreshResponse;
import fr.itsonus.bousoleplussbackend.repositories.UserRepository;
import fr.itsonus.bousoleplussbackend.security.jwt.JwtUtils;
import fr.itsonus.bousoleplussbackend.security.jwt.JwtGenerator;
import fr.itsonus.bousoleplussbackend.security.services.RefreshTokenService;
import fr.itsonus.bousoleplussbackend.security.services.UserDetailsImpl;
import fr.itsonus.bousoleplussbackend.usecase.UserCreationUseCase;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.GetMapping;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
@RestController
@RequestMapping("/auth")
@AllArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final UserRepository userRepository;
private final PasswordEncoder encoder;
private final JwtUtils jwtUtils;
private final DaoAuthenticationProvider userAuthenticationProvider;
private final AuthenticationService authenticationService;
private final UserCreationUseCase userCreationUseCase;
private final JwtGenerator jwtGenerator;
private final RefreshTokenService refreshTokenService;
@PostMapping("/signin")
public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
@PostMapping("/login")
public JwtResponse login(@Valid @RequestBody LoginRequest loginRequest) {
var authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));
var authentication = userAuthenticationProvider
.authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getEmail(), loginRequest.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
var userDetails = (UserDetailsImpl) authentication.getPrincipal();
var jwt = jwtUtils.generateJwtToken(userDetails);
var refreshToken = refreshTokenService.createRefreshToken(userDetails.getId());
var token = jwtGenerator.generateAuthToken(authentication);
var refreshToken = refreshTokenService.createOrUpdateRefreshToken(userDetails.getId());
return ResponseEntity.ok(new JwtResponse(jwt, refreshToken.getToken(), userDetails.getId(),
userDetails.getUsername()));
return new JwtResponse(token, refreshToken.getToken());
}
@PostMapping("/signup")
public ResponseEntity<?> registerUser(@Valid @RequestBody SignupRequest signUpRequest) {
if (userRepository.existsByUsername(signUpRequest.getUsername())) {
return ResponseEntity.badRequest().body(new MessageResponse("Error: Username is already taken!"));
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public void registerUser(@Valid @RequestBody RegisterRequest request) {
userCreationUseCase.createUserAndDefaultBundle(request.toUser());
}
// Create new user's account
var user = new User();
user.setUsername(signUpRequest.getUsername());
user.setPassword(encoder.encode(signUpRequest.getPassword()));
userRepository.save(user);
return ResponseEntity.ok(new MessageResponse("User registered successfully!"));
}
@PostMapping("/refreshtoken")
@PostMapping("/refresh-token")
public ResponseEntity<?> refreshToken(@Valid @RequestBody TokenRefreshRequest request) {
String requestRefreshToken = request.getRefreshToken();
var requestRefreshToken = request.getRefreshToken();
return refreshTokenService.findByToken(requestRefreshToken)
.map(refreshTokenService::verifyExpiration)
.map(RefreshToken::getUser)
.map(user -> {
String token = jwtUtils.generateTokenFromUsername(user.getUsername());
return ResponseEntity.ok(new TokenRefreshResponse(token, requestRefreshToken));
var token = jwtGenerator.generateRefreshToken(user.getEmail());
return ResponseEntity.ok(token);
})
.orElseThrow(() -> new TokenRefreshException(requestRefreshToken,
"Refresh token is not in database!"));
.orElseThrow(() -> new TokenRefreshException(requestRefreshToken, "Refresh token unknown"));
}
@PostMapping("/logout")
public ResponseEntity<?> logoutUser(@Valid @RequestBody LogOutRequest logOutRequest) {
public void logoutUser(@Valid @RequestBody LogOutRequest logOutRequest) {
refreshTokenService.deleteByUserId(logOutRequest.getUserId());
return ResponseEntity.ok(new MessageResponse("Log out successful!"));
}
@GetMapping("/me")
public ResponseEntity<?> me() {
var authentication = SecurityContextHolder.getContext().getAuthentication();
return ResponseEntity.ok(authentication.getPrincipal());
public User me() {
return authenticationService.getCurrentUser();
}
}

View File

@ -0,0 +1,22 @@
package fr.itsonus.bousoleplussbackend.controllers;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Axe;
import fr.itsonus.bousoleplussbackend.usecase.AxeUseCase;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/axes")
@AllArgsConstructor
public class AxeController {
private final AxeUseCase axeRepository;
@GetMapping
public Iterable<Axe> findAll() {
return axeRepository.findAll();
}
}

View File

@ -0,0 +1,47 @@
package fr.itsonus.bousoleplussbackend.controllers;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Bundle;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Question;
import fr.itsonus.bousoleplussbackend.payload.request.BundleCreationRequest;
import fr.itsonus.bousoleplussbackend.usecase.BundleUseCase;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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 java.util.List;
@Slf4j
@RestController
@RequestMapping("/bundles")
@AllArgsConstructor
public class BundleController {
private final BundleUseCase bundleUseCase;
@PostMapping
public Bundle create(@Valid @RequestBody BundleCreationRequest request) {
return bundleUseCase.create(request.label(), request.presentation(), request.questions().stream().map(BundleCreationRequest.QuestionCreationRequest::toQuestion).toList());
}
@GetMapping
public List<Bundle> findAllForCurrentUser() {
return bundleUseCase.findAllForCurrentUser();
}
@GetMapping("{id}")
public Bundle findById(@PathVariable("id") Long id) {
return bundleUseCase.findById(id);
}
@GetMapping("{id}/questions/search")
public Iterable<Question> findQuestions(@PathVariable Long id, @RequestParam Long axeId) {
return bundleUseCase.findQuestions(id, axeId);
}
}

View File

@ -0,0 +1,31 @@
package fr.itsonus.bousoleplussbackend.controllers;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Question;
import fr.itsonus.bousoleplussbackend.usecase.QuestionUseCase;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/questions")
@AllArgsConstructor
public class QuestionController {
private final QuestionUseCase questionUseCase;
@GetMapping("search/defaults")
public List<Question> findDefaultsByAxeId(@RequestParam Long axeId) {
return questionUseCase.findDefaults(axeId);
}
@GetMapping("search")
public List<Question> findAllByAxeId(@RequestParam Long axeId) {
return questionUseCase.findAllByAxeId(axeId);
}
}

View File

@ -1,58 +1,42 @@
package fr.itsonus.bousoleplussbackend.controllers;
import fr.itsonus.bousoleplussbackend.models.Quiz;
import fr.itsonus.bousoleplussbackend.models.QuizScore;
import fr.itsonus.bousoleplussbackend.models.Response;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Quiz;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.QuizDetailed;
import fr.itsonus.bousoleplussbackend.payload.request.QuizRequest;
import fr.itsonus.bousoleplussbackend.repositories.QuestionRepository;
import fr.itsonus.bousoleplussbackend.repositories.QuizRepository;
import fr.itsonus.bousoleplussbackend.repositories.QuizScoreRepository;
import fr.itsonus.bousoleplussbackend.repositories.ResponseRepository;
import fr.itsonus.bousoleplussbackend.usecase.QuizUseCase;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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 javax.validation.Valid;
import java.util.NoSuchElementException;
import java.util.stream.Collectors;
@Slf4j
@RestController
@RequestMapping("/quizzes")
@AllArgsConstructor
public class QuizController {
private QuizRepository quizRepository;
private ResponseRepository responseRepository;
private QuestionRepository questionRepository;
private QuizScoreRepository quizScoreRepository;
private final QuizUseCase quizUseCase;
@PostMapping("batch") // TODO add to REST representation
@PostMapping
public Quiz create(@Valid @RequestBody QuizRequest request) {
var quiz = quizRepository.save(new Quiz());
var responsesCreated = request.getResponses().stream().map(response -> {
// TODO add correct exception with correct status code
var question = questionRepository.findById(response.getQuestionId())
.orElseThrow(() -> new NoSuchElementException("No such question with id " + response.getQuestionId()));
log.info("Saving {}", response);
return responseRepository.save(
new Response()
.setScore(response.getScore())
.setComment(response.getComment())
.setQuiz(quiz)
.setQuestion(question));
}).collect(Collectors.toSet());
quiz.setResponses(responsesCreated);
return quiz;
return quizUseCase.create(request);
}
@GetMapping("{id}/scores")
public Iterable<QuizScore> findScores(@PathVariable("id") Long id) {
return this.quizScoreRepository.findAllByQuizId(id);
@GetMapping("search")
public Page<QuizDetailed> findAllForCurrentUser(@RequestParam Long bundleId, Pageable pageable) {
return this.quizUseCase.findAllForCurrentUser(bundleId, pageable);
}
@GetMapping("{id}")
public QuizDetailed findById(@PathVariable("id") Long id) {
return this.quizUseCase.findById(id);
}
}

View File

@ -0,0 +1,23 @@
package fr.itsonus.bousoleplussbackend.domain.auth;
import fr.itsonus.bousoleplussbackend.domain.auth.model.User;
import fr.itsonus.bousoleplussbackend.domain.auth.spi.UserCacheRepository;
import fr.itsonus.bousoleplussbackend.exception.NoCurrentUserException;
import lombok.AllArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
@Service
@AllArgsConstructor
public class AuthenticationService {
private final UserCacheRepository userCacheRepository;
public User getCurrentUser() {
var authentication = SecurityContextHolder.getContext().getAuthentication();
var userDetails = (UserDetails) authentication.getPrincipal();
return userCacheRepository.findByEmail(userDetails.getUsername())
.orElseThrow(NoCurrentUserException::new);
}
}

View File

@ -0,0 +1,7 @@
package fr.itsonus.bousoleplussbackend.domain.auth.exception;
public class AlreadyExistingUserException extends RuntimeException {
public AlreadyExistingUserException(String message) {
super(message);
}
}

View File

@ -0,0 +1,18 @@
package fr.itsonus.bousoleplussbackend.domain.auth.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.time.Instant;
@Builder(toBuilder = true)
@Getter
@AllArgsConstructor
public class RefreshToken {
private Long id;
private User user;
private String token;
private Instant expiryDate;
}

View File

@ -0,0 +1,22 @@
package fr.itsonus.bousoleplussbackend.domain.auth.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
@Builder(toBuilder = true)
@Getter
@AllArgsConstructor
public class User {
private Long id;
private String email;
private String username;
@JsonIgnore
private String password;
@JsonIgnore
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,18 @@
package fr.itsonus.bousoleplussbackend.domain.auth.spi;
import fr.itsonus.bousoleplussbackend.domain.auth.model.RefreshToken;
import fr.itsonus.bousoleplussbackend.domain.auth.model.User;
import java.util.Optional;
public interface RefreshTokenCacheRepository {
Optional<RefreshToken> findByToken(String token);
Optional<RefreshToken> findByUser(User user);
void deleteByUserId(long userId);
RefreshToken save(RefreshToken refreshToken);
void delete(RefreshToken refreshToken);
}

View File

@ -0,0 +1,15 @@
package fr.itsonus.bousoleplussbackend.domain.auth.spi;
import fr.itsonus.bousoleplussbackend.domain.auth.model.User;
import java.util.Optional;
public interface UserCacheRepository {
Optional<User> findByEmail(String email);
User save(User user);
Boolean existsByEmail(String email);
Optional<User> findById(Long id);
}

View File

@ -0,0 +1,6 @@
package fr.itsonus.bousoleplussbackend.domain.notification;
public interface NotificationService {
void notify(String from, String to, String subject, String text);
}

View File

@ -0,0 +1,15 @@
package fr.itsonus.bousoleplussbackend.domain.quiz.model;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class Axe {
private Long id;
private Integer identifier;
private String shortTitle;
private String title;
private String description;
private String color;
}

View File

@ -0,0 +1,17 @@
package fr.itsonus.bousoleplussbackend.domain.quiz.model;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder(toBuilder = true)
public class AxeResponses {
private Integer axeIdentifier;
private Double average;
private List<QuizResponse> responses;
}

View File

@ -0,0 +1,18 @@
package fr.itsonus.bousoleplussbackend.domain.quiz.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.util.Date;
@Data
@Builder(toBuilder = true)
public class Bundle {
private Long id;
private String label;
private String presentation;
@JsonInclude(JsonInclude.Include.NON_NULL)
private Date lastQuizzDate;
private Integer numberOfQuizzes;
}

View File

@ -0,0 +1,20 @@
package fr.itsonus.bousoleplussbackend.domain.quiz.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Builder;
import lombok.Data;
@Data
@Builder(toBuilder = true)
public class Question {
private Long id;
private String label;
private String description;
@JsonIgnore
private Long axeId;
@JsonIgnore
private Integer index;
}

View File

@ -0,0 +1,12 @@
package fr.itsonus.bousoleplussbackend.domain.quiz.model;
import lombok.Data;
import java.util.Date;
@Data
public class Quiz {
private Long id;
private Date createdDate;
}

View File

@ -0,0 +1,34 @@
package fr.itsonus.bousoleplussbackend.domain.quiz.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
@Data
public class QuizDetailed {
private Long id;
private Date createdDate;
private List<AxeResponses> axes = new ArrayList<>();
public static QuizDetailed from(Quiz quiz, List<QuizResponse> responses) {
var resume = new QuizDetailed();
resume.setId(quiz.getId());
resume.setCreatedDate(quiz.getCreatedDate());
var sortedResponses = new HashMap<Integer, AxeResponses>();
responses.forEach(quizResponse -> sortedResponses.compute(quizResponse.axeIdentifier(), (k, v) -> {
var axeResponses = Objects.requireNonNullElseGet(v, () -> AxeResponses.builder().axeIdentifier(k).responses(new ArrayList<>()).build());
axeResponses.getResponses().add(quizResponse);
return axeResponses;
}));
resume.axes = sortedResponses.values().stream().toList();
resume.axes.forEach(axeResponses -> {
var average = axeResponses.getResponses().stream().mapToDouble(QuizResponse::score).average().orElse(0.0);
axeResponses.setAverage(average);
});
return resume;
}
}

View File

@ -0,0 +1,6 @@
package fr.itsonus.bousoleplussbackend.domain.quiz.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
public record QuizResponse(@JsonIgnore Integer axeIdentifier, String question, Short score, String comment) {
}

View File

@ -0,0 +1,7 @@
package fr.itsonus.bousoleplussbackend.domain.quiz.spi;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Axe;
public interface AxeCacheRepository {
Iterable<Axe> findAll();
}

View File

@ -0,0 +1,16 @@
package fr.itsonus.bousoleplussbackend.domain.quiz.spi;
import fr.itsonus.bousoleplussbackend.domain.auth.model.User;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Bundle;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Question;
import java.util.List;
public interface BundleCacheRepository {
Bundle findByIdAndUserId(Long id, Long userId);
List<Bundle> findAllByUser(User user);
Bundle save(String label, String description, List<Question> questions, User user);
}

View File

@ -0,0 +1,15 @@
package fr.itsonus.bousoleplussbackend.domain.quiz.spi;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Question;
import java.util.List;
public interface QuestionCacheRepository {
List<Question> findQuestions(Long axeId, Long bundleId);
List<Question> findDefaultQuestions();
List<Question> findDefaultQuestions(Long axeId);
List<Question> findAllByAxeId(Long axeId);
}

View File

@ -0,0 +1,19 @@
package fr.itsonus.bousoleplussbackend.domain.quiz.spi;
import fr.itsonus.bousoleplussbackend.domain.auth.model.User;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Quiz;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.Optional;
public interface QuizCacheRepository {
Optional<Quiz> findById(Long id);
Optional<Quiz> findByIdAndUserId(Long id, Long userId);
Page<Quiz> findAllByBundleId(Long bundleId, Pageable pageable);
Quiz createNew(Long bundleId, User user);
}

View File

@ -0,0 +1,15 @@
package fr.itsonus.bousoleplussbackend.domain.quiz.spi;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Quiz;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.QuizResponse;
import fr.itsonus.bousoleplussbackend.payload.request.ResponseRequest;
import java.util.List;
import java.util.Set;
public interface ResponseCacheRepository {
List<QuizResponse> findAllByQuizId(Long quizId);
void saveQuizzResponses(Long bundleId, Quiz quiz, Set<ResponseRequest> responses);
}

View File

@ -0,0 +1,7 @@
package fr.itsonus.bousoleplussbackend.exception;
public class InvalidPasswordException extends RuntimeException {
public InvalidPasswordException(String message) {
super(message);
}
}

View File

@ -0,0 +1,7 @@
package fr.itsonus.bousoleplussbackend.exception;
public class NoCurrentUserException extends RuntimeException {
public NoCurrentUserException() {
super("No current user logged");
}
}

View File

@ -1,13 +1,7 @@
package fr.itsonus.bousoleplussbackend.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.FORBIDDEN)
public class TokenRefreshException extends RuntimeException {
private static final long serialVersionUID = 1L;
public TokenRefreshException(String token, String message) {
super(String.format("Failed for [%s]: %s", token, message));
}

View File

@ -0,0 +1,8 @@
package fr.itsonus.bousoleplussbackend.exception;
public class UnknownEmailException extends RuntimeException {
public UnknownEmailException() {
super("Cet e-mail est inconnu.");
}
}

View File

@ -0,0 +1,118 @@
package fr.itsonus.bousoleplussbackend.exception.handlers;
import fr.itsonus.bousoleplussbackend.domain.auth.exception.AlreadyExistingUserException;
import fr.itsonus.bousoleplussbackend.exception.InvalidPasswordException;
import fr.itsonus.bousoleplussbackend.exception.NoCurrentUserException;
import fr.itsonus.bousoleplussbackend.exception.TokenRefreshException;
import fr.itsonus.bousoleplussbackend.payload.response.ApiError;
import fr.itsonus.bousoleplussbackend.exception.UnknownEmailException;
import jakarta.validation.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.rest.webmvc.ResourceNotFoundException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.util.ArrayList;
@ControllerAdvice
public class ExceptionsHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler({ConstraintViolationException.class})
public ResponseEntity<ApiError> handleValidationExceptions(ConstraintViolationException ex) {
var errors = new ArrayList<ApiError.FieldError>();
ex.getConstraintViolations().forEach(error -> errors.add(new ApiError.FieldError(error.getMessage(), error.getPropertyPath().toString())));
return new ApiError("Saisie invalide", errors).toResponse(HttpStatus.BAD_REQUEST);
}
@ExceptionHandler({AlreadyExistingUserException.class})
public ResponseEntity<ApiError> handleConflictException(RuntimeException ex) {
return new ApiError(ex.getMessage()).toResponse(HttpStatus.CONFLICT);
}
@ExceptionHandler({DataIntegrityViolationException.class})
public ResponseEntity<ApiError> handleIntegrityViolationExceptions(DataIntegrityViolationException ex) {
return new ApiError("Saisie invalide: " + ex.getMessage()).toResponse(HttpStatus.BAD_REQUEST);
}
@ExceptionHandler({InvalidPasswordException.class, UnknownEmailException.class})
public ResponseEntity<ApiError> handleBadRequestException(RuntimeException ex) {
return new ApiError(ex.getMessage()).toResponse(HttpStatus.BAD_REQUEST);
}
@ExceptionHandler({ResourceNotFoundException.class})
public ResponseEntity<ApiError> handleResourceNotFoundException(ResourceNotFoundException ex) {
return new ApiError(ex.getMessage()).toResponse(HttpStatus.NOT_FOUND);
}
@ExceptionHandler({AuthenticationException.class})
public ResponseEntity<ApiError> handleAuthenticationException(Exception ex) {
return new ApiError("Authentication failed: " + ex.getMessage()).toResponse(HttpStatus.UNAUTHORIZED);
}
@ExceptionHandler({AccessDeniedException.class, TokenRefreshException.class})
public ResponseEntity<ApiError> handleAccessDeniedException(Exception ex) {
return new ApiError("Authentication needed: " + ex.getMessage()).toResponse(HttpStatus.FORBIDDEN);
}
@ExceptionHandler({BadCredentialsException.class})
public ResponseEntity<ApiError> handleBadCredentialsException(Exception ex) {
logger.error("Exception raised", ex);
return new ApiError("Vos identifiants sont incorrects.").toResponse(HttpStatus.UNAUTHORIZED);
}
@ExceptionHandler({NoCurrentUserException.class})
public ResponseEntity<ApiError> handleInternalErrorException(RuntimeException ex) {
logger.error("Internal error", ex);
return new ApiError(ex.getMessage()).toResponse(HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler({RestClientResponseException.class})
@ResponseBody
public ResponseEntity<ApiError> handleHttpClientErrorException(RestClientResponseException ex) {
return new ApiError(ex.getStatusText()).toResponse(ex.getStatusCode());
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
var errors = new ArrayList<ApiError.FieldError>();
ex.getBindingResult().getFieldErrors()
.forEach(error -> errors.add(new ApiError.FieldError(error.getDefaultMessage(), error.getField())));
ex.getBindingResult().getGlobalErrors()
.forEach(error -> errors.add(new ApiError.FieldError(error.getDefaultMessage(), error.getObjectName())));
var apiError = new ApiError("Saisie invalide", errors);
return ResponseEntity.status(status).body(apiError);
}
@ExceptionHandler({Throwable.class})
@ResponseBody
public ResponseEntity<ApiError> handleGlobally(Throwable ex) {
if (logger.isDebugEnabled()) {
logger.warn(ex.getMessage(), ex);
} else {
logger.warn(ex.getMessage());
}
return new ApiError(ex.getMessage()).toResponse(HttpStatus.INTERNAL_SERVER_ERROR);
}
@Override
protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers,
HttpStatusCode statusCode, WebRequest request) {
logger.debug("An exception occurred", ex);
return super.handleExceptionInternal(ex, new ApiError(ex.getMessage()), headers, statusCode, request);
}
}

View File

@ -0,0 +1,24 @@
package fr.itsonus.bousoleplussbackend.infrastructure.notification;
import fr.itsonus.bousoleplussbackend.domain.notification.NotificationService;
import lombok.AllArgsConstructor;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
@Service
@AllArgsConstructor
public class SpringMailService implements NotificationService {
private final JavaMailSender mailSender;
@Override
public void notify(String from, String to, String subject, String text) {
var message = new SimpleMailMessage();
message.setFrom(from);
message.setTo(to);
message.setSubject(subject);
message.setText(text);
mailSender.send(message);
}
}

View File

@ -0,0 +1,20 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresRefreshTokenDto;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresUserDto;
import org.springframework.data.repository.Repository;
import java.util.Optional;
public interface RefreshTokenCachePostgresRepository extends Repository<PostgresRefreshTokenDto, Long> {
Optional<PostgresRefreshTokenDto> findByToken(String token);
Optional<PostgresRefreshTokenDto> findByUser(PostgresUserDto user);
void deleteByUserId(long userId);
PostgresRefreshTokenDto save(PostgresRefreshTokenDto refreshToken);
void delete(PostgresRefreshTokenDto refreshToken);
}

View File

@ -0,0 +1,47 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres;
import fr.itsonus.bousoleplussbackend.domain.auth.model.RefreshToken;
import fr.itsonus.bousoleplussbackend.domain.auth.model.User;
import fr.itsonus.bousoleplussbackend.domain.auth.spi.RefreshTokenCacheRepository;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresRefreshTokenDto;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresUserDto;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@AllArgsConstructor
public class RefreshTokenCacheProxyRepository implements RefreshTokenCacheRepository {
private RefreshTokenCachePostgresRepository refreshTokenRepository;
@Override
public Optional<RefreshToken> findByToken(String token) {
return refreshTokenRepository.findByToken(token).map(PostgresRefreshTokenDto::toRefreshToken);
}
@Override
public Optional<RefreshToken> findByUser(User user) {
return refreshTokenRepository.findByUser(new PostgresUserDto(user)).map(PostgresRefreshTokenDto::toRefreshToken);
}
@Override
public void deleteByUserId(long userId) {
refreshTokenRepository.deleteByUserId(userId);
}
@Override
public RefreshToken save(RefreshToken refreshToken) {
User user = refreshToken.getUser();
var userDto = new PostgresUserDto(user);
var refreshTokenDto = new PostgresRefreshTokenDto(refreshToken);
refreshTokenDto.setUser(userDto);
return refreshTokenRepository.save(refreshTokenDto).toRefreshToken();
}
@Override
public void delete(RefreshToken refreshToken) {
refreshTokenRepository.delete(new PostgresRefreshTokenDto(refreshToken));
}
}

View File

@ -0,0 +1,16 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresUserDto;
import org.springframework.data.repository.Repository;
import java.util.Optional;
public interface UserCachePostgresRepository extends Repository<PostgresUserDto, Long> {
Optional<PostgresUserDto> findByEmail(String email);
PostgresUserDto save(PostgresUserDto entity);
Boolean existsByEmail(String email);
Optional<PostgresUserDto> findById(Long id);
}

View File

@ -0,0 +1,39 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres;
import fr.itsonus.bousoleplussbackend.domain.auth.model.User;
import fr.itsonus.bousoleplussbackend.domain.auth.spi.UserCacheRepository;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresUserDto;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@AllArgsConstructor
public class UserCacheProxyRepository implements UserCacheRepository {
private final UserCachePostgresRepository userRepository;
@Override
public Optional<User> findByEmail(String email) {
return userRepository.findByEmail(email)
.map(PostgresUserDto::toUser);
}
@Override
public User save(User user) {
var postgresUserDto = new PostgresUserDto(user);
return userRepository.save(postgresUserDto).toUser();
}
@Override
public Boolean existsByEmail(String email) {
return userRepository.existsByEmail(email);
}
@Override
public Optional<User> findById(Long id) {
return userRepository.findById(id)
.map(PostgresUserDto::toUser);
}
}

View File

@ -1,18 +1,19 @@
package fr.itsonus.bousoleplussbackend.models;
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.models;
import com.fasterxml.jackson.annotation.JsonIgnore;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Axe;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.Hibernate;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.OneToMany;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.util.Objects;
import java.util.Set;
@ -20,8 +21,8 @@ import java.util.Set;
@Setter
@ToString
@RequiredArgsConstructor
@Entity
public class Axe {
@Entity(name = "axe")
public class PostgresAxeDto {
@Id
@GeneratedValue
@ -46,13 +47,24 @@ public class Axe {
@OneToMany(mappedBy = "axe")
@ToString.Exclude
@JsonIgnore
private Set<Question> questions;
private Set<PostgresQuestionDto> questions;
public Axe toAxe() {
return Axe.builder()
.id(id)
.identifier(identifier)
.shortTitle(shortTitle)
.title(title)
.description(description)
.color(color)
.build();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Axe axe = (Axe) o;
PostgresAxeDto axe = (PostgresAxeDto) o;
return id != null && Objects.equals(id, axe.id);
}

View File

@ -0,0 +1,71 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.models;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Bundle;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.Hibernate;
import java.util.Comparator;
import java.util.Objects;
import java.util.Set;
@Getter
@Setter
@ToString
@Entity(name = "bundle")
public class PostgresBundleDto {
@Id
@GeneratedValue
private Long id;
@NotBlank
@Size(max = 50)
private String label;
private String presentation;
@OneToMany(mappedBy = "bundle")
@ToString.Exclude
private Set<PostgresQuestionDto> questions;
@OneToMany(mappedBy = "bundle")
@ToString.Exclude
private Set<PostgresQuizDto> quizzes;
@ManyToOne
@JoinColumn(name = "user_id")
private PostgresUserDto user;
public Bundle toBundle() {
return Bundle.builder()
.id(id)
.label(label)
.presentation(presentation)
.lastQuizzDate(quizzes != null ? quizzes.stream().max(Comparator.comparing(PostgresQuizDto::getCreatedDate)).map(PostgresQuizDto::getCreatedDate).orElse(null): null)
.numberOfQuizzes(quizzes != null ? quizzes.size(): 0)
.build();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
PostgresBundleDto axe = (PostgresBundleDto) o;
return id != null && Objects.equals(id, axe.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}

View File

@ -0,0 +1,74 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.models;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Question;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.Hibernate;
import java.util.Objects;
@Getter
@Setter
@ToString
@RequiredArgsConstructor
@Entity(name = "question")
public class PostgresQuestionDto {
@Id
@GeneratedValue
private Long id;
@ManyToOne
@JoinColumn(name = "axe_id")
@ToString.Exclude
private PostgresAxeDto axe;
@NotBlank
@Size(max = 200)
@Column(nullable = false)
private String label;
@Size(max = 500)
private String description;
@ManyToOne
@JoinColumn(name = "bundle_id")
@ToString.Exclude
private PostgresBundleDto bundle;
@Column(nullable = false)
private Integer index;
public Question toQuestion() {
return Question.builder()
.id(id)
.axeId(axe.getId())
.label(label)
.index(index)
.description(description)
.build();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
var question = (PostgresQuestionDto) o;
return id != null && Objects.equals(id, question.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}

View File

@ -0,0 +1,66 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.models;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Quiz;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.Hibernate;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.util.Date;
import java.util.Objects;
import java.util.Set;
@Getter
@Setter
@ToString
@NoArgsConstructor
@Entity(name = "quiz")
@EntityListeners(AuditingEntityListener.class)
public class PostgresQuizDto {
@Id
@GeneratedValue
private Long id;
@Column(name = "created_date", nullable = false, updatable = false)
@CreatedDate
private Date createdDate;
@OneToMany(mappedBy = "quiz")
@ToString.Exclude
private Set<PostgresQuizResponseDto> responses;
@ManyToOne
@JoinColumn(name = "bundle_id")
private PostgresBundleDto bundle;
public Quiz toQuiz() {
var quiz = new Quiz();
quiz.setId(id);
quiz.setCreatedDate(createdDate);
return quiz;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
PostgresQuizDto that = (PostgresQuizDto) o;
return id != null && Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}

View File

@ -1,6 +1,15 @@
package fr.itsonus.bousoleplussbackend.models;
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.models;
import com.fasterxml.jackson.annotation.JsonIgnore;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.QuizResponse;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToOne;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
@ -9,14 +18,6 @@ import lombok.experimental.Accessors;
import org.hibernate.Hibernate;
import org.hibernate.validator.constraints.Length;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.OneToOne;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import java.util.Objects;
@Getter
@ -24,8 +25,8 @@ import java.util.Objects;
@ToString
@RequiredArgsConstructor
@Accessors(chain = true)
@Entity
public class Response {
@Entity(name = "response")
public class PostgresQuizResponseDto {
@Id
@GeneratedValue
@ -33,12 +34,12 @@ public class Response {
@OneToOne
@JoinColumn(name = "question_id")
private Question question;
private PostgresQuestionDto question;
@ManyToOne
@JoinColumn(name = "quiz_id")
@JsonIgnore
private Quiz quiz;
private PostgresQuizDto quiz;
@Min(0)
@Max(10)
@ -47,11 +48,19 @@ public class Response {
@Length(max = 500)
private String comment;
public QuizResponse toQuizResponse() {
return new QuizResponse(
getQuestion().getAxe().getIdentifier(),
getQuestion().getLabel(),
getScore(),
getComment());
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Response response = (Response) o;
PostgresQuizResponseDto response = (PostgresQuizResponseDto) o;
return id != null && Objects.equals(id, response.id);
}

View File

@ -0,0 +1,66 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.models;
import fr.itsonus.bousoleplussbackend.domain.auth.model.RefreshToken;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.Hibernate;
import java.time.Instant;
import java.util.Objects;
@Getter
@Setter
@ToString
@RequiredArgsConstructor
@Entity(name = "refresh_token")
public class PostgresRefreshTokenDto {
@Id
@GeneratedValue
private Long id;
@OneToOne
@JoinColumn(name = "user_id", referencedColumnName = "id")
private PostgresUserDto user;
@Column(nullable = false, unique = true)
private String token;
@Column(nullable = false)
private Instant expiryDate;
public PostgresRefreshTokenDto(RefreshToken refreshToken) {
this.id = refreshToken.getId();
this.token = refreshToken.getToken();
this.expiryDate = refreshToken.getExpiryDate();
}
public RefreshToken toRefreshToken() {
return RefreshToken.builder()
.id(id)
.user(user.toUser())
.token(token)
.expiryDate(expiryDate)
.build();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
PostgresRefreshTokenDto that = (PostgresRefreshTokenDto) o;
return id != null && Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}

View File

@ -0,0 +1,87 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.models;
import fr.itsonus.bousoleplussbackend.domain.auth.model.User;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.Hibernate;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import java.util.Objects;
@Getter
@Setter
@ToString
@RequiredArgsConstructor
@Entity
@Table(name = "users",
uniqueConstraints = {
@UniqueConstraint(columnNames = "username")
})
@EntityListeners(AuditingEntityListener.class)
public class PostgresUserDto {
@Id
@GeneratedValue
private Long id;
@Email
@Size(max = 100)
@NotBlank
@Column(unique = true, nullable = false, length = 100)
private String email;
@NotBlank
@Size(max = 20)
private String username;
@NotBlank
@Size(max = 120)
private String password;
@Column(name = "created_date", nullable = false, updatable = false)
@CreatedDate
private LocalDateTime createdDate;
public PostgresUserDto(User user) {
this.id = user.getId();
this.email = user.getEmail();
this.username = user.getUsername();
this.password = user.getPassword();
}
public User toUser() {
return User.builder()
.id(id)
.email(email)
.username(username)
.password(password)
.createdAt(createdDate)
.build();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
PostgresUserDto user = (PostgresUserDto) o;
return id != null && Objects.equals(id, user.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}

View File

@ -0,0 +1,12 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.repositories;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresAxeDto;
import org.springframework.data.repository.Repository;
import java.util.List;
public interface AxeCachePostgresRepository extends Repository<PostgresAxeDto, Long> {
PostgresAxeDto findById(Long id);
List<PostgresAxeDto> findAll();
}

View File

@ -0,0 +1,20 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.repositories;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Axe;
import fr.itsonus.bousoleplussbackend.domain.quiz.spi.AxeCacheRepository;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresAxeDto;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@AllArgsConstructor
public class AxeCacheProxyRepository implements AxeCacheRepository {
private final AxeCachePostgresRepository axeCachePostgresRepository;
public Iterable<Axe> findAll() {
return axeCachePostgresRepository.findAll()
.stream()
.map(PostgresAxeDto::toAxe)
.toList();
}
}

View File

@ -0,0 +1,16 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.repositories;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresBundleDto;
import org.springframework.data.repository.Repository;
import java.util.List;
import java.util.Optional;
public interface BundleCachePostgresRepository extends Repository<PostgresBundleDto, Long> {
Optional<PostgresBundleDto> findByIdAndUserId(Long id, Long user_id);
List<PostgresBundleDto> findAllByUserId(Long userId);
PostgresBundleDto save(PostgresBundleDto postgresBundleDto);
}

View File

@ -0,0 +1,58 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.repositories;
import fr.itsonus.bousoleplussbackend.domain.auth.model.User;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Bundle;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Question;
import fr.itsonus.bousoleplussbackend.domain.quiz.spi.BundleCacheRepository;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.UserCachePostgresRepository;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresBundleDto;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresQuestionDto;
import jakarta.transaction.Transactional;
import lombok.AllArgsConstructor;
import org.springframework.data.rest.webmvc.ResourceNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@AllArgsConstructor
public class BundleCacheProxyRepository implements BundleCacheRepository {
private final BundleCachePostgresRepository bundleCachePostgresRepository;
private final QuestionCachePostgresRepository questionCachePostgresRepository;
private final AxeCachePostgresRepository axeCachePostgresRepository;
private final UserCachePostgresRepository userCachePostgresRepository;
public Bundle findByIdAndUserId(Long id, Long userId) {
return bundleCachePostgresRepository.findByIdAndUserId(id, userId)
.map(PostgresBundleDto::toBundle)
.orElseThrow(() -> new ResourceNotFoundException("Bundle not found"));
}
@Override
public List<Bundle> findAllByUser(User user) {
return bundleCachePostgresRepository.findAllByUserId(user.getId())
.stream().map(PostgresBundleDto::toBundle)
.toList();
}
@Override
@Transactional
public Bundle save(String label, String presentation, List<Question> questions, User user) {
var entity = new PostgresBundleDto();
entity.setLabel(label);
entity.setPresentation(presentation);
entity.setUser(userCachePostgresRepository.findById(user.getId()).orElseThrow());
var newEntity = bundleCachePostgresRepository.save(entity);
questions.forEach(question -> {
var questionEntity = new PostgresQuestionDto();
questionEntity.setBundle(newEntity);
questionEntity.setDescription(question.getDescription());
questionEntity.setLabel(question.getLabel());
questionEntity.setAxe(axeCachePostgresRepository.findById(question.getAxeId()));
questionEntity.setIndex(question.getIndex());
questionCachePostgresRepository.save(questionEntity);
});
return newEntity.toBundle();
}
}

View File

@ -0,0 +1,22 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.repositories;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresQuestionDto;
import org.springframework.data.repository.Repository;
import java.util.List;
import java.util.Optional;
public interface QuestionCachePostgresRepository extends Repository<PostgresQuestionDto, Long> {
List<PostgresQuestionDto> findAllByAxeIdAndBundleIdOrderByIndexAsc(final Long axeId, final Long userId);
List<PostgresQuestionDto> findAllByBundleIsNull();
List<PostgresQuestionDto> findAllByAxeIdAndBundleIsNullOrderByIndexAsc(final Long axeId);
Optional<PostgresQuestionDto> findByIdAndBundleId(Long questionId, Long bundleId);
PostgresQuestionDto save(PostgresQuestionDto entity);
List<PostgresQuestionDto> findAllByAxeIdAndBundleIsNotNullOrderByIndexAsc(Long axeId);
}

View File

@ -0,0 +1,46 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.repositories;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Question;
import fr.itsonus.bousoleplussbackend.domain.quiz.spi.QuestionCacheRepository;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresQuestionDto;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@AllArgsConstructor
public class QuestionCacheProxyRepository implements QuestionCacheRepository {
private final QuestionCachePostgresRepository questionCachePostgresRepository;
public List<Question> findQuestions(Long axeId, Long bundleId) {
return questionCachePostgresRepository.findAllByAxeIdAndBundleIdOrderByIndexAsc(axeId, bundleId)
.stream()
.map(PostgresQuestionDto::toQuestion).collect(Collectors.toList());
}
public List<Question> findDefaultQuestions() {
return questionCachePostgresRepository.findAllByBundleIsNull()
.stream()
.map(PostgresQuestionDto::toQuestion).collect(Collectors.toList());
}
public List<Question> findDefaultQuestions(Long axeId) {
return questionCachePostgresRepository.findAllByAxeIdAndBundleIsNullOrderByIndexAsc(axeId)
.stream()
.map(PostgresQuestionDto::toQuestion).collect(Collectors.toList());
}
@Override
public List<Question> findAllByAxeId(Long axeId) {
var questions = questionCachePostgresRepository.findAllByAxeIdAndBundleIsNotNullOrderByIndexAsc(axeId)
.stream()
.map(PostgresQuestionDto::toQuestion).toList();
Set<String> set = new HashSet<>(questions.size());
return questions.stream().filter(q -> set.add(q.getLabel() + q.getDescription())).collect(Collectors.toList());
}
}

View File

@ -0,0 +1,24 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.repositories;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresQuizDto;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.Repository;
import java.nio.channels.FileChannel;
import java.util.Date;
import java.util.List;
import java.util.Optional;
public interface QuizCachePostgresRepository extends Repository<PostgresQuizDto, Long> {
Optional<PostgresQuizDto> findById(Long id);
Page<PostgresQuizDto> findAllByBundleId(Long userId, Pageable pageable);
List<PostgresQuizDto> findAllByCreatedDateBefore(Date createdDate);
PostgresQuizDto save(PostgresQuizDto quiz);
Optional<PostgresQuizDto> findByIdAndBundle_User_Id(Long id, Long userId);
}

View File

@ -0,0 +1,41 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.repositories;
import fr.itsonus.bousoleplussbackend.domain.auth.model.User;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Quiz;
import fr.itsonus.bousoleplussbackend.domain.quiz.spi.QuizCacheRepository;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresQuizDto;
import lombok.AllArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.rest.webmvc.ResourceNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@AllArgsConstructor
public class QuizCacheProxyRepository implements QuizCacheRepository {
private final QuizCachePostgresRepository quizCachePostgresRepository;
private final BundleCachePostgresRepository bundleCachePostgresRepository;
public Optional<Quiz> findById(Long id) {
return quizCachePostgresRepository.findById(id).map(PostgresQuizDto::toQuiz);
}
public Optional<Quiz> findByIdAndUserId(Long id, Long userId) {
return quizCachePostgresRepository.findByIdAndBundle_User_Id(id, userId).map(PostgresQuizDto::toQuiz);
}
public Page<Quiz> findAllByBundleId(Long bundleId, Pageable pageable) {
return quizCachePostgresRepository.findAllByBundleId(bundleId, pageable).map(PostgresQuizDto::toQuiz);
}
public Quiz createNew(Long bundleId, User user) {
var bundle = bundleCachePostgresRepository.findByIdAndUserId(bundleId, user.getId())
.orElseThrow(() -> new ResourceNotFoundException("Bundle not found"));
var quizDto = new PostgresQuizDto();
quizDto.setBundle(bundle);
return quizCachePostgresRepository.save(quizDto).toQuiz();
}
}

View File

@ -0,0 +1,13 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.repositories;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresQuizResponseDto;
import org.springframework.data.repository.Repository;
import java.util.List;
public interface ResponseCachePostgresRepository extends Repository<PostgresQuizResponseDto, Long> {
PostgresQuizResponseDto save(PostgresQuizResponseDto quizResponseDto);
List<PostgresQuizResponseDto> findAllByQuizIdOrderByQuestionIndexAsc(Long quizId);
}

View File

@ -0,0 +1,43 @@
package fr.itsonus.bousoleplussbackend.infrastructure.postgres.repositories;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Quiz;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.QuizResponse;
import fr.itsonus.bousoleplussbackend.domain.quiz.spi.ResponseCacheRepository;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresQuizResponseDto;
import fr.itsonus.bousoleplussbackend.payload.request.ResponseRequest;
import jakarta.transaction.Transactional;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Set;
@Service
@AllArgsConstructor
public class ResponseCacheProxyRepository implements ResponseCacheRepository {
private final ResponseCachePostgresRepository responseCachePostgresRepository;
private final QuestionCachePostgresRepository questionCachePostgresRepository;
private final QuizCachePostgresRepository quizCachePostgresRepository;
@Transactional
public void saveQuizzResponses(Long bundleId, Quiz quiz, Set<ResponseRequest> responses) {
var quizDto = quizCachePostgresRepository.findById(quiz.getId()).orElseThrow();
responses.forEach(response -> {
var question = questionCachePostgresRepository.findByIdAndBundleId(response.getQuestionId(), bundleId)
.orElseThrow(() -> new NoSuchElementException("No such question with id " + response.getQuestionId()));
responseCachePostgresRepository.save(
new PostgresQuizResponseDto()
.setScore(response.getScore())
.setComment(response.getComment())
.setQuiz(quizDto)
.setQuestion(question));
});
}
public List<QuizResponse> findAllByQuizId(Long quizId) {
return responseCachePostgresRepository.findAllByQuizIdOrderByQuestionIndexAsc(quizId)
.stream().map(PostgresQuizResponseDto::toQuizResponse).toList();
}
}

View File

@ -1,66 +0,0 @@
package fr.itsonus.bousoleplussbackend.models;
import com.fasterxml.jackson.annotation.JsonIgnore;
import fr.itsonus.bousoleplussbackend.security.services.UserDetailsImpl;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.Hibernate;
import org.springframework.security.core.context.SecurityContextHolder;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.PrePersist;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.util.Objects;
@Getter
@Setter
@ToString
@RequiredArgsConstructor
@Entity
public class Question {
@Id
@GeneratedValue
private Long id;
@ManyToOne
@JoinColumn(name = "axe_id")
@ToString.Exclude
private Axe axe;
@JsonIgnore
private Long userId;
@NotBlank
@Size(max = 200)
private String label;
@Size(max = 500)
private String description;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
var question = (Question) o;
return id != null && Objects.equals(id, question.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@PrePersist
public void prePersist() {
var userDetails = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
this.userId = userDetails.getId();
}
}

View File

@ -1,70 +0,0 @@
package fr.itsonus.bousoleplussbackend.models;
import com.fasterxml.jackson.annotation.JsonIgnore;
import fr.itsonus.bousoleplussbackend.security.services.UserDetailsImpl;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.Hibernate;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import org.springframework.security.core.context.SecurityContextHolder;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EntityListeners;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.OneToMany;
import javax.persistence.PrePersist;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@Getter
@Setter
@ToString
@RequiredArgsConstructor
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Quiz {
@Id
@GeneratedValue
private Long id;
@Column(name = "created_date", nullable = false, updatable = false)
@CreatedDate
private Date createdDate;
@JsonIgnore
private Long userId;
@OneToMany(mappedBy = "quiz")
@ToString.Exclude
private Set<Response> responses;
@OneToMany(mappedBy = "quiz")
@ToString.Exclude
private List<QuizScore> scores;
@PrePersist
public void prePersist() {
var userDetails = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
this.userId = userDetails.getId();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Quiz that = (Quiz) o;
return id != null && Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}

View File

@ -1,36 +0,0 @@
package fr.itsonus.bousoleplussbackend.models;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.annotations.Immutable;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
@Getter
@Setter
@ToString
@RequiredArgsConstructor
@Entity
@Immutable
public class QuizScore {
@Id
@JsonIgnore
private Long id;
private Float scoreAvg;
private Integer axeIdentifier;
// @JsonIgnore
// private Long quizId;
@ManyToOne
@JoinColumn(name = "quiz_id")
@JsonIgnore
private Quiz quiz;
}

View File

@ -1,51 +0,0 @@
package fr.itsonus.bousoleplussbackend.models;
import lombok.Data;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.Hibernate;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToOne;
import java.time.Instant;
import java.util.Objects;
@Getter
@Setter
@ToString
@RequiredArgsConstructor
@Entity
public class RefreshToken {
@Id
@GeneratedValue
private Long id;
@OneToOne
@JoinColumn(name = "user_id", referencedColumnName = "id")
private User user;
@Column(nullable = false, unique = true)
private String token;
@Column(nullable = false)
private Instant expiryDate;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
RefreshToken that = (RefreshToken) o;
return id != null && Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}

View File

@ -1,54 +0,0 @@
package fr.itsonus.bousoleplussbackend.models;
import lombok.Data;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.Hibernate;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.util.Objects;
@Getter
@Setter
@ToString
@RequiredArgsConstructor
@Entity
@Table(name = "users",
uniqueConstraints = {
@UniqueConstraint(columnNames = "username")
})
public class User {
@Id
@GeneratedValue
private Long id;
@NotBlank
@Size(max = 20)
private String username;
@NotBlank
@Size(max = 120)
private String password;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
User user = (User) o;
return id != null && Objects.equals(id, user.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}

View File

@ -0,0 +1,23 @@
package fr.itsonus.bousoleplussbackend.payload.request;
import fr.itsonus.bousoleplussbackend.domain.quiz.model.Question;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import org.hibernate.validator.constraints.Length;
import java.util.List;
public record BundleCreationRequest(@NotBlank @Length(max = 50) String label, @Length(max = 100) String presentation,
@Valid @NotEmpty List<QuestionCreationRequest> questions) {
public record QuestionCreationRequest(@NotEmpty @Length(max = 200) String label,
@Length(max = 500) String description,
@NotNull Long axeId,
@NotNull Integer index) {
public Question toQuestion() {
return Question.builder().axeId(axeId).label(label).index(index).description(description).build();
}
}
}

View File

@ -1,13 +1,14 @@
package fr.itsonus.bousoleplussbackend.payload.request;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class LoginRequest {
@NotBlank
private String username;
@Email
private String email;
@NotBlank
private String password;

View File

@ -0,0 +1,6 @@
package fr.itsonus.bousoleplussbackend.payload.request;
import jakarta.validation.constraints.Email;
public record NotifyPasswordResetRequest(@Email String email) {
}

View File

@ -1,14 +1,12 @@
package fr.itsonus.bousoleplussbackend.payload.request;
import lombok.Data;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import java.util.Set;
@Data
public class QuizRequest {
@NotEmpty
private Set<@Valid ResponseRequest> responses;
public record QuizRequest(@NotNull Long bundleId,
@NotEmpty Set<@Valid ResponseRequest> responses) {
}

View File

@ -0,0 +1,32 @@
package fr.itsonus.bousoleplussbackend.payload.request;
import fr.itsonus.bousoleplussbackend.domain.auth.model.User;
import fr.itsonus.bousoleplussbackend.payload.validation.Password;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
@Data
public class RegisterRequest {
@NotBlank
@Email
private String email;
@NotBlank
@Length(min = 3, max = 20)
private String username;
@NotBlank
@Password
private String password;
public User toUser() {
return User.builder()
.email(email)
.username(username)
.password(password)
.build();
}
}

View File

@ -0,0 +1,10 @@
package fr.itsonus.bousoleplussbackend.payload.request;
import fr.itsonus.bousoleplussbackend.payload.validation.Password;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record ResetPasswordRequest(@NotBlank String token, @Email String email,
@NotBlank @Password String newPassword,
@NotBlank String confirmationPassword) {
}

View File

@ -1,9 +1,8 @@
package fr.itsonus.bousoleplussbackend.payload.request;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import javax.validation.constraints.NotNull;
@Data
public class ResponseRequest {
@NotNull

View File

@ -1,17 +0,0 @@
package fr.itsonus.bousoleplussbackend.payload.request;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
@Data
public class SignupRequest {
@NotBlank
@Size(min = 3, max = 20)
private String username;
@NotBlank
@Size(min = 6, max = 40)
private String password;
}

View File

@ -1,9 +1,8 @@
package fr.itsonus.bousoleplussbackend.payload.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class TokenRefreshRequest {
@NotBlank

View File

@ -0,0 +1,8 @@
package fr.itsonus.bousoleplussbackend.payload.request;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record UpdateAccountRequest(@NotBlank String username, @NotNull @Email String email) {
}

View File

@ -0,0 +1,9 @@
package fr.itsonus.bousoleplussbackend.payload.request;
import fr.itsonus.bousoleplussbackend.payload.validation.Password;
import jakarta.validation.constraints.NotBlank;
public record UpdatePasswordRequest(@NotBlank String currentPassword,
@NotBlank @Password String newPassword,
@NotBlank String confirmationPassword) {
}

View File

@ -0,0 +1,26 @@
package fr.itsonus.bousoleplussbackend.payload.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import java.util.List;
public record ApiError(String message, @JsonInclude(JsonInclude.Include.NON_EMPTY) List<FieldError> fieldErrors) {
public ApiError(String message) {
this(message, List.of());
}
public ResponseEntity<ApiError> toResponse(HttpStatus status) {
return ResponseEntity.status(status).body(this);
}
public ResponseEntity<ApiError> toResponse(HttpStatusCode status) {
return ResponseEntity.status(status).body(this);
}
public record FieldError(String detail, @JsonInclude(JsonInclude.Include.NON_EMPTY) String... fields) {
}
}

View File

@ -1,15 +1,17 @@
package fr.itsonus.bousoleplussbackend.payload.response;
import fr.itsonus.bousoleplussbackend.models.Question;
import fr.itsonus.bousoleplussbackend.infrastructure.postgres.models.PostgresQuestionDto;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.hateoas.RepresentationModel;
@Data
@EqualsAndHashCode(callSuper = true)
public class AxeWithQuestion extends RepresentationModel<AxeWithQuestion> {
private Integer identifier;
private String shortTitle;
private String title;
private String color;
private Iterable<Question> questions;
private Iterable<PostgresQuestionDto> questions;
}

View File

@ -1,15 +1,12 @@
package fr.itsonus.bousoleplussbackend.payload.response;
import fr.itsonus.bousoleplussbackend.security.jwt.Token;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class JwtResponse {
private static String type = "Bearer";
private String token;
private Token token;
private String refreshToken;
private Long id;
private String username;
}

View File

@ -7,7 +7,7 @@ import lombok.Data;
@AllArgsConstructor
public class TokenRefreshResponse {
private static String tokenType = "Bearer";
private String tokenType;
private String accessToken;
private String refreshToken;
}

View File

@ -0,0 +1,20 @@
package fr.itsonus.bousoleplussbackend.payload.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Documented
@Constraint(validatedBy = PasswordConstraintValidator.class)
@Target( { ElementType.METHOD, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface Password {
String message() default "Votre mot de passe doit fait au moins 8 caractères, et être composé dau moins une majuscule, une minuscule, un chiffre de 0 à 9, et un caractère spécial parmi @?!#$&;,:";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@ -0,0 +1,12 @@
package fr.itsonus.bousoleplussbackend.payload.validation;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class PasswordConstraintValidator implements ConstraintValidator<Password, String> {
@Override
public boolean isValid(String password, ConstraintValidatorContext cxt) {
return password != null && password.matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@?!#$&;,:])[A-Za-z\\d@?!#$&;,:]{8,}$");
}
}

View File

@ -1,11 +0,0 @@
package fr.itsonus.bousoleplussbackend.projections;
import fr.itsonus.bousoleplussbackend.models.Question;
import org.springframework.data.rest.core.config.Projection;
@Projection(types = {Question.class})
public interface QuestionProj {
Long getId();
String getLabel();
String getDescription();
}

View File

@ -1,21 +0,0 @@
package fr.itsonus.bousoleplussbackend.projections;
import fr.itsonus.bousoleplussbackend.models.Quiz;
import fr.itsonus.bousoleplussbackend.models.Response;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.rest.core.config.Projection;
import java.util.Map;
import java.util.Set;
@Projection(name = "responseWithQuestion", types = { Quiz.class })
public interface QuizWithResponses {
String getCreatedDate();
@Value("#{target.getResponses()}")
Set<ResponseWithQuestion> getResponses();
// @Value("#{target.getResponses().stream().collect(Collectors.toMap(value -> value, value -> value.length()))}")
// Map<Integer, ResponseWithQuestion> getResponses();
}

View File

@ -1,20 +0,0 @@
package fr.itsonus.bousoleplussbackend.projections;
import fr.itsonus.bousoleplussbackend.models.Quiz;
import fr.itsonus.bousoleplussbackend.models.QuizScore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.rest.core.config.Projection;
import java.util.Date;
import java.util.List;
@Projection(name = "quizWithScore", types = {Quiz.class})
public interface QuizWithScore {
Long getId();
Date getCreatedDate();
@Value("#{target.getScores()}")
List<QuizScore> getScores();
}

View File

@ -1,18 +0,0 @@
package fr.itsonus.bousoleplussbackend.projections;
import fr.itsonus.bousoleplussbackend.models.Response;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.rest.core.config.Projection;
@Projection(name = "responseWithQuestion", types = { Response.class })
public interface ResponseWithQuestion {
String getComment();
Short getScore();
@Value("#{target.getQuestion().getLabel()}")
String getQuestion();
@Value("#{target.getQuestion().getAxe().getIdentifier()}")
Integer getAxeIdentifier();
}

View File

@ -1,11 +0,0 @@
package fr.itsonus.bousoleplussbackend.repositories;
import fr.itsonus.bousoleplussbackend.models.Axe;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;
@RepositoryRestResource
public interface AxeRepository extends CrudRepository<Axe, Long>, JpaSpecificationExecutor<Axe> {
}

View File

@ -1,17 +0,0 @@
package fr.itsonus.bousoleplussbackend.repositories;
import fr.itsonus.bousoleplussbackend.models.Question;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;
import org.springframework.data.rest.core.annotation.RestResource;
@RepositoryRestResource
public interface QuestionRepository extends CrudRepository<Question, Long> {
@RestResource(path="byAxeId", rel="byAxeId")
@Query("SELECT q FROM Question q JOIN q.axe a WHERE q.axe.id = :id AND user_id = ?#{principal.id}")
Iterable<Question> findAllByAxeId(@Param("id") final Long id);
}

View File

@ -1,18 +0,0 @@
package fr.itsonus.bousoleplussbackend.repositories;
import fr.itsonus.bousoleplussbackend.models.Quiz;
import fr.itsonus.bousoleplussbackend.projections.QuizWithScore;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.PagingAndSortingRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;
import org.springframework.data.rest.core.annotation.RestResource;
@RepositoryRestResource(excerptProjection = QuizWithScore.class)
public interface QuizRepository extends PagingAndSortingRepository<Quiz, Long> {
@RestResource(path = "me", rel = "me")
@Query("SELECT q FROM Quiz q WHERE q.userId = ?#{principal.id}")
Page<Quiz> findAllWithScoresOfCurrentUser(final Pageable pageable);
}

View File

@ -1,11 +0,0 @@
package fr.itsonus.bousoleplussbackend.repositories;
import fr.itsonus.bousoleplussbackend.models.QuizScore;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface QuizScoreRepository extends JpaRepository<QuizScore, Long> {
Iterable<QuizScore> findAllByQuizId(Long quizId);
}

View File

@ -1,17 +0,0 @@
package fr.itsonus.bousoleplussbackend.repositories;
import fr.itsonus.bousoleplussbackend.models.RefreshToken;
import fr.itsonus.bousoleplussbackend.models.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByToken(String token);
@Modifying
int deleteByUser(User user);
}

View File

@ -1,11 +0,0 @@
package fr.itsonus.bousoleplussbackend.repositories;
import fr.itsonus.bousoleplussbackend.models.Response;
import fr.itsonus.bousoleplussbackend.projections.ResponseWithQuestion;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;
@RepositoryRestResource(excerptProjection = ResponseWithQuestion.class)
public interface ResponseRepository extends CrudRepository<Response, Long> {
}

View File

@ -1,14 +0,0 @@
package fr.itsonus.bousoleplussbackend.repositories;
import fr.itsonus.bousoleplussbackend.models.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Boolean existsByUsername(String username);
}

View File

@ -0,0 +1,49 @@
package fr.itsonus.bousoleplussbackend.security;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.data.repository.query.SecurityEvaluationContextExtension;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
@Order(1)
public class CoreSecurityConfig {
@Value("${security.cors.allow-origins}")
private List<String> corsOrigins;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
var configuration = new CorsConfiguration();
configuration.setAllowedOrigins(corsOrigins);
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public SecurityEvaluationContextExtension securityEvaluationContextExtension() {
return new SecurityEvaluationContextExtension();
}
}

View File

@ -0,0 +1,67 @@
package fr.itsonus.bousoleplussbackend.security;
import fr.itsonus.bousoleplussbackend.security.jwt.ExceptionHandlerFilter;
import fr.itsonus.bousoleplussbackend.security.jwt.JwtAuthenticationFilter;
import fr.itsonus.bousoleplussbackend.security.jwt.JwtGenerator;
import fr.itsonus.bousoleplussbackend.security.services.UserDetailsServiceImpl;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.header.HeaderWriterFilter;
import org.springframework.web.cors.CorsConfigurationSource;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {
private final UserDetailsServiceImpl userDetailsService;
private final PasswordEncoder passwordEncoder;
private final CorsConfigurationSource corsConfigurationSource;
private final JwtGenerator jwtGenerator;
private final ExceptionHandlerFilter exceptionHandlerFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(Customizer.withDefaults())
.sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.securityMatcher("/auth/**", "/account/**", "bundles/**", "/quizzes/**", "/axes/**", "questions/**", "/responses/**")
.authorizeHttpRequests(matcher -> matcher
.requestMatchers(HttpMethod.POST,
"/auth/login", "/auth/register", "/auth/refresh-token",
"/account/password/notify-reset-request", "/account/password/reset")
.permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.addFilterBefore(exceptionHandlerFilter, HeaderWriterFilter.class)
.addFilterBefore(userJwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
public JwtAuthenticationFilter userJwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtGenerator, userDetailsService);
}
@Bean
public DaoAuthenticationProvider userAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
}

View File

@ -0,0 +1,20 @@
package fr.itsonus.bousoleplussbackend.security;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigInteger;
import java.security.SecureRandom;
import java.util.Base64;
public class SecurityConstants {
public static final SecretKey JWT_SECRET;
static {
var randomGenerator = new SecureRandom();
byte[] randomBytes = new byte[128];
randomGenerator.nextBytes(randomBytes);
JWT_SECRET = new SecretKeySpec(
Base64.getDecoder().decode(new BigInteger(1, randomBytes).toString(16)),
"HmacSHA256");
}
}

View File

@ -1,45 +0,0 @@
package fr.itsonus.bousoleplussbackend.security.jwt;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.databind.ObjectMapper;
@Component
public class AuthEntryPointJwt implements AuthenticationEntryPoint {
private static final Logger logger = LoggerFactory.getLogger(AuthEntryPointJwt.class);
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
throws IOException, ServletException {
logger.error("Unauthorized error: {}", authException.getMessage());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
final Map<String, Object> body = new HashMap<>();
body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
body.put("error", "Unauthorized");
body.put("message", authException.getMessage());
body.put("path", request.getServletPath());
final ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getOutputStream(), body);
// response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Error: Unauthorized");
}
}

View File

@ -1,57 +0,0 @@
package fr.itsonus.bousoleplussbackend.security.jwt;
import fr.itsonus.bousoleplussbackend.security.services.UserDetailsServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
public class AuthTokenFilter extends OncePerRequestFilter {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
var jwt = parseJwt(request);
if (jwt != null && jwtUtils.validateJwtToken(jwt)) {
String username = jwtUtils.getUserNameFromJwtToken(jwt);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null,
userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
log.error("Cannot set user authentication: {}", e.getMessage());
}
filterChain.doFilter(request, response);
}
private String parseJwt(HttpServletRequest request) {
var headerAuth = request.getHeader("Authorization");
if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
return headerAuth.substring(7);
}
return null;
}
}

View File

@ -0,0 +1,25 @@
package fr.itsonus.bousoleplussbackend.security.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class ExceptionHandlerFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (AuthenticationException e) {
response.sendError(HttpStatus.UNAUTHORIZED.value(), e.getMessage());
}
}
}

View File

@ -0,0 +1,50 @@
package fr.itsonus.bousoleplussbackend.security.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@AllArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private JwtGenerator jwtGenerator;
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
var token = getJWTFromRequest(request);
if (token != null && jwtGenerator.validateToken(token)) {
String subject = jwtGenerator.getSubjectFromJWT(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(subject);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
null, userDetails.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
filterChain.doFilter(request, response);
}
private String getJWTFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
} else {
return null;
}
}
}

Some files were not shown because too many files have changed in this diff Show More