Compare commits

..

153 Commits

Author SHA1 Message Date
b79068623f Update jenkinsfile 2025-12-05 14:27:59 +00:00
Vincent Guillet
3eed3d251f Refactor CORS configuration to use allowed origins and enhance header handling 2025-12-05 15:14:16 +01:00
7dcc85ac95 Update api/src/main/java/fr/gameovergne/api/controller/auth/AuthController.java 2025-12-05 13:57:42 +00:00
ec9eb0dc7d Update api/src/main/java/fr/gameovergne/api/config/SecurityConfig.java 2025-12-05 13:40:48 +00:00
01cafd5904 Update docker-compose.prod.yml 2025-12-05 13:35:48 +00:00
321e2fd546 Update jenkinsfile 2025-12-05 13:27:16 +00:00
3026f0a13f Update jenkinsfile 2025-12-05 13:23:58 +00:00
52d17e5ad8 Update jenkinsfile 2025-12-05 12:56:15 +00:00
2803e910bd Add docker-compose.prod.yml 2025-12-05 12:54:54 +00:00
653ce83c33 Add docker-compose.dev.yml 2025-12-05 12:54:08 +00:00
ce618deecf Update docker-compose.yml.OLD 2025-12-05 12:53:06 +00:00
5331ce7866 Update docker-compose.yml 2025-12-04 09:19:07 +00:00
Vincent Guillet
6f6d033be3 Center align items in main navbar container for improved layout 2025-12-03 22:48:26 +01:00
Vincent Guillet
ff8536b448 Update SecurityConfig to require authentication for /api/app/** endpoints 2025-12-03 22:47:14 +01:00
Vincent Guillet
60593f6c11 Update base URL in index.html for proper routing in Game Over'gne app 2025-12-03 22:46:34 +01:00
Vincent Guillet
1708c1bead Update base URL in index.html for proper routing in Game Over'gne app 2025-12-03 22:11:33 +01:00
Vincent Guillet
dc33d762a1 Refactor app configuration to use hrefBase for base URL and improve provider imports 2025-12-03 21:59:42 +01:00
Vincent Guillet
e04cac3345 Enhance main navbar styles for better overflow handling and safe area support 2025-12-03 21:49:52 +01:00
Vincent Guillet
00f45ae6c7 Add loading indicators to product CRUD and dialog components 2025-12-03 21:46:18 +01:00
Vincent Guillet
1a5d3a570a Add image deletion functionality to product dialog carousel 2025-12-03 21:21:50 +01:00
Vincent Guillet
9763289c2f Export resizeImage function and integrate it into Prestashop service 2025-12-03 19:48:19 +01:00
Vincent Guillet
fd6c730ae3 Add image resizing utility and integrate it into product image upload process 2025-12-03 19:46:31 +01:00
Vincent Guillet
eb94697955 Refactor application.properties to set maximum file and request sizes for multipart uploads 2025-12-03 16:40:57 +01:00
Vincent Guillet
a72957648e Refactor PrestashopClient to improve image upload response handling and enhance error reporting 2025-12-03 15:47:12 +01:00
Vincent Guillet
b15c331295 Refactor PrestashopClient to store basic auth header for improved readability and maintainability 2025-12-03 15:32:44 +01:00
Vincent Guillet
503cbee641 Refactor PrestashopClient to enhance manual multipart image upload handling and improve error logging 2025-12-03 15:19:26 +01:00
Vincent Guillet
078cef0585 Refactor PrestashopClient to enhance image upload logging and improve filename handling 2025-12-03 15:06:53 +01:00
Vincent Guillet
48f7e84ef9 Refactor PrestashopClient to improve multipart image upload construction and enhance header management 2025-12-03 14:53:39 +01:00
Vincent Guillet
f975e57110 Refactor PrestashopClient to improve multipart image upload handling and logging 2025-12-03 14:46:32 +01:00
Vincent Guillet
389beca604 Refactor PrestashopClient to construct absolute URL for image uploads and improve logging 2025-12-03 14:36:40 +01:00
Vincent Guillet
fa7a1c2f26 Refactor PrestashopClient to streamline image upload process and enhance error handling 2025-12-03 14:30:34 +01:00
Vincent Guillet
df98dfe38e Refactor PrestashopClient to enhance image upload handling and improve error logging 2025-12-03 14:22:47 +01:00
Vincent Guillet
65559bbb48 Refactor PrestashopProxyController to simplify image upload API path 2025-12-03 13:51:54 +01:00
Vincent Guillet
f317d15ac5 Refactor PrestashopProxyController to simplify image upload API path 2025-12-03 13:43:29 +01:00
Vincent Guillet
68ccb164e2 Refactor PrestashopProxyController to improve path extraction and enhance logging for proxy requests 2025-12-03 13:33:56 +01:00
Vincent Guillet
a8f9c5f49a Refactor PrestashopProxyController to unify API path handling and enhance error logging for proxy requests 2025-12-03 13:22:43 +01:00
Vincent Guillet
ff331e1630 Refactor PrestashopProxyController to streamline API endpoint handling and improve code readability 2025-12-03 12:28:22 +01:00
Vincent Guillet
d076286728 Add image upload functionality to PrestashopClient and PrestashopProxyController 2025-12-03 12:00:48 +01:00
Vincent Guillet
de942e0d96 Enhance PrestashopClient to use UTF-8 encoding for XML body in POST and PUT methods 2025-12-03 11:39:55 +01:00
Vincent Guillet
ce3389f2e6 Enhance PrestashopClient and PrestashopProxyController to add error handling for POST, PUT, and DELETE methods 2025-12-03 11:26:23 +01:00
Vincent Guillet
8680b2fc92 Enhance PrestashopProxyController and PrestashopClient to support POST, PUT, and DELETE methods with raw query handling 2025-12-03 11:03:26 +01:00
Vincent Guillet
5068390a14 Refactor package structure for Prestashop components and enhance raw query handling in PrestashopProxyController 2025-12-03 10:25:59 +01:00
Vincent Guillet
4fe16b0cb1 restore previous version 2025-12-03 10:04:22 +01:00
Vincent Guillet
42c1e655f1 Refactor PrestashopClient to normalize base URL, enhance URL building, and improve raw query handling 2025-12-03 09:33:30 +01:00
Vincent Guillet
fdb6c40bb9 Refactor PrestashopClient to normalize base URL, enhance URL building, and improve raw query handling 2025-12-02 17:48:27 +01:00
Vincent Guillet
72f3791616 Refactor PrestashopClient and PrestashopProxyController to support raw query handling and simplify proxy GET requests 2025-12-02 17:35:53 +01:00
Vincent Guillet
db8085c0aa Refactor PrestashopClient to enhance header configuration and improve query parameter encoding 2025-12-02 17:23:31 +01:00
Vincent Guillet
177eb2eb5c Refactor URI building in PrestashopClient to simplify query parameter encoding 2025-12-02 16:54:49 +01:00
Vincent Guillet
bceedc8620 Add support for raw query handling in PrestashopClient and update response handling in PrestashopProxyController 2025-12-02 16:35:42 +01:00
Vincent Guillet
14a6f66742 Update Prestashop service to return ID for all valid responses 2025-12-02 16:28:38 +01:00
Vincent Guillet
28faf2ed2b Refactor PrestashopClient to improve URI building and parameter encoding 2025-12-02 16:28:27 +01:00
Vincent Guillet
02387e9a50 Refactor Prestashop service endpoints to remove redundant reference-data prefix 2025-11-30 11:58:02 +01:00
Vincent Guillet
c09316189e Update PrestashopAdminController request mapping to remove redundant API prefix 2025-11-30 11:54:13 +01:00
Vincent Guillet
ad441b8dbc Update Prestashop service endpoints to remove redundant API prefix 2025-11-30 11:53:42 +01:00
Vincent Guillet
9759f2cf8e Update PrestashopAdminController request mapping to include duplicate API prefix 2025-11-30 11:47:57 +01:00
Vincent Guillet
16bd098954 Update API URLs in production environment configuration for improved routing 2025-11-30 11:47:35 +01:00
Vincent Guillet
7e5f75e482 Update API URLs in production environment configuration for consistency 2025-11-30 11:44:10 +01:00
Vincent Guillet
d866924130 Update PrestashopAdminController request mapping to include API prefix 2025-11-30 11:43:28 +01:00
Vincent Guillet
9eb8256fd7 Update PrestashopAdminController request mapping to remove API prefix 2025-11-30 11:39:43 +01:00
Vincent Guillet
e5411f2fdc Rename Prestashop API base URL property for consistency 2025-11-29 12:00:39 +01:00
Vincent Guillet
d64fed8157 Refactor PrestashopService and ps-product-dialog component to enhance API integration and improve product management functionality 2025-11-29 11:53:25 +01:00
Vincent Guillet
007cb34c81 Enhance PrestashopClient and PrestashopProxyController with new proxy method and improved response handling 2025-11-29 11:32:41 +01:00
Vincent Guillet
aead87b1bc Refactor PrestashopService to improve API integration and enhance product management functionality 2025-11-29 10:43:57 +01:00
Vincent Guillet
e30fb83043 Add PrestashopAdminController and PrestashopAdminService for managing admin resources 2025-11-29 10:36:09 +01:00
Vincent Guillet
44764a5f14 Refactor PrestashopProxyController to enhance proxy response handling and ensure consistent JSON output 2025-11-29 10:05:21 +01:00
Vincent Guillet
4c16e356a3 Refactor PrestashopClient and PrestashopProxyController to enhance API request handling and improve error logging 2025-11-29 09:58:51 +01:00
Vincent Guillet
8a750b94d0 Refactor PrestashopClient and PrestashopProxyController to improve query handling and simplify request processing 2025-11-29 09:47:02 +01:00
Vincent Guillet
9ae60a087a Refactor PrestashopClient and PrestashopProxyController to improve query handling and simplify request processing 2025-11-29 09:33:10 +01:00
Vincent Guillet
9d2f89f805 Refactor PrestashopClient and PrestashopProxyController to enhance API request handling and simplify query processing 2025-11-29 09:04:47 +01:00
Vincent Guillet
d802418c29 Refactor PrestashopProxyController to simplify endpoint handling and enhance query processing 2025-11-29 08:52:59 +01:00
504fb4fe8e Update docker-compose.yml 2025-11-28 22:09:49 +00:00
Vincent Guillet
5c42db7540 Refactor PrestashopClient and PrestashopProxyController to use API key for authentication and simplify request handling 2025-11-28 23:08:56 +01:00
Vincent Guillet
14e19ac2ea Refactor PrestashopClient and PrestashopProxyController to enhance query handling and response logging 2025-11-28 22:39:04 +01:00
Vincent Guillet
136f9c1732 Update environment configuration and simplify nginx settings for API routing 2025-11-28 22:20:44 +01:00
Vincent Guillet
60ce19f72f Refactor PrestashopClient and PrestashopProxyController to enhance query handling and response logging 2025-11-28 22:19:26 +01:00
659b16f700 Update client/nginx.conf 2025-11-28 19:33:09 +00:00
664123cc22 Update client/nginx.conf 2025-11-28 19:23:14 +00:00
fec615db26 Update docker-compose.yml 2025-11-28 18:59:45 +00:00
6388db1026 Update docker-compose.yml 2025-11-28 18:57:05 +00:00
35a5f0d755 Update docker-compose.yml 2025-11-28 18:55:13 +00:00
e6efbdeafe Update docker-compose.yml 2025-11-28 18:50:30 +00:00
dba9e19c6c Update client/nginx.conf 2025-11-28 18:50:04 +00:00
9b2a4b55a2 Update client/nginx.conf 2025-11-28 18:47:15 +00:00
ad66b1bf31 Update docker-compose.yml 2025-11-28 18:46:26 +00:00
785c482057 Update jenkinsfile 2025-11-28 18:46:00 +00:00
bbd1b94524 Update client/nginx.conf 2025-11-28 18:39:45 +00:00
9d07e4d14e revert 8088d3efb7
revert Update client/nginx.conf
2025-11-28 18:38:38 +00:00
8088d3efb7 Update client/nginx.conf 2025-11-28 18:23:38 +00:00
Vincent Guillet
e5adb9356f Add PrestaShop API configuration to application properties 2025-11-28 19:11:35 +01:00
Vincent Guillet
b8aa3e61ed Refactor AuthController to enhance refresh token handling and support CORS 2025-11-28 18:50:35 +01:00
Vincent Guillet
411c407a40 Refactor AuthController to support both POST and GET methods for refresh token 2025-11-28 18:47:15 +01:00
9e350ec2b5 Update client/nginx.conf 2025-11-28 17:38:00 +00:00
6427f08ed8 Update client/Dockerfile 2025-11-28 17:37:48 +00:00
40eb58aa4d Update client/Dockerfile 2025-11-28 17:26:12 +00:00
c9d8186f21 Update client/nginx.conf 2025-11-28 17:26:03 +00:00
8e5819db38 Update docker-compose.yml 2025-11-28 17:23:28 +00:00
1659ac6ad2 Update docker-compose.yml 2025-11-28 17:16:28 +00:00
0d68975f41 Update client/src/index.html 2025-11-28 17:14:23 +00:00
0d71596e70 Update client/nginx.conf 2025-11-28 17:13:54 +00:00
03661021cc Update client/Dockerfile 2025-11-28 17:13:45 +00:00
82de928f3e Update client/nginx.conf 2025-11-28 17:03:15 +00:00
bfa90c5b31 Update client/Dockerfile 2025-11-28 17:03:04 +00:00
136532947f Update docker-compose.yml 2025-11-28 16:45:01 +00:00
7028d1094b Update docker-compose.yml 2025-11-28 16:38:34 +00:00
00fd7e2069 Update client/src/environments/environment.prod.ts 2025-11-28 16:30:41 +00:00
d388ce2d1d Update client/src/environments/environment.ts 2025-11-28 16:30:27 +00:00
939bb6159c Update client/nginx.conf 2025-11-28 16:30:00 +00:00
3a6b26ac38 Update client/Dockerfile 2025-11-28 16:29:43 +00:00
dabfd03d0c Update docker-compose.yml 2025-11-28 16:29:25 +00:00
d8410b7463 Update docker-compose.yml 2025-11-28 15:56:28 +00:00
d526b8ab39 Update client/src/environments/environment.prod.ts 2025-11-28 15:49:10 +00:00
687400ebd9 Update client/src/environments/environment.ts 2025-11-28 15:48:57 +00:00
44abcda2e8 Update client/src/index.html 2025-11-28 15:48:38 +00:00
6e47c4b4d9 Update client/nginx.conf 2025-11-28 15:48:21 +00:00
6cc3423451 Update client/Dockerfile 2025-11-28 15:48:08 +00:00
1cda6f4660 Update docker-compose.yml 2025-11-28 15:47:43 +00:00
2a4b71f52f Update client/src/environments/environment.prod.ts 2025-11-28 15:23:26 +00:00
edd8011efc Update client/src/index.html 2025-11-28 15:22:57 +00:00
f2ba047fc4 Update docker-compose.yml 2025-11-28 15:22:31 +00:00
9b8c8b05b3 Update client/src/index.html 2025-11-28 15:18:47 +00:00
30e740e70f Update client/Dockerfile 2025-11-28 15:18:23 +00:00
669980ea93 Update docker-compose.yml 2025-11-28 15:18:07 +00:00
1a670ae930 Update client/angular.json 2025-11-28 15:13:12 +00:00
b42b4fd015 Update client/Dockerfile 2025-11-28 15:01:20 +00:00
d6dba27b16 Update client/angular.json 2025-11-28 15:01:04 +00:00
9a27cd3789 Update docker-compose.yml 2025-11-28 14:54:23 +00:00
b9c4a7fdb4 Update docker-compose.yml 2025-11-28 14:47:28 +00:00
b888089e22 Update client/src/index.html 2025-11-28 14:38:20 +00:00
Vincent Guillet
bfe7176388 Add serve options to angular.json for development environment 2025-11-28 15:27:24 +01:00
facfd5d32b Update jenkinsfile 2025-11-28 13:29:48 +00:00
97f97450f4 Update jenkinsfile 2025-11-28 13:19:07 +00:00
dd0478970b Update jenkinsfile 2025-11-28 13:17:10 +00:00
Vincent Guillet
f44ca08f6a Add Vite configuration for Angular development server 2025-11-28 13:45:12 +01:00
dc21f3820c Update docker-compose.yml 2025-11-28 12:28:35 +00:00
9a8e59e07e Update docker-compose.yml 2025-11-28 11:11:12 +00:00
Vincent Guillet
669a4cbe00 Refactor Dockerfile and update environment configurations for improved API integration 2025-11-28 12:10:56 +01:00
b98995b7ae Update docker-compose.yml 2025-11-28 10:48:18 +00:00
a40fd2c7ac Update docker-compose.yml 2025-11-28 10:33:51 +00:00
Vincent Guillet
d73275572f Remove PrestaShop proxy configuration from nginx.conf 2025-11-28 11:33:23 +01:00
Vincent Guillet
ad6567efa2 Add PrestashopClient and PrestashopProxyController for API integration 2025-11-28 11:33:15 +01:00
f8358594d5 Update docker-compose.yml 2025-11-28 10:12:35 +00:00
1261e90fd7 Update docker-compose.yml 2025-11-28 09:54:38 +00:00
60f6ac4823 Update client/nginx.conf 2025-11-28 09:53:53 +00:00
734320a405 Update docker-compose.yml 2025-11-28 09:52:44 +00:00
Vincent Guillet
6f05f66ea6 Refactor environment configurations and nginx settings for improved API routing and path handling 2025-11-28 10:32:03 +01:00
Vincent Guillet
e5cce7668f Update API URL in production environment configuration for correct routing 2025-11-26 15:34:43 +01:00
Vincent Guillet
b16eff2e76 Refactor environment and nginx configuration for improved API routing and proxy handling 2025-11-26 15:14:53 +01:00
Vincent Guillet
8cdfab9596 Refactor PrestashopProxyController to enhance request forwarding and error handling 2025-11-26 14:46:37 +01:00
Vincent Guillet
f9a9e81713 Refactor PrestashopClient and PrestashopProxyController to improve query handling and enhance code clarity 2025-11-26 14:25:25 +01:00
Vincent Guillet
f4696b5f5b Refactor PrestashopClient and PrestashopProxyController for improved error handling and response management 2025-11-26 14:08:33 +01:00
Vincent Guillet
d94ce06d95 Refactor PrestashopClient and PrestashopProxyController for improved API request handling and response management 2025-11-26 12:14:07 +01:00
Vincent Guillet
103e4c055d Enhance responsiveness of navigation and product CRUD components with CSS adjustments for better layout on small screens 2025-11-26 11:57:48 +01:00
Vincent Guillet
005bbcc678 Enhance responsiveness of navigation and product CRUD components with CSS adjustments for better layout on small screens 2025-11-26 10:01:40 +01:00
29 changed files with 1489 additions and 521 deletions

View File

@@ -46,7 +46,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // autoriser les preflight
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/users/**").authenticated()
.requestMatchers("/api/app/**").permitAll()
.requestMatchers("/api/app/**").authenticated()
.anyRequest().permitAll()
)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
@@ -61,16 +61,26 @@ public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(Arrays.asList(
"http://localhost:4200",
"http://127.0.0.1:4200",
"https://dev.vincent-guillet.fr"
));
config.setAllowedMethods(Arrays.asList("GET","POST","PUT","DELETE","OPTIONS"));
config.setAllowedHeaders(Arrays.asList("Authorization","Content-Type","Accept"));
config.setExposedHeaders(Arrays.asList("Authorization"));
// IMPORTANT : origins explicites, sans path
config.setAllowedOrigins(Arrays.asList(
"http://localhost:4200",
"http://127.0.0.1:4200",
"https://dev.vincent-guillet.fr",
"https://projets.vincent-guillet.fr"
));
config.setAllowCredentials(true);
// Autoriser tous les headers côté requête (plus robuste)
config.setAllowedHeaders(Arrays.asList("*"));
// Autoriser les méthodes classiques
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
// Headers que le client *voit* dans la réponse
config.setExposedHeaders(Arrays.asList("Authorization", "Content-Type"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;

View File

@@ -0,0 +1,13 @@
package fr.gameovergne.api.config.prestashop;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "prestashop")
@Getter
@Setter
public class PrestashopProperties {
private String baseUrl;
private String apiKey;
}

View File

@@ -1,13 +1,13 @@
package fr.gameovergne.api.controller.auth;
import fr.gameovergne.api.dto.auth.AuthRequest;
import fr.gameovergne.api.dto.auth.AuthResponse;
import fr.gameovergne.api.dto.user.UserDTO;
import fr.gameovergne.api.mapper.user.UserMapper;
import fr.gameovergne.api.service.auth.AuthService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import fr.gameovergne.api.dto.auth.AuthResponse;
import fr.gameovergne.api.dto.user.UserDTO;
import fr.gameovergne.api.dto.auth.AuthRequest;
import fr.gameovergne.api.mapper.user.UserMapper;
import fr.gameovergne.api.service.auth.AuthService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
@@ -25,39 +25,52 @@ public class AuthController {
this.authService = authService;
}
// ===================== REGISTER =====================
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@RequestBody UserDTO dto) {
public ResponseEntity<AuthResponse> register(@RequestBody UserDTO dto,
HttpServletResponse response) {
return authService.register(UserMapper.fromDto(dto))
.map((ResponseEntity::ok))
.map(authResponse -> createAuthResponse(authResponse, response))
.orElse(ResponseEntity.badRequest().build());
}
// ===================== LOGIN =====================
@PostMapping("/login")
public ResponseEntity<AuthResponse> authenticate(@RequestBody AuthRequest request, HttpServletResponse response) {
public ResponseEntity<AuthResponse> authenticate(@RequestBody AuthRequest request,
HttpServletResponse response) {
return authService.authenticate(request)
.map(authResponse -> createAuthResponse(authResponse, response))
.orElse(ResponseEntity.badRequest().build());
}
// ===================== LOGOUT =====================
@GetMapping("/logout")
public ResponseEntity<Void> logout(HttpServletResponse response) {
// Supprime le cookie de refresh token
ResponseCookie cookie = ResponseCookie.from("refreshToken", "")
.httpOnly(true)
.secure(false) // true en prod
.secure(false) // true en prod derrière HTTPS
.path("/")
.maxAge(0) // Expire immédiatement
.maxAge(0) // expire immédiatement
.sameSite("Lax")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.ok().build();
}
// ===================== ME =====================
@GetMapping("/me")
public ResponseEntity<UserDTO> getCurrentUser(HttpServletRequest request) {
String username = request.getUserPrincipal() != null ? request.getUserPrincipal().getName() : null;
String username = request.getUserPrincipal() != null
? request.getUserPrincipal().getName()
: null;
if (username == null) {
return ResponseEntity.status(401).build(); // Unauthorized
return ResponseEntity.status(401).build();
}
return authService.getCurrentUser(username)
@@ -65,9 +78,26 @@ public class AuthController {
.orElse(ResponseEntity.notFound().build());
}
// ===================== REFRESH =====================
// Accepte POST et GET pour être robuste aux proxies / redirects bizarres
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(HttpServletRequest request, HttpServletResponse response) {
public ResponseEntity<AuthResponse> refreshPost(HttpServletRequest request,
HttpServletResponse response) {
return handleRefresh(request, response);
}
@GetMapping("/refresh")
public ResponseEntity<AuthResponse> refreshGet(HttpServletRequest request,
HttpServletResponse response) {
return handleRefresh(request, response);
}
private ResponseEntity<AuthResponse> handleRefresh(HttpServletRequest request,
HttpServletResponse response) {
String refreshToken = null;
if (request.getCookies() != null) {
refreshToken = Arrays.stream(request.getCookies())
.filter(c -> "refreshToken".equals(c.getName()))
@@ -75,18 +105,23 @@ public class AuthController {
.map(Cookie::getValue)
.orElse(null);
}
if (refreshToken == null) {
// Pas de cookie -> pas d'erreur réseau
// Pas de cookie -> on ne casse pas le front, juste 204
return ResponseEntity.noContent().build();
}
return authService.refresh(refreshToken)
.map(authResponse -> createAuthResponse(authResponse, response))
// Token inconnu/expiré -> pas d'erreur réseau non plus
// token expiré / invalide -> 204 aussi (pas derreur réseau)
.orElse(ResponseEntity.noContent().build());
}
private ResponseEntity<AuthResponse> createAuthResponse(AuthResponse authResponse, HttpServletResponse response) {
// ===================== UTILITAIRE =====================
private ResponseEntity<AuthResponse> createAuthResponse(AuthResponse authResponse,
HttpServletResponse response) {
// Cookie HTTP-only pour le refresh
ResponseCookie cookie = ResponseCookie.from("refreshToken", authResponse.refreshToken())
.httpOnly(true)
.secure(false) // true en prod
@@ -96,7 +131,7 @@ public class AuthController {
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
// Ne pas renvoyer le refresh token dans le body
// On ne renvoie pas le refresh token dans le body
return ResponseEntity.ok(new AuthResponse(
authResponse.username(),
authResponse.accessToken(),

View File

@@ -2,79 +2,142 @@ package fr.gameovergne.api.controller.prestashop;
import fr.gameovergne.api.service.prestashop.PrestashopClient;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.HandlerMapping;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/ps")
public class PrestashopProxyController {
Logger log = LoggerFactory.getLogger(PrestashopProxyController.class);
private final PrestashopClient prestashopClient;
public PrestashopProxyController(PrestashopClient prestashopClient) {
this.prestashopClient = prestashopClient;
}
// Utilitaire pour extraire /products, /products/446, etc.
// --- Helpers communs ---
private String extractPath(HttpServletRequest request) {
String fullPath = request.getRequestURI(); // ex: /api/ps/products/446
String contextPath = request.getContextPath(); // souvent ""
String relative = fullPath.substring(contextPath.length()); // /api/ps/products/446
return relative.replaceFirst("^/api/ps", ""); // => /products/446
String fullPath = (String) request.getAttribute(
HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE
);
String bestMatchPattern = (String) request.getAttribute(
HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE
);
String relativePath = new AntPathMatcher()
.extractPathWithinPattern(bestMatchPattern, fullPath);
// On renvoie toujours avec un "/" devant
return "/" + relativePath;
}
private String extractDecodedQuery(HttpServletRequest request) {
String rawQuery = request.getQueryString();
if (rawQuery != null) {
rawQuery = URLDecoder.decode(rawQuery, StandardCharsets.UTF_8);
}
return rawQuery;
}
// ---------- GET ----------
@GetMapping("/**")
public ResponseEntity<String> proxyGet(HttpServletRequest request) {
String path = extractPath(request);
String query = request.getQueryString();
String rawQuery = extractDecodedQuery(request);
String body = prestashopClient.get(path, query);
ResponseEntity<String> prestaResponse =
prestashopClient.getWithRawQuery(path, rawQuery);
return ResponseEntity
.ok()
.status(prestaResponse.getStatusCode())
.contentType(MediaType.APPLICATION_JSON)
.body(body);
}
@PutMapping("/**")
public ResponseEntity<String> proxyPut(HttpServletRequest request,
@RequestBody String body) {
String path = extractPath(request);
String query = request.getQueryString();
String responseBody = prestashopClient.put(path, query, body);
return ResponseEntity
.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(responseBody);
.body(prestaResponse.getBody());
}
// ---------- POST ----------
@PostMapping("/**")
public ResponseEntity<String> proxyPost(HttpServletRequest request,
@RequestBody String body) {
@RequestBody String xmlBody) {
String path = extractPath(request);
String query = request.getQueryString();
String rawQuery = extractDecodedQuery(request);
String responseBody = prestashopClient.post(path, query, body);
log.info("XML envoyé à Presta:\n{}", xmlBody);
ResponseEntity<String> prestaResponse =
prestashopClient.postWithRawQuery(path, rawQuery, xmlBody);
return ResponseEntity
.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(responseBody);
.status(prestaResponse.getStatusCode())
.contentType(MediaType.APPLICATION_XML)
.body(prestaResponse.getBody());
}
// ---------- PUT ----------
@PutMapping("/**")
public ResponseEntity<String> proxyPut(HttpServletRequest request,
@RequestBody String xmlBody) {
String path = extractPath(request);
String rawQuery = extractDecodedQuery(request);
log.info("XML envoyé à Presta:\n{}", xmlBody);
ResponseEntity<String> prestaResponse =
prestashopClient.putWithRawQuery(path, rawQuery, xmlBody);
return ResponseEntity
.status(prestaResponse.getStatusCode())
.contentType(MediaType.APPLICATION_XML)
.body(prestaResponse.getBody());
}
// ---------- DELETE ----------
@DeleteMapping("/**")
public ResponseEntity<String> proxyDelete(HttpServletRequest request) {
String path = extractPath(request);
String query = request.getQueryString();
String rawQuery = extractDecodedQuery(request);
String responseBody = prestashopClient.delete(path, query);
ResponseEntity<String> prestaResponse =
prestashopClient.deleteWithRawQuery(path, rawQuery);
return ResponseEntity
.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(responseBody);
.status(prestaResponse.getStatusCode())
.contentType(MediaType.APPLICATION_XML)
.body(prestaResponse.getBody());
}
/**
* Upload d'une image produit :
* Front → (multipart/form-data) → /api/ps/images/products/{productId}
* Backend → (bytes) → https://shop.gameovergne.fr/api/images/products/{productId}
*/
@PostMapping(
path = "/images/products/{productId}",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_XML_VALUE
)
public ResponseEntity<String> uploadProductImage(
jakarta.servlet.http.HttpServletRequest request,
@PathVariable("productId") String productId,
@RequestPart("image") MultipartFile imageFile
) {
String rawQuery = request.getQueryString(); // si jamais tu ajoutes des options côté Presta
log.info("[Proxy] Upload image produit {} (size={} bytes, ct={})",
productId, imageFile.getSize(), imageFile.getContentType());
return prestashopClient.uploadProductImage(productId, rawQuery, imageFile);
}
}

View File

@@ -1,225 +1,366 @@
package fr.gameovergne.api.service.prestashop;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.List;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
@Service
@Slf4j
public class PrestashopClient {
private static final Logger log = LoggerFactory.getLogger(PrestashopClient.class);
private final RestTemplate restTemplate = new RestTemplate();
private static final MediaType APPLICATION_XML_UTF8 =
new MediaType("application", "xml", StandardCharsets.UTF_8);
private final RestClient client;
private final String baseUrl;
private final String basicAuth; // base64 SANS le "Basic "
private final String basicAuthHeader;
public PrestashopClient(
@Value("${prestashop.base-url}") String baseUrl,
@Value("${prestashop.basic-auth}") String basicAuth
@Value("${prestashop.api-key}") String apiKey
) {
this.baseUrl = baseUrl;
this.basicAuth = basicAuth;
String basicAuth = Base64.getEncoder()
.encodeToString((apiKey + ":").getBytes(StandardCharsets.UTF_8));
this.basicAuthHeader = "Basic " + basicAuth; // <--- mémorisé pour HttpURLConnection
this.client = RestClient.builder()
.defaultHeader(HttpHeaders.AUTHORIZATION, basicAuthHeader)
.build();
log.info("[PrestaShop] Base URL = {}", baseUrl);
log.info("[PrestaShop] API key length = {}", apiKey.length());
}
// =========================
// GET : on force JSON + full
// =========================
public String get(String path, String ignoredQuery) {
private String buildUri(String path, MultiValueMap<String, String> params) {
UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl(baseUrl)
.path("/api")
.path(path)
.queryParam("output_format", "JSON")
.queryParam("display", "full");
.fromHttpUrl(baseUrl + path);
if (params != null && !params.isEmpty()) {
builder.queryParams(params);
}
return builder.build(true).toUriString();
}
String url = builder.build(true).toUriString();
// -------- Méthodes "typed" JSON / XML utilisées par ps-admin --------
log.info("[PrestaShop] GET {}", url);
public String getJson(String path, MultiValueMap<String, String> params) {
String uri = buildUri(path, params);
log.info("[PrestaShop] GET JSON {}", uri);
return client.get()
.uri(uri)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(String.class);
}
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, "Basic " + basicAuth);
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
public String getXml(String path, MultiValueMap<String, String> params) {
String uri = buildUri(path, params);
log.info("[PrestaShop] GET XML {}", uri);
return client.get()
.uri(uri)
.accept(MediaType.APPLICATION_XML)
.retrieve()
.body(String.class);
}
HttpEntity<Void> entity = new HttpEntity<>(headers);
public String postXml(String path, MultiValueMap<String, String> params, String xmlBody) {
String uri = buildUri(path, params);
log.info("[PrestaShop] POST XML {}", uri);
byte[] bodyBytes = xmlBody.getBytes(StandardCharsets.UTF_8);
return client.post()
.uri(uri)
.contentType(APPLICATION_XML_UTF8)
.body(bodyBytes)
.retrieve()
.body(String.class);
}
public String putXml(String path, MultiValueMap<String, String> params, String xmlBody) {
String uri = buildUri(path, params);
log.info("[PrestaShop] PUT XML {}", uri);
byte[] bodyBytes = xmlBody.getBytes(StandardCharsets.UTF_8);
return client.put()
.uri(uri)
.contentType(APPLICATION_XML_UTF8)
.body(bodyBytes)
.retrieve()
.body(String.class);
}
public void delete(String path, MultiValueMap<String, String> params) {
String uri = buildUri(path, params);
log.info("[PrestaShop] DELETE {}", uri);
client.delete()
.uri(uri)
.retrieve()
.toBodilessEntity();
}
// -------- Méthodes génériques utilisées par le proxy /api/ps/** --------
public ResponseEntity<String> getWithRawQuery(String path, String rawQuery) {
String uri = baseUrl + path;
if (rawQuery != null && !rawQuery.isBlank()) {
uri = uri + "?" + rawQuery;
}
log.info("[PrestaShop] GET (proxy) {}", uri);
return client.get()
.uri(uri)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.toEntity(String.class);
}
public ResponseEntity<String> postWithRawQuery(String path, String rawQuery, String xmlBody) {
String uri = baseUrl + path;
if (rawQuery != null && !rawQuery.isBlank()) {
uri = uri + "?" + rawQuery;
}
log.info("[PrestaShop] POST (proxy) {}", uri);
log.info("[PrestaShop] XML envoyé (proxy POST):\n{}", xmlBody);
byte[] bodyBytes = xmlBody.getBytes(StandardCharsets.UTF_8);
try {
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.GET,
entity,
String.class
);
log.info("[PrestaShop] Réponse GET {} pour {}", response.getStatusCode(), url);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new RuntimeException("PrestaShop returned non-2xx status: "
+ response.getStatusCode() + " for URL " + url);
}
return response.getBody();
} catch (RestClientException e) {
log.error("[PrestaShop] Erreur GET {}", url, e);
throw new RuntimeException("Erreur GET PrestaShop", e);
return client.post()
.uri(uri)
.contentType(APPLICATION_XML_UTF8)
.body(bodyBytes)
.retrieve()
.toEntity(String.class);
} catch (RestClientResponseException ex) {
// On propage tel quel le status + le body XML renvoyé par Presta
log.error("[PrestaShop] POST error {} : {}", ex.getRawStatusCode(), ex.getResponseBodyAsString());
return ResponseEntity
.status(ex.getRawStatusCode())
.contentType(MediaType.APPLICATION_XML)
.body(ex.getResponseBodyAsString());
} catch (RestClientException ex) {
// Cas réseau, timeout, etc.
log.error("[PrestaShop] POST technical error", ex);
return ResponseEntity
.status(502)
.contentType(MediaType.TEXT_PLAIN)
.body("Error while calling PrestaShop WebService");
}
}
// =========================
// PUT : on respecte la query du front
// =========================
public String put(String path, String query, String body) {
UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl(baseUrl)
.path("/api")
.path(path);
if (query != null && !query.isBlank()) {
// on laisse Angular décider (ex: output_format=JSON)
builder.query(query);
public ResponseEntity<String> putWithRawQuery(String path, String rawQuery, String xmlBody) {
String uri = baseUrl + path;
if (rawQuery != null && !rawQuery.isBlank()) {
uri = uri + "?" + rawQuery;
}
String url = builder.build(true).toUriString();
log.info("[PrestaShop] PUT {}", url);
log.debug("[PrestaShop] PUT body = {}", body);
log.info("[PrestaShop] PUT (proxy) {}", uri);
log.info("[PrestaShop] XML envoyé (proxy PUT):\n{}", xmlBody);
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, "Basic " + basicAuth);
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
HttpEntity<String> entity = new HttpEntity<>(body, headers);
byte[] bodyBytes = xmlBody.getBytes(StandardCharsets.UTF_8);
try {
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.PUT,
entity,
String.class
);
log.info("[PrestaShop] Réponse PUT {} pour {}", response.getStatusCode(), url);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new RuntimeException("PrestaShop returned non-2xx status: "
+ response.getStatusCode() + " for URL " + url);
}
return response.getBody();
} catch (HttpStatusCodeException e) {
// Ici on log le body d'erreur de Presta !
log.error("[PrestaShop] Erreur PUT {} status={} body={}",
url, e.getStatusCode(), e.getResponseBodyAsString(), e);
throw new RuntimeException("Erreur PUT PrestaShop", e);
} catch (RestClientException e) {
log.error("[PrestaShop] Erreur PUT {}", url, e);
throw new RuntimeException("Erreur PUT PrestaShop", e);
return client.put()
.uri(uri)
.contentType(APPLICATION_XML_UTF8)
.body(bodyBytes)
.retrieve()
.toEntity(String.class);
} catch (RestClientResponseException ex) {
// On propage tel quel le status + le body XML renvoyé par Presta
log.error("[PrestaShop] PUT error {} : {}", ex.getRawStatusCode(), ex.getResponseBodyAsString());
return ResponseEntity
.status(ex.getRawStatusCode())
.contentType(MediaType.APPLICATION_XML)
.body(ex.getResponseBodyAsString());
} catch (RestClientException ex) {
// Cas réseau, timeout, etc.
log.error("[PrestaShop] PUT technical error", ex);
return ResponseEntity
.status(502)
.contentType(MediaType.TEXT_PLAIN)
.body("Error while calling PrestaShop WebService");
}
}
// =========================
// POST : création
// =========================
public String post(String path, String query, String body) {
UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl(baseUrl)
.path("/api")
.path(path);
if (query != null && !query.isBlank()) {
builder.query(query);
public ResponseEntity<String> deleteWithRawQuery(String path, String rawQuery) {
String uri = baseUrl + path;
if (rawQuery != null && !rawQuery.isBlank()) {
uri = uri + "?" + rawQuery;
}
String url = builder.build(true).toUriString();
log.info("[PrestaShop] POST {}", url);
log.debug("[PrestaShop] POST body = {}", body);
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, "Basic " + basicAuth);
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
HttpEntity<String> entity = new HttpEntity<>(body, headers);
log.info("[PrestaShop] DELETE (proxy) {}", uri);
try {
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.POST,
entity,
String.class
);
log.info("[PrestaShop] Réponse POST {} pour {}", response.getStatusCode(), url);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new RuntimeException("PrestaShop returned non-2xx status: "
+ response.getStatusCode() + " for URL " + url);
}
return response.getBody();
} catch (HttpStatusCodeException e) {
log.error("[PrestaShop] Erreur POST {} status={} body={}",
url, e.getStatusCode(), e.getResponseBodyAsString(), e);
throw new RuntimeException("Erreur POST PrestaShop", e);
} catch (RestClientException e) {
log.error("[PrestaShop] Erreur POST {}", url, e);
throw new RuntimeException("Erreur POST PrestaShop", e);
return client.delete()
.uri(uri)
.retrieve()
.toEntity(String.class);
} catch (RestClientResponseException ex) {
// On propage tel quel le status + le body XML renvoyé par Presta
log.error("[PrestaShop] DELETE error {} : {}", ex.getRawStatusCode(), ex.getResponseBodyAsString());
return ResponseEntity
.status(ex.getRawStatusCode())
.contentType(MediaType.APPLICATION_XML)
.body(ex.getResponseBodyAsString());
} catch (RestClientException ex) {
// Cas réseau, timeout, etc.
log.error("[PrestaShop] DELETE technical error", ex);
return ResponseEntity
.status(502)
.contentType(MediaType.TEXT_PLAIN)
.body("Error while calling PrestaShop WebService");
}
}
// =========================
// DELETE : suppression
// =========================
public String delete(String path, String query) {
UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl(baseUrl)
.path("/api")
.path(path);
if (query != null && !query.isBlank()) {
builder.query(query);
}
String url = builder.build(true).toUriString();
log.info("[PrestaShop] DELETE {}", url);
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, "Basic " + basicAuth);
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
HttpEntity<Void> entity = new HttpEntity<>(headers);
/**
* Upload d'une image produit vers PrestaShop :
* POST /api/images/products/{productId}
*/
public ResponseEntity<String> uploadProductImage(
String productId,
String rawQuery,
MultipartFile imageFile
) {
try {
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.DELETE,
entity,
String.class
);
// Construire lURL Presta (comme avant)
StringBuilder urlBuilder = new StringBuilder(baseUrl)
.append("/images/products/")
.append(productId);
log.info("[PrestaShop] Réponse DELETE {} pour {}", response.getStatusCode(), url);
if (rawQuery != null && !rawQuery.isBlank()) {
urlBuilder.append('?').append(rawQuery);
}
String url = urlBuilder.toString();
if (!response.getStatusCode().is2xxSuccessful()) {
throw new RuntimeException("PrestaShop returned non-2xx status: "
+ response.getStatusCode() + " for URL " + url);
byte[] fileBytes = imageFile.getBytes();
String originalFilename = imageFile.getOriginalFilename();
if (originalFilename == null || originalFilename.isBlank()) {
originalFilename = "image.jpg";
}
return response.getBody();
} catch (HttpStatusCodeException e) {
log.error("[PrestaShop] Erreur DELETE {} status={} body={}",
url, e.getStatusCode(), e.getResponseBodyAsString(), e);
throw new RuntimeException("Erreur DELETE PrestaShop", e);
} catch (RestClientException e) {
log.error("[PrestaShop] Erreur DELETE {}", url, e);
throw new RuntimeException("Erreur DELETE PrestaShop", e);
String contentType = imageFile.getContentType();
if (contentType == null || contentType.isBlank()) {
contentType = "application/octet-stream";
}
log.info(
"[PrestaShop] POST (image multipart - manual) {} (size={} bytes, contentType={}, filename={})",
url, fileBytes.length, contentType, originalFilename
);
// -------- Construction du multipart "à la main" --------
String boundary = "----PrestashopBoundary" + System.currentTimeMillis();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
OutputStreamWriter osw = new OutputStreamWriter(baos, StandardCharsets.UTF_8);
PrintWriter writer = new PrintWriter(osw, true);
// Début de la part "image"
writer.append("--").append(boundary).append("\r\n");
writer.append("Content-Disposition: form-data; name=\"image\"; filename=\"")
.append(originalFilename)
.append("\"\r\n");
writer.append("Content-Type: ").append(contentType).append("\r\n");
writer.append("\r\n");
writer.flush();
// Données binaires du fichier
baos.write(fileBytes);
baos.write("\r\n".getBytes(StandardCharsets.UTF_8));
// Fin du multipart
writer.append("--").append(boundary).append("--").append("\r\n");
writer.flush();
byte[] multipartBytes = baos.toByteArray();
// -------- Envoi via HttpURLConnection --------
URL targetUrl = URI.create(url).toURL();
HttpURLConnection conn = (HttpURLConnection) targetUrl.openConnection();
conn.setDoOutput(true);
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
conn.setRequestProperty("Accept", "application/xml, text/xml, */*;q=0.1");
conn.setRequestProperty("Authorization", basicAuthHeader);
// Important : pas de chunked, on envoie une taille fixe
conn.setFixedLengthStreamingMode(multipartBytes.length);
try (OutputStream os = conn.getOutputStream()) {
os.write(multipartBytes);
}
int status = conn.getResponseCode();
InputStream is = (status >= 200 && status < 300)
? conn.getInputStream()
: conn.getErrorStream();
String responseBody;
if (is != null) {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
responseBody = sb.toString();
}
} else {
responseBody = "";
}
log.info("[PrestaShop] Image upload response status={}, body={}", status, responseBody);
// ---------- Mapping vers une réponse propre pour le front ----------
if (status >= 200 && status < 300) {
// Succès : on renvoie un petit JSON que Angular sait lire
return ResponseEntity
.ok()
.contentType(MediaType.APPLICATION_JSON)
.body("{\"success\":true}");
}
HttpStatus springStatus = HttpStatus.resolve(status);
if (springStatus == null) {
springStatus = HttpStatus.INTERNAL_SERVER_ERROR;
}
// En cas derreur Presta, on propage lXML pour debug
return ResponseEntity
.status(springStatus)
.contentType(MediaType.APPLICATION_XML)
.body(responseBody);
} catch (IOException e) {
log.error("[PrestaShop] Erreur lors de l'upload d'image", e);
return ResponseEntity
.status(HttpStatus.BAD_GATEWAY)
.contentType(MediaType.TEXT_PLAIN)
.body("Erreur lors de l'upload d'image vers PrestaShop");
}
}
}

View File

@@ -10,7 +10,10 @@ spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
spring.jpa.show-sql=true
spring.servlet.multipart.max-file-size=15MB
spring.servlet.multipart.max-request-size=15MB
jwt.secret=a23ac96ce968bf13099d99410b951dd498118851bdfc996a3f844bd68b1b2afd
prestashop.base-url=https://shop.gameovergne.fr
prestashop.basic-auth=MkFRUEcxM01KOFgxMTdVNkZKNU5HSFBTOTNIRTM0QUI=
prestashop.base-url=https://shop.gameovergne.fr/api
prestashop.api-key=${PRESTASHOP_API_KEY}

View File

@@ -1,18 +1,31 @@
# Build Angular
FROM node:20 AS build
# ===== STAGE 1 : build Angular =====
FROM node:20-alpine AS build
WORKDIR /app
# Dépendances
COPY package*.json ./
RUN npm install
RUN npm ci
# Code source
COPY . .
RUN npm run build
# Build Angular en mode prod
RUN npm run build -- --configuration production
# NGINX final
FROM nginx:stable
# ===== STAGE 2 : Nginx pour servir le build =====
FROM nginx:1.27-alpine
COPY --from=build /app/dist/client/browser /usr/share/nginx/html
# On nettoie la racine Nginx
RUN rm -rf /usr/share/nginx/html/*
# ⚠ On copie TOUT dist/client dans l'image
# => ça crée /usr/share/nginx/html/browser/index.html
COPY --from=build /app/dist/client/ /usr/share/nginx/html/
# Conf Nginx spécifique à l'app
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -62,8 +62,21 @@
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"host": "0.0.0.0",
"port": 4200,
"allowedHosts": [
"dev.vincent-guillet.fr"
],
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "client:build:production"
@@ -74,9 +87,11 @@
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {

View File

@@ -1,13 +1,12 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
root /usr/share/nginx/html/browser;
index index.html;
# Angular SPA
location / {
try_files $uri $uri/ /index.html;
}
}
}

View File

@@ -4,7 +4,9 @@
"secure": true,
"changeOrigin": true,
"logLevel": "debug",
"pathRewrite": { "^/ps": "/api" },
"pathRewrite": {
"^/ps": "/api"
},
"headers": {
"Authorization": "Basic MkFRUEcxM01KOFgxMTdVNkZKNU5HSFBTOTNIRTM0QUI="
}

View File

@@ -1,32 +1,41 @@
import {APP_INITIALIZER, ApplicationConfig, inject, provideZoneChangeDetection} from '@angular/core';
import {
APP_INITIALIZER,
ApplicationConfig,
inject,
provideZoneChangeDetection,
importProvidersFrom
} from '@angular/core';
import {provideRouter} from '@angular/router';
import {BrowserModule} from '@angular/platform-browser';
import {APP_BASE_HREF} from '@angular/common';
import {routes} from './app.routes';
import {provideHttpClient, withInterceptors} from '@angular/common/http';
import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
import {authTokenInterceptor} from './interceptors/auth-token.interceptor';
import {AuthService} from './services/auth.service';
import {catchError, firstValueFrom, of} from 'rxjs';
import {environment} from '../environments/environment';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({eventCoalescing: true}),
importProvidersFrom(BrowserModule),
{provide: APP_BASE_HREF, useValue: environment.hrefBase},
provideRouter(routes),
provideAnimationsAsync(),
provideHttpClient(withInterceptors([
authTokenInterceptor
])
),
provideHttpClient(withInterceptors([authTokenInterceptor])),
{
provide: APP_INITIALIZER,
multi: true,
useFactory: () => {
const auth = inject(AuthService);
return () => firstValueFrom(auth.bootstrapSession().pipe(
catchError(err => of(null))
)
);
return () =>
firstValueFrom(
auth.bootstrapSession().pipe(
catchError(() => of(null))
)
);
}
}, provideAnimationsAsync()
}
]
};

View File

@@ -5,7 +5,7 @@ import {LoginComponent} from './pages/auth/login/login.component';
import {ProfileComponent} from './pages/profile/profile.component';
import {guestOnlyCanActivate, guestOnlyCanMatch} from './guards/guest-only.guard';
import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard';
import {authOnlyCanMatch} from './guards/auth-only.guard';
import {authOnlyCanActivate, authOnlyCanMatch} from './guards/auth-only.guard';
import {PsAdminComponent} from './pages/admin/ps-admin/ps-admin.component';
import {ProductsComponent} from './pages/products/products.component';
@@ -40,13 +40,13 @@ export const routes: Routes = [
path: 'profile',
component: ProfileComponent,
canMatch: [authOnlyCanMatch],
canActivate: [authOnlyCanMatch]
canActivate: [authOnlyCanActivate]
},
{
path: 'products',
component: ProductsComponent,
canMatch: [authOnlyCanMatch],
canActivate: [authOnlyCanMatch]
canMatch: [adminOnlyCanMatch],
canActivate: [adminOnlyCanActivate]
},
{
path: 'admin',

View File

@@ -1,3 +1,16 @@
/* src/app/components/main-navbar/main-navbar.component.css */
/* Ajout prise en charge safe-area et meilleure gestion des overflow */
.mat-toolbar {
/* protège contre les zones sensibles (notch / status bar) */
padding-top: constant(safe-area-inset-top);
padding-top: env(safe-area-inset-top);
position: relative;
z-index: 1000;
box-sizing: border-box;
}
/* wrapper principal */
.container {
max-width: 1200px;
margin: 0 auto;
@@ -5,20 +18,107 @@
align-items: center;
justify-content: space-between;
width: 100%;
gap: 12px;
padding: 0 12px;
box-sizing: border-box;
min-height: 56px; /* assure une hauteur minimale utile sur mobile */
}
/* marque / titre */
.brand {
font-weight: bold;
font-size: 1.2rem;
cursor: pointer;
min-width: 0; /* autorise le shrink */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
}
/* actions (boutons, menu utilisateur) */
.nav-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex: 0 1 auto;
justify-content: flex-end;
flex-wrap: nowrap;
min-width: 0; /* important pour permettre la réduction des enfants */
overflow: visible;
}
/* icône dans mat-menu */
.mat-menu-item mat-icon {
margin-right: 8px;
}
/* Empêcher les boutons de dépasser et couper le texte avec ellipsis */
.nav-actions button {
min-width: 0;
max-width: 100%;
display: inline-flex;
align-items: center;
gap: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Angular Material place le texte dans .mat-button-wrapper — on le tronque proprement */
.nav-actions button .mat-button-wrapper {
display: inline-block;
max-width: calc(100% - 56px); /* espace pour icônes + padding */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
/* Ajustements spécifiques pour petits écrans */
@media (max-width: 720px) {
.mat-toolbar {
padding-top: env(safe-area-inset-top);
}
.container {
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding: 8px;
}
.brand {
order: 1;
width: 100%;
font-size: 1.05rem;
margin-bottom: 4px;
}
.nav-actions {
order: 2;
width: 100%;
justify-content: flex-end;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 4px; /* espace pour le scroll horizontal */
}
.nav-actions button {
padding: 6px 8px;
font-size: 0.95rem;
min-width: 0;
white-space: nowrap;
max-width: 100%;
}
.nav-actions button .mat-button-wrapper {
max-width: calc(100% - 40px);
}
.nav-actions mat-icon {
font-size: 20px;
line-height: 1;
}
}

View File

@@ -3,12 +3,12 @@
gap: 16px;
}
/* Toolbar responsive */
.toolbar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap; /* permet le retour à la ligne sur petits écrans */
flex-wrap: wrap;
}
.toolbar .filter {
@@ -16,27 +16,23 @@
min-width: 360px;
}
/* Tableau : container scrollable horizontalement */
.mat-elevation-z2 {
width: 100%;
max-width: 100%;
overflow-x: auto; /* scroll interne horizontal */
-webkit-overflow-scrolling: touch; /* scroll fluide sur iOS */
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Table garde une largeur minimale pour activer le scroll interne */
table {
width: 100%;
min-width: 800px; /* ajuster si nécessaire selon colonnes */
min-width: 800px;
border-collapse: collapse;
}
/* Prévenir les retours à la ligne pour conserver la structure de colonnes */
th, td {
white-space: nowrap;
}
/* Cellules et miniatures */
.prod-cell {
display: flex;
align-items: center;
@@ -51,19 +47,31 @@ th, td {
flex-shrink: 0;
}
/* Paginator : s'adapte si l'espace est réduit */
.product-list-root {
position: relative;
}
.product-list-loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
pointer-events: all;
}
mat-paginator {
width: 100%;
overflow: hidden;
}
/* Ajustements spécifiques pour petits écrans */
@media (max-width: 720px) {
.toolbar {
gap: 8px;
}
/* faire passer le filtre en full-width et placer le bouton au-dessus */
.toolbar button {
flex: 0 0 auto;
order: 1;
@@ -76,13 +84,11 @@ mat-paginator {
width: 100%;
}
/* réduire légèrement la miniature pour économiser de l'espace */
.prod-thumb {
width: 24px;
height: 24px;
}
/* option : réduire la min-width du tableau si nécessaire */
table {
min-width: 720px;
}

View File

@@ -1,16 +1,27 @@
<section class="crud">
<div class="toolbar">
<button mat-raised-button color="primary" (click)="create()">
<button mat-raised-button
color="primary"
(click)="create()"
[disabled]="isLoading">
<mat-icon>add</mat-icon>&nbsp;Nouveau produit
</button>
<mat-form-field appearance="outline" class="filter">
<mat-label>Filtrer</mat-label>
<input matInput [formControl]="filterCtrl" placeholder="Nom, ID, catégorie, marque, fournisseur…">
<input matInput
[formControl]="filterCtrl"
placeholder="Nom, ID, catégorie, marque, fournisseur…"
[disabled]="isLoading">
</mat-form-field>
</div>
<div class="mat-elevation-z2">
<div class="mat-elevation-z2 product-list-root">
<!-- Overlay de chargement -->
<div class="product-list-loading-overlay" *ngIf="isLoading">
<mat-spinner diameter="48"></mat-spinner>
</div>
<table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="id">
@@ -51,8 +62,19 @@
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let el">
<button mat-icon-button (click)="edit(el)" aria-label="edit"><mat-icon>edit</mat-icon></button>
<button mat-icon-button color="warn" (click)="remove(el)" aria-label="delete"><mat-icon>delete</mat-icon></button>
<button mat-icon-button
aria-label="edit"
(click)="edit(el)"
[disabled]="isLoading">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button
color="warn"
aria-label="delete"
(click)="remove(el)"
[disabled]="isLoading">
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
@@ -60,10 +82,17 @@
<tr mat-row *matRowDef="let row; columns: displayed;"></tr>
<tr class="mat-row" *matNoDataRow>
<td class="mat-cell" [attr.colspan]="displayed.length">Aucune donnée.</td>
<td class="mat-cell" [attr.colspan]="displayed.length">
Aucune donnée.
</td>
</tr>
</table>
<mat-paginator [pageSizeOptions]="[5,10,25,100]" [pageSize]="10" aria-label="Pagination"></mat-paginator>
<mat-paginator
[pageSizeOptions]="[5,10,25,100]"
[pageSize]="10"
aria-label="Pagination"
[disabled]="isLoading">
</mat-paginator>
</div>
</section>

View File

@@ -13,7 +13,8 @@ import {MatButton, MatIconButton} from '@angular/material/button';
import {MatIcon} from '@angular/material/icon';
import {FormBuilder, ReactiveFormsModule} from '@angular/forms';
import {MatDialog, MatDialogModule} from '@angular/material/dialog';
import {forkJoin} from 'rxjs';
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import {forkJoin, finalize} from 'rxjs';
import {PsItem} from '../../interfaces/ps-item';
import {ProductListItem} from '../../interfaces/product-list-item';
@@ -32,7 +33,8 @@ import {ProductDialogData, PsProductDialogComponent} from '../ps-product-dialog/
MatSortModule, MatPaginatorModule,
MatFormField, MatLabel, MatInput,
MatButton, MatIconButton, MatIcon,
MatDialogModule
MatDialogModule,
MatProgressSpinnerModule
]
})
export class PsProductCrudComponent implements OnInit {
@@ -40,26 +42,24 @@ export class PsProductCrudComponent implements OnInit {
private readonly ps = inject(PrestashopService);
private readonly dialog = inject(MatDialog);
// référentiels
categories: PsItem[] = [];
manufacturers: PsItem[] = [];
suppliers: PsItem[] = [];
// maps daffichage
private catMap = new Map<number, string>();
private manMap = new Map<number, string>();
private supMap = new Map<number, string>();
// table
displayed: string[] = ['id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', 'quantity', 'actions'];
dataSource = new MatTableDataSource<any>([]);
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;
@ViewChild(MatTable) table!: MatTable<any>;
// filtre
filterCtrl = this.fb.control<string>('');
isLoading = false;
ngOnInit(): void {
forkJoin({
cats: this.ps.list('categories'),
@@ -73,6 +73,7 @@ export class PsProductCrudComponent implements OnInit {
this.manMap = new Map(this.manufacturers.map(x => [x.id, x.name]));
this.suppliers = sups ?? [];
this.supMap = new Map(this.suppliers.map(x => [x.id, x.name]));
this.reload();
},
error: err => {
@@ -80,7 +81,6 @@ export class PsProductCrudComponent implements OnInit {
}
});
// filtre client
this.filterCtrl.valueChanges.subscribe(v => {
this.dataSource.filter = (v ?? '').toString().trim().toLowerCase();
if (this.paginator) this.paginator.firstPage();
@@ -133,10 +133,24 @@ export class PsProductCrudComponent implements OnInit {
}
reload() {
this.ps.listProducts().subscribe(p => this.bindProducts(p));
this.isLoading = true;
this.ps.listProducts()
.pipe(
finalize(() => {
this.isLoading = false;
})
)
.subscribe({
next: p => this.bindProducts(p),
error: err => {
console.error('Erreur lors du chargement des produits', err);
}
});
}
create() {
if (this.isLoading) return;
const data: ProductDialogData = {
mode: 'create',
refs: {
@@ -145,12 +159,16 @@ export class PsProductCrudComponent implements OnInit {
suppliers: this.suppliers
}
};
this.dialog.open(PsProductDialogComponent, {width: '900px', data}).afterClosed().subscribe(ok => {
if (ok) this.reload();
});
this.dialog.open(PsProductDialogComponent, {width: '900px', data})
.afterClosed()
.subscribe(ok => {
if (ok) this.reload();
});
}
edit(row: ProductListItem & { priceHt?: number }) {
if (this.isLoading) return;
const data: ProductDialogData = {
mode: 'edit',
productRow: row,
@@ -160,16 +178,29 @@ export class PsProductCrudComponent implements OnInit {
suppliers: this.suppliers
}
};
this.dialog.open(PsProductDialogComponent, {width: '900px', data}).afterClosed().subscribe(ok => {
if (ok) this.reload();
});
this.dialog.open(PsProductDialogComponent, {width: '900px', data})
.afterClosed()
.subscribe(ok => {
if (ok) this.reload();
});
}
remove(row: ProductListItem) {
if (this.isLoading) return;
if (!confirm(`Supprimer le produit "${row.name}" (#${row.id}) ?`)) return;
this.ps.deleteProduct(row.id).subscribe({
next: () => this.reload(),
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
});
this.isLoading = true;
this.ps.deleteProduct(row.id)
.pipe(
finalize(() => {
})
)
.subscribe({
next: () => this.reload(),
error: (e: unknown) => {
this.isLoading = false;
alert('Erreur: ' + (e instanceof Error ? e.message : String(e)));
}
});
}
}

View File

@@ -76,6 +76,24 @@
font-size: 40px;
}
/* Bouton de suppression (croix rouge) */
.carousel-delete-btn {
position: absolute;
top: 6px;
right: 6px;
background: rgba(255, 255, 255, 0.9);
border-radius: 4px;
padding: 2px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
}
.carousel-delete-btn mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #e53935;
}
/* Bandeau de vignettes */
.carousel-thumbs {
@@ -85,15 +103,42 @@
}
.thumb-item {
position: relative;
width: 64px;
height: 64px;
border-radius: 4px;
overflow: hidden;
overflow: hidden; /* tu peux laisser comme ça */
border: 2px solid transparent;
flex: 0 0 auto;
cursor: pointer;
}
/* Bouton de suppression sur les vignettes */
.thumb-delete-btn {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
min-width: 18px;
padding: 0;
line-height: 18px;
background: transparent;
border-radius: 0;
box-shadow: none;
}
.thumb-delete-btn mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #e53935; /* rouge discret mais lisible */
}
.thumb-item.active {
border-color: #1976d2;
}
@@ -118,3 +163,19 @@
.thumb-placeholder mat-icon {
font-size: 28px;
}
.dialog-root {
position: relative;
}
/* Overlay plein écran dans le dialog pendant la sauvegarde */
.dialog-loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
pointer-events: all;
}

View File

@@ -1,137 +1,183 @@
<h2 mat-dialog-title>{{ mode === 'create' ? 'Nouveau produit' : 'Modifier le produit' }}</h2>
<div mat-dialog-content class="grid" [formGroup]="form">
<div class="dialog-root">
<!-- Overlay de chargement -->
@if (isSaving) {
<div class="dialog-loading-overlay">
<mat-spinner diameter="48"></mat-spinner>
</div>
}
<!-- CARROUSEL IMAGES -->
<div class="col-12 carousel">
<div class="carousel-main">
<div mat-dialog-content class="grid" [formGroup]="form">
<!-- Bouton précédent -->
<button mat-icon-button
class="carousel-nav-btn left"
(click)="prev()"
[disabled]="carouselItems.length <= 1">
<mat-icon>chevron_left</mat-icon>
</button>
<!-- CARROUSEL IMAGES -->
<div class="col-12 carousel">
<div class="carousel-main">
<!-- Image principale ou placeholder -->
@if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
<img [src]="carouselItems[currentIndex].src" alt="Produit">
} @else {
<div class="carousel-placeholder" (click)="fileInput.click()">
<mat-icon>add_photo_alternate</mat-icon>
<span>Ajouter des images</span>
</div>
}
<!-- Bouton précédent -->
<button mat-icon-button
class="carousel-nav-btn left"
(click)="prev()"
[disabled]="carouselItems.length <= 1">
<mat-icon>chevron_left</mat-icon>
</button>
<!-- Bouton suivant -->
<button mat-icon-button
class="carousel-nav-btn right"
(click)="next()"
[disabled]="carouselItems.length <= 1">
<mat-icon>chevron_right</mat-icon>
</button>
<!-- Image principale ou placeholder -->
@if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
<img [src]="carouselItems[currentIndex].src" alt="Produit">
} @else {
<div class="carousel-placeholder" (click)="fileInput.click()">
<mat-icon>add_photo_alternate</mat-icon>
<span>Ajouter des images</span>
</div>
}
<!-- Bouton suivant -->
<button mat-icon-button
class="carousel-nav-btn right"
(click)="next()"
[disabled]="carouselItems.length <= 1">
<mat-icon>chevron_right</mat-icon>
</button>
<!-- Bouton de suppression (croix rouge) -->
@if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
<button mat-icon-button
class="carousel-delete-btn"
(click)="onDeleteCurrentImage()">
<mat-icon>delete</mat-icon>
</button>
}
<!-- Bouton suivant -->
<button mat-icon-button
class="carousel-nav-btn right"
(click)="next()"
[disabled]="carouselItems.length <= 1">
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<!-- Bandeau de vignettes -->
<div class="carousel-thumbs">
@for (item of carouselItems; let i = $index; track item) {
<div class="thumb-item"
[class.active]="i === currentIndex"
(click)="onThumbClick(i)">
@if (!item.isPlaceholder) {
<!-- Bouton suppression vignette -->
<button mat-icon-button
class="thumb-delete-btn"
(click)="onDeleteThumb(i, $event)">
<mat-icon>close</mat-icon>
</button>
<img class="thumb-img" [src]="item.src" alt="Vignette produit">
} @else {
<div class="thumb-placeholder" (click)="fileInput.click()">
<mat-icon>add</mat-icon>
</div>
}
</div>
}
</div>
<!-- Input réel, caché -->
<input #fileInput type="file" multiple hidden (change)="onFiles($event)">
</div>
<!-- Bandeau de vignettes -->
<div class="carousel-thumbs">
@for (item of carouselItems; let i = $index; track item) {
<div class="thumb-item"
[class.active]="i === currentIndex"
(click)="onThumbClick(i)">
@if (!item.isPlaceholder) {
<img class="thumb-img" [src]="item.src" alt="Vignette produit">
} @else {
<div class="thumb-placeholder" (click)="fileInput.click()">
<mat-icon>add</mat-icon>
</div>
}
</div>
}
<!-- Input pour le nom du produit -->
<mat-form-field class="col-12">
<mat-label>Nom du produit</mat-label>
<input matInput formControlName="name" autocomplete="off">
</mat-form-field>
<!-- Textarea pour la description -->
<mat-form-field class="col-12">
<mat-label>Description</mat-label>
<textarea matInput rows="4" formControlName="description"></textarea>
</mat-form-field>
<!-- Sélecteur pour la catégorie -->
<mat-form-field class="col-6">
<mat-label>Catégorie</mat-label>
<mat-select formControlName="categoryId">
<mat-option [value]="null" disabled>Choisir…</mat-option>
@for (c of categories; track c.id) {
<mat-option [value]="c.id">{{ c.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Sélecteur pour l'état du produit -->
<mat-form-field class="col-6">
<mat-label>État</mat-label>
<mat-select formControlName="conditionLabel">
@for (opt of conditionOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Sélecteur pour la marque -->
<mat-form-field class="col-6">
<mat-label>Marque</mat-label>
<mat-select formControlName="manufacturerId">
<mat-option [value]="null" disabled>Choisir…</mat-option>
@for (m of manufacturers; track m.id) {
<mat-option [value]="m.id">{{ m.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Sélecteur pour la plateforme (Fournisseur) -->
<mat-form-field class="col-6">
<mat-label>Plateforme</mat-label>
<mat-select formControlName="supplierId">
<mat-option [value]="null" disabled>Choisir…</mat-option>
@for (s of suppliers; track s.id) {
<mat-option [value]="s.id">{{ s.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Checkboxes pour Complet/Notice -->
<div class="col-12 flags">
<mat-checkbox formControlName="complete">Complet</mat-checkbox>
<mat-checkbox formControlName="hasManual">Notice</mat-checkbox>
</div>
<!-- Input réel, caché -->
<input #fileInput type="file" multiple hidden (change)="onFiles($event)">
<!-- Inputs pour le prix -->
<mat-form-field class="col-4">
<mat-label>Prix TTC (€)</mat-label>
<input matInput type="number" step="0.01" min="0" formControlName="priceTtc">
</mat-form-field>
<!-- Input pour la quantité -->
<mat-form-field class="col-4">
<mat-label>Quantité</mat-label>
<input matInput type="number" step="1" min="0" formControlName="quantity">
</mat-form-field>
</div>
<!-- Input pour le nom du produit -->
<mat-form-field class="col-12">
<mat-label>Nom du produit</mat-label>
<input matInput formControlName="name" autocomplete="off">
</mat-form-field>
<!-- Textarea pour la description -->
<mat-form-field class="col-12">
<mat-label>Description</mat-label>
<textarea matInput rows="4" formControlName="description"></textarea>
</mat-form-field>
<!-- Sélecteur pour la catégorie -->
<mat-form-field class="col-6">
<mat-label>Catégorie</mat-label>
<mat-select formControlName="categoryId">
<mat-option [value]="null" disabled>Choisir…</mat-option>
@for (c of categories; track c.id) {
<mat-option [value]="c.id">{{ c.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Sélecteur pour l'état du produit -->
<mat-form-field class="col-6">
<mat-label>État</mat-label>
<mat-select formControlName="conditionLabel">
@for (opt of conditionOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Sélecteur pour la marque -->
<mat-form-field class="col-6">
<mat-label>Marque</mat-label>
<mat-select formControlName="manufacturerId">
<mat-option [value]="null" disabled>Choisir…</mat-option>
@for (m of manufacturers; track m.id) {
<mat-option [value]="m.id">{{ m.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Sélecteur pour la plateforme (Fournisseur) -->
<mat-form-field class="col-6">
<mat-label>Plateforme</mat-label>
<mat-select formControlName="supplierId">
<mat-option [value]="null" disabled>Choisir…</mat-option>
@for (s of suppliers; track s.id) {
<mat-option [value]="s.id">{{ s.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Checkboxes pour Complet/Notice -->
<div class="col-12 flags">
<mat-checkbox formControlName="complete">Complet</mat-checkbox>
<mat-checkbox formControlName="hasManual">Notice</mat-checkbox>
</div>
<!-- Inputs pour le prix -->
<mat-form-field class="col-4">
<mat-label>Prix TTC (€)</mat-label>
<input matInput type="number" step="0.01" min="0" formControlName="priceTtc">
</mat-form-field>
<!-- Input pour la quantité -->
<mat-form-field class="col-4">
<mat-label>Quantité</mat-label>
<input matInput type="number" step="1" min="0" formControlName="quantity">
</mat-form-field>
</div>
<!-- Actions -->
<div mat-dialog-actions>
<button mat-button (click)="close()">Annuler</button>
<button mat-raised-button color="primary" (click)="save()" [disabled]="form.invalid">
{{ mode === 'create' ? 'Créer' : 'Enregistrer' }}
<mat-dialog-actions align="end">
<button mat-button
(click)="close()"
[disabled]="isSaving">
Annuler
</button>
</div>
<button mat-raised-button
color="primary"
(click)="save()"
[disabled]="form.invalid || isSaving">
@if (!isSaving) {
Enregistrer
} @else {
Enregistrement...
}
</button>
</mat-dialog-actions>

View File

@@ -1,11 +1,11 @@
import {Component, Inject, OnInit, inject, OnDestroy} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatButton, MatIconButton } from '@angular/material/button';
import {CommonModule} from '@angular/common';
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
import {MatFormField, MatLabel} from '@angular/material/form-field';
import {MatInput} from '@angular/material/input';
import {MatSelectModule} from '@angular/material/select';
import {MatCheckbox} from '@angular/material/checkbox';
import {MatButton, MatIconButton} from '@angular/material/button';
import {
MatDialogRef,
MAT_DIALOG_DATA,
@@ -13,13 +13,14 @@ import {
MatDialogContent,
MatDialogTitle
} from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import {MatIcon} from '@angular/material/icon';
import { catchError, forkJoin, of, Observable } from 'rxjs';
import {catchError, forkJoin, of, Observable, finalize} from 'rxjs';
import { PsItem } from '../../interfaces/ps-item';
import { ProductListItem } from '../../interfaces/product-list-item';
import { PrestashopService } from '../../services/prestashop.serivce';
import {PsItem} from '../../interfaces/ps-item';
import {ProductListItem} from '../../interfaces/product-list-item';
import {PrestashopService} from '../../services/prestashop.serivce';
import {MatProgressSpinner} from '@angular/material/progress-spinner';
export type ProductDialogData = {
mode: 'create' | 'edit';
@@ -38,7 +39,7 @@ type CarouselItem = { src: string; isPlaceholder: boolean };
CommonModule, ReactiveFormsModule,
MatFormField, MatLabel, MatInput, MatSelectModule, MatCheckbox,
MatButton, MatDialogActions, MatDialogContent, MatDialogTitle,
MatIcon, MatIconButton
MatIcon, MatIconButton, MatProgressSpinner
]
})
export class PsProductDialogComponent implements OnInit, OnDestroy {
@@ -48,7 +49,10 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
constructor(
@Inject(MAT_DIALOG_DATA) public data: ProductDialogData,
private readonly dialogRef: MatDialogRef<PsProductDialogComponent>
) {}
) {
}
isSaving = false;
mode!: 'create' | 'edit';
categories: PsItem[] = [];
@@ -167,11 +171,11 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
const qty$ = this.ps.getProductQuantity(r.id).pipe(catchError(() => of(0)));
const imgs$ = this.ps.getProductImageUrls(r.id).pipe(catchError(() => of<string[]>([])));
const flags$ = this.ps.getProductFlags(r.id).pipe(
catchError(() => of({ complete: false, hasManual: false, conditionLabel: undefined }))
catchError(() => of({complete: false, hasManual: false, conditionLabel: undefined}))
);
forkJoin({ details: details$, qty: qty$, imgs: imgs$, flags: flags$ })
.subscribe(({ details, qty, imgs, flags }) => {
forkJoin({details: details$, qty: qty$, imgs: imgs$, flags: flags$})
.subscribe(({details, qty, imgs, flags}) => {
const ttc = this.toTtc(details.priceHt ?? 0);
const baseDesc = this.cleanForTextarea(details.description ?? '');
this.lastLoadedDescription = baseDesc;
@@ -203,7 +207,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
const fl = (ev.target as HTMLInputElement).files;
// Nettoyage des anciens objectURL
for(let url of this.previewUrls) {
for (let url of this.previewUrls) {
URL.revokeObjectURL(url);
}
this.previewUrls = [];
@@ -224,12 +228,12 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
private buildCarousel() {
const items: CarouselItem[] = [
...this.existingImageUrls.map(u => ({ src: u, isPlaceholder: false })),
...this.previewUrls.map(u => ({ src: u, isPlaceholder: false }))
...this.existingImageUrls.map(u => ({src: u, isPlaceholder: false})),
...this.previewUrls.map(u => ({src: u, isPlaceholder: false}))
];
// placeholder en dernier
items.push({ src: '', isPlaceholder: true });
items.push({src: '', isPlaceholder: true});
this.carouselItems = items;
if (!this.carouselItems.length) {
@@ -261,10 +265,13 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
}
}
// -------- Save / close inchangés (à part dto.images) --------
// -------- Save / close --------
save() {
if (this.form.invalid) return;
if (this.form.invalid || this.isSaving) return;
this.isSaving = true;
this.dialogRef.disableClose = true;
const v = this.form.getRawValue();
const effectiveDescription = (v.description ?? '').trim() || this.lastLoadedDescription;
@@ -275,7 +282,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
categoryId: +v.categoryId!,
manufacturerId: +v.manufacturerId!,
supplierId: +v.supplierId!,
images: this.images, // toujours les fichiers sélectionnés
images: this.images,
complete: !!v.complete,
hasManual: !!v.hasManual,
conditionLabel: v.conditionLabel || undefined,
@@ -291,13 +298,99 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
op$ = this.ps.updateProduct(this.productRow.id, dto) as Observable<unknown>;
}
op$.subscribe({
next: () => this.dialogRef.close(true),
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
});
op$
.pipe(
finalize(() => {
// si la boîte de dialogue est encore ouverte, on réactive tout
this.isSaving = false;
this.dialogRef.disableClose = false;
})
)
.subscribe({
next: () => this.dialogRef.close(true),
error: (e: unknown) =>
alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
});
}
/** Extrait l'id_image depuis une URL FO Presta (.../img/p/.../<id>.jpg) */
private extractImageIdFromUrl(url: string): number | null {
const m = /\/(\d+)\.(?:jpg|jpeg|png|gif)$/i.exec(url);
if (!m) return null;
const id = Number(m[1]);
return Number.isFinite(id) ? id : null;
}
/** Suppression générique d'une image à l'index donné (carrousel + vignettes) */
private deleteImageAtIndex(idx: number) {
if (!this.carouselItems.length) return;
const item = this.carouselItems[idx];
if (!item || item.isPlaceholder) return;
const existingCount = this.existingImageUrls.length;
// --- Cas 1 : image existante (déjà chez Presta) ---
if (idx < existingCount) {
if (!this.productRow) return; // sécurité
const url = this.existingImageUrls[idx];
const imageId = this.extractImageIdFromUrl(url);
if (!imageId) {
alert('Impossible de déterminer lID de limage à supprimer.');
return;
}
if (!confirm('Supprimer cette image du produit ?')) return;
this.ps.deleteProductImage(this.productRow.id, imageId).subscribe({
next: () => {
// On la retire du tableau local et on reconstruit le carrousel
this.existingImageUrls.splice(idx, 1);
this.buildCarousel();
// Repositionnement de lindex si nécessaire
if (this.currentIndex >= this.carouselItems.length - 1) {
this.currentIndex = Math.max(0, this.carouselItems.length - 2);
}
},
error: (e: unknown) => {
alert('Erreur lors de la suppression de limage : ' + (e instanceof Error ? e.message : String(e)));
}
});
return;
}
// --- Cas 2 : image locale (nouvelle) ---
const localIdx = idx - existingCount;
if (localIdx >= 0 && localIdx < this.previewUrls.length) {
if (!confirm('Retirer cette image de la sélection ?')) return;
this.previewUrls.splice(localIdx, 1);
this.images.splice(localIdx, 1);
this.buildCarousel();
if (this.currentIndex >= this.carouselItems.length - 1) {
this.currentIndex = Math.max(0, this.carouselItems.length - 2);
}
}
}
// utilisée par la grande image
onDeleteCurrentImage() {
if (!this.carouselItems.length) return;
this.deleteImageAtIndex(this.currentIndex);
}
// utilisée par la croix sur une vignette
onDeleteThumb(index: number, event: MouseEvent) {
event.stopPropagation();
this.deleteImageAtIndex(index);
}
close() {
if (this.isSaving) return;
this.dialogRef.close(false);
}
}

View File

@@ -1,10 +1,11 @@
import {inject, Injectable} from '@angular/core';
import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http';
import {forkJoin, map, of, switchMap, Observable, catchError} from 'rxjs';
import {forkJoin, map, of, switchMap, Observable, catchError, from} from 'rxjs';
import {PsItem} from '../interfaces/ps-item';
import {PsProduct} from '../interfaces/ps-product';
import {ProductListItem} from '../interfaces/product-list-item';
import {environment} from '../../environments/environment';
import {resizeImage} from '../utils/image-utils';
type Resource = 'categories' | 'manufacturers' | 'suppliers';
@@ -499,9 +500,30 @@ export class PrestashopService {
}
uploadProductImage(productId: number, file: File) {
const fd = new FormData();
fd.append('image', file);
return this.http.post(`${this.base}/images/products/${productId}`, fd);
// 1) Compression AVANT upload
return from(resizeImage(file, 1600, 1600, 0.8)).pipe(
switchMap(compressedFile => {
const fd = new FormData();
fd.append('image', compressedFile); // ← image compressée
// 2) Envoi vers ton backend (identique à avant)
return this.http.post(
`${this.base}/images/products/${productId}`,
fd,
{ reportProgress: true, observe: 'events' }
);
})
);
}
deleteProductImage(productId: number, imageId: number) {
// Presta : DELETE /images/products/{id_product}/{id_image}
return this.http.delete(
`${this.base}/images/products/${productId}/${imageId}`,
{ responseType: 'text' }
).pipe(
map(() => true)
);
}
// -------- Stock (quantité) — gestion fine via stock_availables

View File

@@ -0,0 +1,45 @@
export function resizeImage(
file: File,
maxWidth = 1600,
maxHeight = 1600,
quality = 0.8
): Promise<File> {
return new Promise((resolve, reject) => {
const img = new Image();
const reader = new FileReader();
reader.onload = e => {
if (!e.target?.result) return reject('Cannot load file');
img.onload = () => {
let { width, height } = img;
const ratio = Math.min(maxWidth / width, maxHeight / height, 1);
const newW = Math.round(width * ratio);
const newH = Math.round(height * ratio);
const canvas = document.createElement('canvas');
canvas.width = newW;
canvas.height = newH;
const ctx = canvas.getContext('2d');
if (!ctx) return reject('Cannot get canvas context');
ctx.drawImage(img, 0, 0, newW, newH);
canvas.toBlob(
blob => {
if (!blob) return reject('Compression failed');
resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' }));
},
'image/jpeg',
quality
);
};
img.src = e.target.result as string;
};
reader.readAsDataURL(file);
});
}

View File

@@ -1,5 +1,6 @@
export const environment = {
production: true,
apiUrl: 'https://dev.vincent-guillet.fr/gameovergne-api/api',
psUrl: 'https://dev.vincent-guillet.fr/gameovergne-api/api/ps'
apiUrl: '/gameovergne-api/api',
psUrl: '/gameovergne-api/api/ps',
hrefBase: '/gameovergne/',
};

View File

@@ -1,5 +1,6 @@
export const environment = {
production: false,
apiUrl: 'http://localhost:3000/api',
psUrl: '/ps'
psUrl: '/ps',
hrefBase: '/',
};

View File

@@ -3,13 +3,13 @@
<head>
<meta charset="utf-8">
<title>Game Over'gne App</title>
<base href="./">
<base href="/gameovergne/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
<app-root></app-root>
</body>
</html>

View File

@@ -8,5 +8,7 @@ body, html {
);
}
html, body { height: 100%; }
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
body {
margin: 0;
font-family: Roboto, "Helvetica Neue", sans-serif;
}

86
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,86 @@
# docker-compose.dev.yml
services:
mysql:
image: mysql:8.4
container_name: gameovergne-mysql
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: gameovergne_app
MYSQL_USER: gameovergne
MYSQL_PASSWORD: gameovergne
volumes:
- ./mysql-data:/var/lib/mysql
ports:
- "3366:3306"
networks:
- gameovergne
restart: unless-stopped
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-proot"]
interval: 10s
timeout: 5s
retries: 10
spring:
image: registry.vincent-guillet.fr/gameovergne-api:dev-latest
container_name: gameovergne-api
depends_on:
mysql:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/gameovergne_app?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
SPRING_DATASOURCE_USERNAME: gameovergne
SPRING_DATASOURCE_PASSWORD: gameovergne
PRESTASHOP_API_KEY: 2AQPG13MJ8X117U6FJ5NGHPS93HE34AB
SERVER_PORT: 3000
networks:
- traefik
- gameovergne
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- traefik.http.routers.gameovergne-api.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne-api`)
- traefik.http.routers.gameovergne-api.entrypoints=edge
- traefik.http.routers.gameovergne-api.service=gameovergne-api
- traefik.http.services.gameovergne-api.loadbalancer.server.port=3000
- traefik.http.routers.gameovergne-api.middlewares=gameovergne-api-stripprefix
- traefik.http.middlewares.gameovergne-api-stripprefix.stripprefix.prefixes=/gameovergne-api
angular:
image: registry.vincent-guillet.fr/gameovergne-client:dev-latest
container_name: gameovergne-client
depends_on:
- spring
networks:
- gameovergne
- traefik
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- traefik.http.routers.gameovergne-client.rule=Host(`dev.vincent-guillet.fr`) && (Path(`/gameovergne`) || PathPrefix(`/gameovergne/`))
- traefik.http.routers.gameovergne-client.entrypoints=edge
- traefik.http.routers.gameovergne-client.service=gameovergne-client
- traefik.http.routers.gameovergne-client.middlewares=gameovergne-slash,gameovergne-client-stripprefix
- traefik.http.middlewares.gameovergne-slash.redirectregex.regex=^https?://([^/]+)/gameovergne$$
- traefik.http.middlewares.gameovergne-slash.redirectregex.replacement=https://$${1}/gameovergne/
- traefik.http.middlewares.gameovergne-slash.redirectregex.permanent=true
- traefik.http.middlewares.gameovergne-client-stripprefix.stripprefix.prefixes=/gameovergne
- traefik.http.services.gameovergne-client.loadbalancer.server.port=80
- traefik.http.routers.gameovergne-ps.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne/ps`)
- traefik.http.routers.gameovergne-ps.entrypoints=edge
- traefik.http.routers.gameovergne-ps.service=gameovergne-client
- traefik.http.routers.gameovergne-ps.middlewares=gameovergne-client-stripprefix
networks:
traefik:
external: true
gameovergne:
external: true

86
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,86 @@
# docker-compose.prod.yml
services:
mysql:
image: mysql:8.4
container_name: gameovergne-mysql-prod
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: gameovergne_app
MYSQL_USER: gameovergne
MYSQL_PASSWORD: gameovergne
volumes:
- ./mysql-data-prod:/var/lib/mysql
ports:
- "3366:3306"
networks:
- gameovergne
restart: unless-stopped
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-proot"]
interval: 10s
timeout: 5s
retries: 10
spring:
image: registry.vincent-guillet.fr/gameovergne-api:prod-latest
container_name: gameovergne-api-prod
depends_on:
mysql:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/gameovergne_app?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
SPRING_DATASOURCE_USERNAME: gameovergne
SPRING_DATASOURCE_PASSWORD: gameovergne
PRESTASHOP_API_KEY: 2AQPG13MJ8X117U6FJ5NGHPS93HE34AB
SERVER_PORT: 3000
networks:
- traefik
- gameovergne
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- traefik.http.routers.gameovergne-api.rule=Host(`projets.vincent-guillet.fr`) && PathPrefix(`/gameovergne-api`)
- traefik.http.routers.gameovergne-api.entrypoints=edge
- traefik.http.routers.gameovergne-api.service=gameovergne-api
- traefik.http.services.gameovergne-api.loadbalancer.server.port=3000
- traefik.http.routers.gameovergne-api.middlewares=gameovergne-api-stripprefix
- traefik.http.middlewares.gameovergne-api-stripprefix.stripprefix.prefixes=/gameovergne-api
angular:
image: registry.vincent-guillet.fr/gameovergne-client:prod-latest
container_name: gameovergne-client-prod
depends_on:
- spring
networks:
- gameovergne
- traefik
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- traefik.http.routers.gameovergne-client.rule=Host(`projets.vincent-guillet.fr`) && (Path(`/gameovergne`) || PathPrefix(`/gameovergne/`))
- traefik.http.routers.gameovergne-client.entrypoints=edge
- traefik.http.routers.gameovergne-client.service=gameovergne-client
- traefik.http.routers.gameovergne-client.middlewares=gameovergne-slash,gameovergne-client-stripprefix
- traefik.http.middlewares.gameovergne-slash.redirectregex.regex=^https?://([^/]+)/gameovergne$$
- traefik.http.middlewares.gameovergne-slash.redirectregex.replacement=https://$${1}/gameovergne/
- traefik.http.middlewares.gameovergne-slash.redirectregex.permanent=true
- traefik.http.middlewares.gameovergne-client-stripprefix.stripprefix.prefixes=/gameovergne
- traefik.http.services.gameovergne-client.loadbalancer.server.port=80
- traefik.http.routers.gameovergne-ps.rule=Host(`projets.vincent-guillet.fr`) && PathPrefix(`/gameovergne/ps`)
- traefik.http.routers.gameovergne-ps.entrypoints=edge
- traefik.http.routers.gameovergne-ps.service=gameovergne-client
- traefik.http.routers.gameovergne-ps.middlewares=gameovergne-client-stripprefix
networks:
traefik:
external: true
gameovergne:
external: true

View File

@@ -8,7 +8,9 @@ services:
MYSQL_USER: gameovergne
MYSQL_PASSWORD: gameovergne
volumes:
- mysql-data:/var/lib/mysql
- ./mysql-data:/var/lib/mysql
ports:
- "3366:3306"
networks:
- gameovergne
restart: unless-stopped
@@ -28,15 +30,16 @@ services:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/gameovergne_app?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
SPRING_DATASOURCE_USERNAME: gameovergne
SPRING_DATASOURCE_PASSWORD: gameovergne
PRESTASHOP_API_KEY: 2AQPG13MJ8X117U6FJ5NGHPS93HE34AB
SERVER_PORT: 3000
networks:
- gameovergne
- traefik
- gameovergne
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=traefik
# API sous /gameovergne-api
- traefik.http.routers.gameovergne-api.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne-api`)
- traefik.http.routers.gameovergne-api.entrypoints=edge
- traefik.http.routers.gameovergne-api.service=gameovergne-api
@@ -57,26 +60,26 @@ services:
- traefik.enable=true
- traefik.docker.network=traefik
# FRONT sous /gameovergne (avec et sans slash final)
- traefik.http.routers.gameovergne-client.rule=Host(`dev.vincent-guillet.fr`) && (Path(`/gameovergne`) || PathPrefix(`/gameovergne/`))
- traefik.http.routers.gameovergne-client.entrypoints=edge
- traefik.http.routers.gameovergne-client.service=gameovergne-client
- traefik.http.services.gameovergne-client.loadbalancer.server.port=80
# 1) Middleware : ajoute un "/" si on arrive sur /gameovergne sans slash
- traefik.http.routers.gameovergne-client.middlewares=gameovergne-slash,gameovergne-client-stripprefix
- traefik.http.middlewares.gameovergne-slash.redirectregex.regex=^(.*/gameovergne)$$
- traefik.http.middlewares.gameovergne-slash.redirectregex.replacement=$$1/
- traefik.http.middlewares.gameovergne-slash.redirectregex.regex=^https?://([^/]+)/gameovergne$$
- traefik.http.middlewares.gameovergne-slash.redirectregex.replacement=https://$${1}/gameovergne/
- traefik.http.middlewares.gameovergne-slash.redirectregex.permanent=true
# 2) Ensuite on enlève le préfixe /gameovergne avant d'envoyer à Nginx
- traefik.http.middlewares.gameovergne-client-stripprefix.stripprefix.prefixes=/gameovergne
- traefik.http.services.gameovergne-client.loadbalancer.server.port=80
- traefik.http.routers.gameovergne-ps.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne/ps`)
- traefik.http.routers.gameovergne-ps.entrypoints=edge
- traefik.http.routers.gameovergne-ps.service=gameovergne-client
- traefik.http.routers.gameovergne-ps.middlewares=gameovergne-client-stripprefix
networks:
traefik:
external: true
gameovergne:
driver: bridge
volumes:
mysql-data:
external: true

View File

@@ -1,67 +1,120 @@
pipeline {
agent any
tools {
maven 'mvn'
nodejs 'npm'
}
agent none
environment {
JAVA_HOME = '/opt/java/openjdk'
PATH = "${JAVA_HOME}/bin:${env.PATH}"
SPRING_IMAGE_NAME = 'spring-jenkins'
ANGULAR_IMAGE_NAME = 'angular-jenkins'
IMAGE_TAG = 'latest'
COMPOSE_PROJECT = 'gameovergne-app'
REGISTRY = 'registry.vincent-guillet.fr'
API_IMAGE_DEV = "${REGISTRY}/gameovergne-api:dev-latest"
CLIENT_IMAGE_DEV = "${REGISTRY}/gameovergne-client:dev-latest"
API_IMAGE_PROD = "${REGISTRY}/gameovergne-api:prod-latest"
CLIENT_IMAGE_PROD = "${REGISTRY}/gameovergne-client:prod-latest"
COMPOSE_PROJECT = 'gameovergne-app'
}
stages {
stage('Checkout sur la branche dev') {
steps {
git branch: 'dev', url: 'https://gitea.vincent-guillet.fr/vincentguillet/gameovergne-app.git'
}
}
stage('Maven Build') {
// Build & push images (toujours sur ct-home-dev)
stage('Build & Push Docker Images') {
agent { label 'ct-home-dev' }
steps {
// Multi-branch friendly : Jenkins fait le checkout de la branche courante
checkout scm
script {
// Choix des tags selon la branche
if (env.BRANCH_NAME == 'main') {
env.API_IMAGE = API_IMAGE_PROD
env.CLIENT_IMAGE = CLIENT_IMAGE_PROD
} else {
env.API_IMAGE = API_IMAGE_DEV
env.CLIENT_IMAGE = CLIENT_IMAGE_DEV
}
}
// ----- Build API -----
dir('api') {
sh 'mvn clean package -DskipTests'
sh """
echo "=== Build image API ${API_IMAGE} ==="
docker build -t ${API_IMAGE} .
"""
}
}
}
stage('Angular Build') {
steps {
// ----- Build Client -----
dir('client') {
sh 'npm install'
sh 'npm run build'
sh """
echo "=== Build image CLIENT ${CLIENT_IMAGE} ==="
docker build -t ${CLIENT_IMAGE} .
"""
}
// ----- Push vers registry -----
sh """
echo "=== Push images vers ${REGISTRY} ==="
docker push ${API_IMAGE}
docker push ${CLIENT_IMAGE}
"""
}
}
// Déploiement DEV (ct-home-dev, branche dev)
stage('Deploy DEV') {
when {
branch 'dev'
}
agent { label 'ct-home-dev' }
steps {
checkout scm
withEnv([
"DOCKER_HOST=unix:///var/run/docker.sock",
"COMPOSE_PROJECT_NAME=${env.COMPOSE_PROJECT}"
]) {
sh """
echo "=== [DEV] Nettoyage anciens conteneurs ==="
docker rm -f gameovergne-api gameovergne-client 2>/dev/null || true
echo "=== [DEV] docker compose down ==="
docker compose -f docker-compose.dev.yml down -v || true
echo "=== [DEV] docker compose pull ==="
docker compose -f docker-compose.dev.yml pull
echo "=== [DEV] docker compose up (force recreate) ==="
docker compose -f docker-compose.dev.yml up -d --force-recreate mysql spring angular
"""
}
}
}
stage('Spring Docker Build') {
steps {
sh 'docker build -t registry.vincent-guillet.fr/gameovergne-api:dev-latest ./api'
// Déploiement PROD (ct-home-projets, branche main)
stage('Deploy PROD') {
when {
branch 'main'
}
}
agent { label 'ct-home-projets' }
stage('Angular Docker Build') {
steps {
sh 'docker build -t registry.vincent-guillet.fr/gameovergne-client:dev-latest ./client'
}
}
checkout scm
stage('Deployment') {
steps {
sh '''
CONTAINERS=$(docker ps -a --filter "label=com.docker.compose.project=${COMPOSE_PROJECT}" -q || true)
if [ -n "$CONTAINERS" ]; then
docker rm -f $CONTAINERS
fi
withEnv([
"DOCKER_HOST=unix:///var/run/docker.sock",
"COMPOSE_PROJECT_NAME=${env.COMPOSE_PROJECT}"
]) {
sh """
echo "=== [PROD] Nettoyage anciens conteneurs ==="
docker rm -f gameovergne-api-prod gameovergne-client-prod 2>/dev/null || true
echo "=== (Re)création de la stack MySQL + Spring + Angular ==="
docker-compose up -d mysql spring angular
'''
echo "=== [PROD] docker compose down ==="
docker compose -f docker-compose.prod.yml down || true
echo "=== [PROD] docker compose pull ==="
docker compose -f docker-compose.prod.yml pull
echo "=== [PROD] docker compose up (force recreate) ==="
docker compose -f docker-compose.prod.yml up -d --force-recreate mysql spring angular
"""
}
}
}
}