diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ee42e5 --- /dev/null +++ b/README.md @@ -0,0 +1,200 @@ +# Microservicio de Gestión de Clases - FitDesk + +## 📋 Descripción + +Microservicio para la gestión de clases de gimnasio, incluyendo reservas, asistencia, estadísticas y dashboards para trainers y miembros. + +## 🚀 Nuevas Funcionalidades Implementadas + +### 1. **Vista de Gestión de Clases con Estadísticas** +- Endpoint: `GET /classes/my-classes/stats` +- Rol requerido: `TRAINER` o `ADMIN` +- Retorna lista de clases con: + - Estudiantes actuales vs capacidad máxima + - Porcentaje de asistencia promedio + - Estado de la clase (Activa, Llena, Cancelada) + - Información del trainer y ubicación + +### 2. **Dashboard del Trainer** +- Endpoint: `GET /dashboard/trainer` +- Rol requerido: `TRAINER` o `ADMIN` +- Métricas incluidas: + - Total de estudiantes únicos + - Porcentaje de asistencia promedio + - Clases impartidas este mes + - Cambio porcentual respecto al mes anterior + - Tendencia semanal de estudiantes activos/inactivos + +### 3. **Detalle de Clase con Estudiantes** +- Endpoint: `GET /classes/{id}/detail` +- Rol requerido: `TRAINER` o `ADMIN` +- Información detallada: + - Datos completos de la clase + - Lista de estudiantes inscritos con: + - Información del miembro (integración con msvc-members) + - Porcentaje de asistencia individual + - Total de clases del estudiante + - Estado de membresía + - Último acceso + +### 4. **Calendario de Clases** +- Endpoint: `GET /classes/calendar?startDate=2025-01-01&endDate=2025-01-31` +- Endpoint: `GET /classes/upcoming` (próximas clases) +- Rol requerido: `USER`, `TRAINER` o `ADMIN` +- Muestra: + - Clases por rango de fechas + - Horarios y ubicaciones + - Capacidad disponible + - Acción sugerida (Reservar, Lista de espera, Llena) + +### 5. **Dashboard del Miembro** (existente, mejorado) +- Endpoint: `GET /dashboard/member` +- Rol requerido: `USER` o `ADMIN` +- Información incluida: + - Estado actual (en clase o no) + - Clases restantes del plan + - Próxima clase programada + - Días consecutivos de asistencia + - Actividad semanal + - Lista de próximas clases + +## 📦 Nuevos DTOs Creados + +### Gestión de Clases +- `ClassWithStatsResponse`: Clase con estadísticas agregadas +- `ClassDetailResponse`: Detalle completo de clase +- `StudentInClassDTO`: Información de estudiante en clase +- `CalendarClassDTO`: Clase para vista de calendario + +### Dashboard y Métricas +- `TrainerDashboardDTO`: Métricas del trainer +- `StudentTrendDTO`: Tendencia de estudiantes por semana +- `MemberDashboardDTO`: Dashboard del miembro (mejorado) +- `WeeklyActivityDTO`: Actividad semanal +- `UpcomingClassDTO`: Próximas clases + +### Integración Externa +- `MemberInfoDTO`: Información de miembro desde msvc-members + +## 🔧 Componentes Técnicos + +### Servicios +- `ClassService`: Extendido con métodos de estadísticas y calendario +- `TrainerDashboardService`: Nuevo servicio para métricas del trainer +- `MemberClientService`: Cliente HTTP para integración con msvc-members +- `DashboardServiceImpl`: Dashboard del miembro (existente) + +### Repositorios Extendidos +- `ClassRepository`: Consultas por trainer, fechas, y estadísticas mensuales +- `ClassReservationRepository`: Cálculos de asistencia y conteo de reservas + +### Controladores +- `ClassController`: Extendido con endpoints de estadísticas y calendario +- `TrainerDashboardController`: Nuevo controlador para métricas del trainer +- `DashboardController`: Dashboard del miembro (existente) +- `ClassReservationController`: Gestión de reservas (existente) + +## 🔐 Roles y Permisos + +- **USER**: Puede ver calendario, reservar clases, ver su dashboard +- **TRAINER**: Puede ver sus clases con estadísticas, ver detalles de estudiantes, ver su dashboard +- **ADMIN**: Acceso completo a todas las funcionalidades + +## 🌐 Integración con Otros Microservicios + +### msvc-members +- Obtención de información detallada de miembros +- Estado de membresía +- Último acceso +- URL: `http://msvc-members/members/{id}` + +### Configuración necesaria +- Eureka Client configurado para service discovery +- RestTemplate con LoadBalancer para comunicación entre microservicios + +## 📊 Endpoints Principales + +| Método | Endpoint | Descripción | Rol | +|--------|----------|-------------|-----| +| GET | `/classes` | Listar todas las clases | ANY | +| POST | `/classes` | Crear nueva clase | ADMIN/TRAINER | +| GET | `/classes/my-classes/stats` | Clases con estadísticas del trainer | TRAINER/ADMIN | +| GET | `/classes/{id}/detail` | Detalle completo de clase | TRAINER/ADMIN | +| GET | `/classes/calendar` | Calendario de clases | ANY | +| GET | `/classes/upcoming` | Próximas clases | ANY | +| GET | `/dashboard/trainer` | Dashboard del trainer | TRAINER/ADMIN | +| GET | `/dashboard/member` | Dashboard del miembro | USER/ADMIN | +| POST | `/reservations` | Reservar clase | USER/ADMIN | +| GET | `/reservations/my` | Mis reservas | USER/ADMIN | + +## 🚦 Cómo Probar + +### 1. Obtener clases con estadísticas (como Trainer) +```bash +curl -X GET "http://localhost:8083/classes/my-classes/stats" \ + -H "Authorization: Bearer {token_trainer}" +``` + +### 2. Ver dashboard del trainer +```bash +curl -X GET "http://localhost:8083/dashboard/trainer" \ + -H "Authorization: Bearer {token_trainer}" +``` + +### 3. Ver calendario de clases +```bash +curl -X GET "http://localhost:8083/classes/calendar?startDate=2025-01-01&endDate=2025-01-31" +``` + +### 4. Ver detalle de clase +```bash +curl -X GET "http://localhost:8083/classes/{classId}/detail" \ + -H "Authorization: Bearer {token_trainer}" +``` + +## 📝 Notas de Implementación + +1. **Cálculo de Asistencia**: Se basa en el campo `attended` de `ClassReservation` +2. **Tendencias Semanales**: Analiza las últimas 4 semanas de datos +3. **Estado de Clase**: Se determina por capacidad y estado activo +4. **Integración Resiliente**: Si msvc-members no responde, se usan datos por defecto +5. **Optimización**: Uso de transacciones read-only para consultas + +## 🔄 Próximas Mejoras + +- [ ] Cache de información de miembros para reducir llamadas HTTP +- [ ] Websockets para actualizaciones en tiempo real del dashboard +- [ ] Exportación de estadísticas a PDF/Excel +- [ ] Notificaciones automáticas cuando una clase está por llenarse +- [ ] Sistema de lista de espera automatizado +- [ ] Análisis predictivo de asistencia + +## 🛠️ Stack Tecnológico + +- **Framework**: Spring Boot 3.5.5 +- **Base de Datos**: PostgreSQL +- **ORM**: Spring Data JPA +- **Mapeo**: MapStruct 1.5.5 ⭐ (usado en todos los DTOs) +- **Service Discovery**: Eureka Client +- **Config**: Spring Cloud Config +- **Seguridad**: Spring Security + OAuth2 +- **Documentación**: Swagger/OpenAPI 3 +- **Logging**: SLF4J + Logback + +### 🔄 MapStruct Integration +Los mappers están completamente implementados: +- `ClassMapper`: Mapeo CRUD básico (4 métodos) +- `ClassStatsMapper`: Mapeo de estadísticas y vistas (4 métodos) +- `ClassReservationMapper`: Mapeo de reservas +- Mapeo Entity ↔ DTO sin código manual +- Ver: `MAPSTRUCT_USAGE.md` para detalles + +### 🏗️ Arquitectura de Controladores +Controladores especializados por responsabilidad: +- `AdminClassController`: CRUD (solo ADMIN) +- `ClassViewController`: Vistas y consultas (TRAINER/USER) +- Ver: `ARQUITECTURA_CONTROLADORES.md` para detalles + +--- + +✨ **Desarrollado para FitDesk Gym Management System** diff --git a/pom.xml b/pom.xml index bc1d869..a747dc8 100644 --- a/pom.xml +++ b/pom.xml @@ -10,8 +10,8 @@ 3.5.5 - com.members - msvc-members + com.classes + msvc-classes 0.0.1-SNAPSHOT msvc-classes msvc-classes @@ -40,6 +40,12 @@ org.springframework.boot spring-boot-starter-web + + commons-io + commons-io + 2.19.0 + + org.springframework.boot spring-boot-starter-actuator diff --git a/src/main/java/com/classes/config/AzureConfig.java b/src/main/java/com/classes/config/AzureConfig.java new file mode 100644 index 0000000..df2ceb2 --- /dev/null +++ b/src/main/java/com/classes/config/AzureConfig.java @@ -0,0 +1,44 @@ +package com.classes.config; + +import com.azure.storage.blob.BlobContainerClient; +import com.azure.storage.blob.BlobServiceClient; +import com.azure.storage.blob.BlobServiceClientBuilder; +import com.azure.storage.blob.models.PublicAccessType; +import com.azure.storage.common.StorageSharedKeyCredential; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AzureConfig { + + @Value("${azure.storage.account-name}") + private String accountName; + + @Value("${azure.storage.account-key}") + private String accountKey; + + @Value("${azure.storage.container-name}") + private String containerName; + + @Bean + public BlobContainerClient blobContainerClient() { + String endpoint = String.format("https://%s.blob.core.windows.net", accountName); + StorageSharedKeyCredential credential = new StorageSharedKeyCredential(accountName, accountKey); + + BlobServiceClient serviceClient = new BlobServiceClientBuilder() + .endpoint(endpoint) + .credential(credential) + .buildClient(); + + BlobContainerClient containerClient = serviceClient.getBlobContainerClient(containerName); + + // Crear el contenedor si no existe y configurar acceso público + if (!containerClient.exists()) { + containerClient.create(); + containerClient.setAccessPolicy(PublicAccessType.BLOB, null); + } + + return containerClient; + } +} \ No newline at end of file diff --git a/src/main/java/com/classes/config/CloudinaryConfig.java b/src/main/java/com/classes/config/CloudinaryConfig.java new file mode 100644 index 0000000..333928c --- /dev/null +++ b/src/main/java/com/classes/config/CloudinaryConfig.java @@ -0,0 +1,32 @@ +package com.classes.config; + +import com.cloudinary.Cloudinary; +import com.cloudinary.utils.ObjectUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +@Configuration +public class CloudinaryConfig { + @Value("${cloudinary.cloud_name}") + private String cloudName; + @Value("${cloudinary.api_key}") + private String apikey; + @Value("${cloudinary.api_secret}") + private String apiSecret; + + + @Bean + public Cloudinary cloudinary() { + if (cloudName.isBlank() || apikey.isBlank() || apiSecret.isBlank()) { + throw new IllegalStateException("Faltan propiedades de Cloudinary: configura cloudinary.cloud_name, cloudinary.api_key y cloudinary.api_secret"); + } + return new Cloudinary(ObjectUtils.asMap( + "cloud_name", cloudName, + "api_key", apikey, + "api_secret", apiSecret, + "secure", true + )); + } +} diff --git a/src/main/java/com/classes/config/RestTemplateConfig.java b/src/main/java/com/classes/config/RestTemplateConfig.java new file mode 100644 index 0000000..a376e58 --- /dev/null +++ b/src/main/java/com/classes/config/RestTemplateConfig.java @@ -0,0 +1,16 @@ +package com.classes.config; + +import org.springframework.cloud.client.loadbalancer.LoadBalanced; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + @LoadBalanced + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/src/main/java/com/classes/controllers/ClassController.java b/src/main/java/com/classes/controllers/ClassController.java index c7188d6..7ff30fe 100644 --- a/src/main/java/com/classes/controllers/ClassController.java +++ b/src/main/java/com/classes/controllers/ClassController.java @@ -3,28 +3,32 @@ import com.classes.dtos.Class.ClassRequest; import com.classes.dtos.Class.ClassResponse; import com.classes.services.ClassService; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.PostMapping; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import org.springframework.data.domain.Page; import java.util.List; import java.util.UUID; @RestController -@RequestMapping("/api/classes") +@RequestMapping("/classes") @RequiredArgsConstructor +@Slf4j +@Tag(name = "Admin - Clases", description = "Gestión administrativa de clases (CRUD)") public class ClassController { private final ClassService classService; - - @PostMapping @PreAuthorize("@authorizationServiceImpl.canAccessResource(#id, authentication)") public ResponseEntity createClass(@RequestBody ClassRequest request) { + log.info("Admin creando nueva clase: {}", request.getClassName()); ClassResponse created = classService.createClass(request); return ResponseEntity.status(HttpStatus.CREATED).body(created); } @@ -36,6 +40,15 @@ public ResponseEntity> findAllClasses() { return ResponseEntity.ok(list); } + @GetMapping("/paginated") + public ResponseEntity> findAllClassesPaginated( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String search) { + Page result = classService.findAllPaginated(page, size, search); + return ResponseEntity.ok(result); + } + @PutMapping("/{id}") @PreAuthorize("@authorizationServiceImpl.canAccessResource(#id, authentication)") @@ -44,9 +57,11 @@ public ResponseEntity updateClass(@PathVariable UUID id, @Request return ResponseEntity.ok(updated); } + @DeleteMapping("/{id}") @PreAuthorize("@authorizationServiceImpl.canAccessResource(#id, authentication)") public ResponseEntity deleteClass(@PathVariable UUID id) { + log.info("🗑Admin eliminando clase: {}", id); classService.deleteClass(id); return ResponseEntity.ok("Clase eliminada correctamente"); } diff --git a/src/main/java/com/classes/controllers/ClassReservationController.java b/src/main/java/com/classes/controllers/ClassReservationController.java index 11f6d70..d9f58bb 100644 --- a/src/main/java/com/classes/controllers/ClassReservationController.java +++ b/src/main/java/com/classes/controllers/ClassReservationController.java @@ -16,60 +16,82 @@ import java.util.UUID; @RestController -@RequestMapping("/api/reservations") +@RequestMapping("/reservations") @RequiredArgsConstructor @Slf4j public class ClassReservationController { private final ClassReservationService reservationService; - private final AuthorizationService authorizationService; // Para obtener userId desde el token/cookie + private final AuthorizationService authorizationService; + - // ✅ Reservar una clase @Operation(summary = "Reservar una clase") @PreAuthorize("hasRole('USER') or hasRole('ADMIN')") @PostMapping public ResponseEntity reserveClass( @RequestBody ClassReservationRequest request, Authentication authentication) { - UUID memberId = authorizationService.getUserId(authentication); log.info("🎟️ Usuario {} intentando reservar clase {}", memberId, request.getClassId()); - ClassReservationResponse response = reservationService.reserveClass(request, memberId); return ResponseEntity.ok(response); } - // ✅ Cancelar una reserva + @Operation(summary = "Cancelar una reserva") @PreAuthorize("hasRole('USER') or hasRole('ADMIN')") @DeleteMapping("/{reservationId}") public ResponseEntity cancelReservation( @PathVariable UUID reservationId, Authentication authentication) { - UUID memberId = authorizationService.getUserId(authentication); log.info("❌ Usuario {} cancelando reserva {}", memberId, reservationId); - reservationService.cancelReservation(reservationId, memberId); return ResponseEntity.noContent().build(); } - // ✅ Obtener reservas del usuario (próximas o completadas) + + @Operation(summary = "Confirmar asistencia a una clase reservada") + @PreAuthorize("hasRole('USER') or hasRole('ADMIN')") + @PutMapping("/{reservationId}/confirm") + public ResponseEntity confirmAttendance( + @PathVariable UUID reservationId, + Authentication authentication) { + + UUID memberId = authorizationService.getUserId(authentication); + log.info("✅ Usuario {} confirmando asistencia para reserva {}", memberId, reservationId); + + reservationService.confirmAttendance(reservationId, memberId); + return ResponseEntity.noContent().build(); + } + + @Operation(summary = "Marcar una reserva como completada") + @PreAuthorize("hasRole('USER') or hasRole('ADMIN')") + @PutMapping("/{reservationId}/complete") + public ResponseEntity completeReservation( + @PathVariable UUID reservationId, + Authentication authentication) { + + UUID memberId = authorizationService.getUserId(authentication); + log.info("🏁 Usuario {} completando reserva {}", memberId, reservationId); + + reservationService.completeReservation(reservationId, memberId); + return ResponseEntity.noContent().build(); + } + + @Operation(summary = "Obtener mis reservas activas o completadas") @PreAuthorize("hasRole('USER') or hasRole('ADMIN')") @GetMapping("/my") public ResponseEntity> getMyReservations( Authentication authentication, @RequestParam(required = false) Boolean completed) { - UUID memberId = authorizationService.getUserId(authentication); - log.info("📋 Usuario {} consultando sus reservas", memberId); - + log.info("📋 Usuario {} consultando sus reservas (completed={})", memberId, completed); List reservations = reservationService.getReservationsByMember(memberId, completed); if (reservations.isEmpty()) { return ResponseEntity.noContent().build(); } - return ResponseEntity.ok(reservations); } } \ No newline at end of file diff --git a/src/main/java/com/classes/controllers/ClassViewController.java b/src/main/java/com/classes/controllers/ClassViewController.java new file mode 100644 index 0000000..0e67036 --- /dev/null +++ b/src/main/java/com/classes/controllers/ClassViewController.java @@ -0,0 +1,84 @@ +package com.classes.controllers; + +import com.classes.dtos.Class.*; +import com.classes.services.AuthorizationService; +import com.classes.services.ClassService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + + +@RestController +@RequestMapping("/stadistic") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "Clases - Vistas", description = "Consultas y vistas de clases") +public class ClassViewController { + + private final ClassService classService; + private final AuthorizationService authorizationService; + + @Operation(summary = "Listar todas las clases", description = "Todos los roles pueden ver la lista") + @GetMapping + public ResponseEntity> findAllClasses() { + log.info("Consultando todas las clases"); + List list = classService.findAll(); + return ResponseEntity.ok(list); + } + + @Operation(summary = "Obtener mis clases con estadísticas", description = "Vista para trainers con métricas") + @GetMapping("/my-classes/stats") + @PreAuthorize("hasRole('TRAINER') or hasRole('ADMIN')") + public ResponseEntity> getMyClassesWithStats(Authentication authentication) { + UUID trainerId = authorizationService.getUserId(authentication); + log.info("Trainer {} consultando sus clases con estadísticas", trainerId); + List classes = classService.getClassesWithStatsByTrainer(trainerId); + return ResponseEntity.ok(classes); + } + + @Operation(summary = "Ver detalle de clase con estudiantes", description = "Vista detallada para trainers") + @GetMapping("/{id}/detail") + @PreAuthorize("hasRole('TRAINER') or hasRole('ADMIN')") + public ResponseEntity getClassDetail(@PathVariable UUID id) { + log.info("Consultando detalle de la clase {}", id); + ClassDetailResponse detail = classService.getClassDetail(id); + return ResponseEntity.ok(detail); + } + + @Operation(summary = "Ver calendario de clases", description = "Vista de calendario para todos") + @GetMapping("/calendar") + @PreAuthorize("hasRole('USER') or hasRole('TRAINER') or hasRole('ADMIN')") + public ResponseEntity> getClassesForCalendar( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { + + log.info("📅 Consultando clases para calendario"); + + if (startDate != null && endDate != null) { + List classes = classService.getClassesForCalendar(startDate, endDate); + return ResponseEntity.ok(classes); + } else { + List classes = classService.getUpcomingClasses(); + return ResponseEntity.ok(classes); + } + } + + @Operation(summary = "Ver próximas clases", description = "Lista de clases futuras") + @GetMapping("/upcoming") + @PreAuthorize("hasRole('USER') or hasRole('TRAINER') or hasRole('ADMIN')") + public ResponseEntity> getUpcomingClasses() { + log.info("Consultando próximas clases"); + List classes = classService.getUpcomingClasses(); + return ResponseEntity.ok(classes); + } +} diff --git a/src/main/java/com/classes/controllers/DashboardController.java b/src/main/java/com/classes/controllers/DashboardController.java index c2f6c34..ae1f431 100644 --- a/src/main/java/com/classes/controllers/DashboardController.java +++ b/src/main/java/com/classes/controllers/DashboardController.java @@ -15,7 +15,7 @@ import java.util.UUID; @RestController -@RequestMapping("/api/dashboard") +@RequestMapping("/dashboard") @RequiredArgsConstructor @Slf4j public class DashboardController { diff --git a/src/main/java/com/classes/controllers/TrainerController.java b/src/main/java/com/classes/controllers/TrainerController.java index 07db9e2..104252a 100644 --- a/src/main/java/com/classes/controllers/TrainerController.java +++ b/src/main/java/com/classes/controllers/TrainerController.java @@ -1,6 +1,8 @@ package com.classes.controllers; -import com.classes.dtos.Trainer.TrainerDTO; +import com.classes.dtos.Trainer.ImageResponseDTO; +import com.classes.dtos.Trainer.TrainerRequestDTO; +import com.classes.dtos.Trainer.TrainerResponseDTO; import com.classes.services.AuthorizationService; import com.classes.services.TrainerService; import com.fasterxml.jackson.databind.ObjectMapper; @@ -9,11 +11,11 @@ import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -24,77 +26,95 @@ import java.util.UUID; @RestController -@RequestMapping("/api/trainers") +@RequestMapping("/trainers") @RequiredArgsConstructor @Slf4j public class TrainerController { private final TrainerService trainerService; - private final AuthorizationService authService; - -/*probado*/ - @PreAuthorize("@authorizationServiceImpl.canAccessResource(#id,authentication)") - @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity createTrainer( - Authentication authentication, - @RequestParam("trainer") String trainerJson, - @RequestParam(value = "profileImage", required = false) MultipartFile profileImage, - @RequestParam(value = "certifications", required = false) List certifications - ) throws IOException { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new JavaTimeModule()); - mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - TrainerDTO trainerDTO = mapper.readValue(trainerJson, TrainerDTO.class); - TrainerDTO createdTrainer = trainerService.createTrainer(trainerDTO, profileImage, certifications); - return ResponseEntity.status(HttpStatus.CREATED).body(createdTrainer); - } - /*probado*/ - @PreAuthorize("@authorizationServiceImpl.canAccessResource(#id,authentication)") - @GetMapping("/{id}") - public ResponseEntity getTrainerById(@PathVariable UUID id) { - TrainerDTO trainer = trainerService.getTrainerById(id); - return ResponseEntity.ok(trainer); - } - //probado - @PreAuthorize("@authorizationServiceImpl.canAccessResource(#id,authentication)") - @GetMapping - public ResponseEntity> getAllTrainers() { - List trainers = trainerService.getAllTrainers(); - return ResponseEntity.ok(trainers); - } + @PreAuthorize("@authorizationServiceImpl.canAccessResource(#id,authentication)") + @GetMapping + public ResponseEntity> getAllTrainers( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String search, + @RequestParam(required = false) String status + ) { + Page trainers = trainerService.getAllTrainers(page, size, search, status); + return ResponseEntity.ok(trainers); + } - @PreAuthorize("@authorizationServiceImpl.canAccessResource(#id, authentication)") - @PutMapping(value = "/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity updateTrainer( - @PathVariable UUID id, - @RequestParam("trainer") String trainerJson, - @RequestParam(value = "profileImage", required = false) MultipartFile profileImage, - @RequestParam(value = "certifications", required = false) List certifications - ) throws IOException { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new JavaTimeModule()); - mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - TrainerDTO trainerDTO = mapper.readValue(trainerJson, TrainerDTO.class); - TrainerDTO updatedTrainer = trainerService.updateTrainer(id, trainerDTO, profileImage, certifications); - return ResponseEntity.ok(updatedTrainer); - } + @PreAuthorize("@authorizationServiceImpl.canAccessResource(#id,authentication)") + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createTrainer( + @RequestParam("trainer") String trainerJson, + @RequestParam(value = "profileImage", required = false) MultipartFile profileImage, + @RequestParam(value = "certifications", required = false) List certifications + ) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + TrainerRequestDTO trainerDTO = mapper.readValue(trainerJson, TrainerRequestDTO.class); + TrainerResponseDTO createdTrainer = trainerService.createTrainer(trainerDTO, profileImage, certifications); + return ResponseEntity.status(HttpStatus.CREATED).body(createdTrainer); + } + + @PreAuthorize("@authorizationServiceImpl.canAccessResource(#id,authentication)") + @GetMapping("/{id}") + public ResponseEntity getTrainerById(@PathVariable UUID id) { + TrainerResponseDTO trainer = trainerService.getTrainerById(id); + return ResponseEntity.ok(trainer); + } - @PreAuthorize("@authorizationServiceImpl.canAccessResource(#id,authentication)") - @DeleteMapping("/{id}") - public ResponseEntity deleteTrainer(@PathVariable UUID id) { - try { - trainerService.deleteTrainer(id); - return ResponseEntity.noContent().build(); - } catch ( - EntityNotFoundException e) { - return ResponseEntity.notFound().build(); - } catch ( - IOException e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + @PreAuthorize("@authorizationServiceImpl.canAccessResource(#id,authentication)") + @PutMapping(value = "/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity updateTrainer( + @PathVariable UUID id, + @RequestParam("trainer") String trainerJson, + @RequestParam(value = "profileImage", required = false) MultipartFile profileImage, + @RequestParam(value = "certifications", required = false) List certifications + ) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + TrainerRequestDTO trainerDTO = mapper.readValue(trainerJson, TrainerRequestDTO.class); + TrainerResponseDTO updatedTrainer = trainerService.updateTrainer(id, trainerDTO, profileImage, certifications); + return ResponseEntity.ok(updatedTrainer); } + + + @PreAuthorize("@authorizationServiceImpl.canAccessResource(#id,authentication)") + @PutMapping("/{id}/profile-image") + public ResponseEntity updateTrainerProfileImage( + @PathVariable UUID id, + @RequestParam("file") MultipartFile file) throws IOException { + return ResponseEntity.ok(trainerService.updateTrainerProfile(id, file)); + } + + @PreAuthorize("@authorizationServiceImpl.canAccessResource(#id,authentication)") + @DeleteMapping("/{id}") + public ResponseEntity deleteTrainer(@PathVariable UUID id) { + try { + trainerService.deleteTrainer(id); + return ResponseEntity.noContent().build(); // 204 No Content si se eliminó correctamente + } catch (EntityNotFoundException e) { + return ResponseEntity.notFound().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(null); + } catch (IOException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); // 500 si falla la eliminación de archivos + } + } + + @PreAuthorize("@authorizationServiceImpl.canAccessResource(#id,authentication)") + @DeleteMapping("/{id}/profile-image") + public ResponseEntity deleteTrainerProfileImage(@PathVariable UUID id) { + boolean deleted = trainerService.deleteTrainerProfileImage(id); + return deleted ? ResponseEntity.noContent().build() : ResponseEntity.badRequest().build(); + } + } -} diff --git a/src/main/java/com/classes/controllers/TrainerDashboardController.java b/src/main/java/com/classes/controllers/TrainerDashboardController.java new file mode 100644 index 0000000..3361b5d --- /dev/null +++ b/src/main/java/com/classes/controllers/TrainerDashboardController.java @@ -0,0 +1,37 @@ +package com.classes.controllers; + +import com.classes.dtos.Dashboard.TrainerDashboardDTO; +import com.classes.services.AuthorizationService; +import com.classes.services.TrainerDashboardService; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping("/dashboard/trainer") +@RequiredArgsConstructor +@Slf4j +public class TrainerDashboardController { + + private final TrainerDashboardService dashboardService; + private final AuthorizationService authorizationService; + + @Operation(summary = "Obtener dashboard del trainer autenticado con métricas") + @GetMapping + @PreAuthorize("hasRole('TRAINER') or hasRole('ADMIN')") + public ResponseEntity getTrainerDashboard(Authentication authentication) { + UUID trainerId = authorizationService.getUserId(authentication); + log.info("Trainer {} consultando su dashboard", trainerId); + + TrainerDashboardDTO dashboard = dashboardService.getDashboardForTrainer(trainerId); + return ResponseEntity.ok(dashboard); + } +} diff --git a/src/main/java/com/classes/dtos/Class/CalendarClassDTO.java b/src/main/java/com/classes/dtos/Class/CalendarClassDTO.java new file mode 100644 index 0000000..b4f1998 --- /dev/null +++ b/src/main/java/com/classes/dtos/Class/CalendarClassDTO.java @@ -0,0 +1,36 @@ +package com.classes.dtos.Class; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.UUID; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class CalendarClassDTO { + private UUID id; + private String className; + private String trainerName; + + @JsonFormat(pattern = "dd-MM-yyyy") + private LocalDate classDate; + + @JsonFormat(pattern = "HH:mm") + private LocalTime startTime; + + @JsonFormat(pattern = "HH:mm") + private LocalTime endTime; + + private String schedule; // "08:00 - 09:00" + private String locationName; + private int currentStudents; + private int maxCapacity; + private String action; // "Reservar", "Lista de espera", "Llena" +} diff --git a/src/main/java/com/classes/dtos/Class/ClassDetailResponse.java b/src/main/java/com/classes/dtos/Class/ClassDetailResponse.java new file mode 100644 index 0000000..b0f45a7 --- /dev/null +++ b/src/main/java/com/classes/dtos/Class/ClassDetailResponse.java @@ -0,0 +1,39 @@ +package com.classes.dtos.Class; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.UUID; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ClassDetailResponse { + private UUID id; + private String className; + private String description; + private int currentStudents; + private int maxCapacity; + private String trainerName; + private String locationName; + + @JsonFormat(pattern = "dd-MM-yyyy") + private LocalDate classDate; + + @JsonFormat(pattern = "HH:mm") + private LocalTime startTime; + + @JsonFormat(pattern = "HH:mm") + private LocalTime endTime; + + private String schedule; + private boolean active; + private List students; +} diff --git a/src/main/java/com/classes/dtos/Class/ClassWithStatsResponse.java b/src/main/java/com/classes/dtos/Class/ClassWithStatsResponse.java new file mode 100644 index 0000000..6fa3877 --- /dev/null +++ b/src/main/java/com/classes/dtos/Class/ClassWithStatsResponse.java @@ -0,0 +1,39 @@ +package com.classes.dtos.Class; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.UUID; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ClassWithStatsResponse { + private UUID id; + private String className; + private String description; + private int currentStudents; + private int maxCapacity; + private String trainerName; + private String locationName; + + @JsonFormat(pattern = "dd-MM-yyyy") + private LocalDate classDate; + + @JsonFormat(pattern = "HH:mm") + private LocalTime startTime; + + @JsonFormat(pattern = "HH:mm") + private LocalTime endTime; + + private String schedule; // "Lun 08:00 - 09:00" + private double averageAttendance; // Porcentaje promedio de asistencia + private boolean active; + private String status; +} diff --git a/src/main/java/com/classes/dtos/Class/StudentInClassDTO.java b/src/main/java/com/classes/dtos/Class/StudentInClassDTO.java new file mode 100644 index 0000000..fc0fb25 --- /dev/null +++ b/src/main/java/com/classes/dtos/Class/StudentInClassDTO.java @@ -0,0 +1,30 @@ +package com.classes.dtos.Class; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class StudentInClassDTO { + private UUID memberId; + private String name; + private String email; + private String avatarInitials; // Para mostrar iniciales en avatar + private String status; // "Activo", "Inactivo", "Suspendido" + private String membershipType; // "Mensual", "Anual", "Premium" + private double attendancePercentage; // Porcentaje de asistencia + private int totalClasses; // Total de clases del estudiante + + @JsonFormat(pattern = "dd MMM yyyy") + private LocalDateTime lastAccess; + + private UUID reservationId; +} diff --git a/src/main/java/com/classes/dtos/Dashboard/StudentTrendDTO.java b/src/main/java/com/classes/dtos/Dashboard/StudentTrendDTO.java new file mode 100644 index 0000000..c92fca1 --- /dev/null +++ b/src/main/java/com/classes/dtos/Dashboard/StudentTrendDTO.java @@ -0,0 +1,17 @@ +package com.classes.dtos.Dashboard; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class StudentTrendDTO { + private String week; // "Sem 1", "Sem 2", etc. + private int activeStudents; + private int inactiveStudents; + private String label; // Para el tooltip del gráfico +} diff --git a/src/main/java/com/classes/dtos/Dashboard/TrainerDashboardDTO.java b/src/main/java/com/classes/dtos/Dashboard/TrainerDashboardDTO.java new file mode 100644 index 0000000..b826e85 --- /dev/null +++ b/src/main/java/com/classes/dtos/Dashboard/TrainerDashboardDTO.java @@ -0,0 +1,20 @@ +package com.classes.dtos.Dashboard; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class TrainerDashboardDTO { + private int totalStudents; + private double averageAttendance; // Porcentaje promedio + private int classesThisMonth; + private double attendanceChange; // Cambio porcentual respecto al mes anterior + private List studentTrends; // Tendencia semanal de estudiantes +} diff --git a/src/main/java/com/classes/dtos/Dashboard/WeeklyActivityDTO.java b/src/main/java/com/classes/dtos/Dashboard/WeeklyActivityDTO.java index 75c569b..259eeb2 100644 --- a/src/main/java/com/classes/dtos/Dashboard/WeeklyActivityDTO.java +++ b/src/main/java/com/classes/dtos/Dashboard/WeeklyActivityDTO.java @@ -6,6 +6,6 @@ @Data @Builder public class WeeklyActivityDTO { - private String day; // Lunes, Martes, etc. - private int sessions; // cantidad de clases asistidas ese día + private String day; + private int sessions; } \ No newline at end of file diff --git a/src/main/java/com/classes/dtos/Trainer/FileResponseDTO.java b/src/main/java/com/classes/dtos/Trainer/FileResponseDTO.java index ed64c33..429ac5d 100644 --- a/src/main/java/com/classes/dtos/Trainer/FileResponseDTO.java +++ b/src/main/java/com/classes/dtos/Trainer/FileResponseDTO.java @@ -1,8 +1,6 @@ package com.classes.dtos.Trainer; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; @Data @AllArgsConstructor diff --git a/src/main/java/com/classes/dtos/Trainer/ImageResponseDTO.java b/src/main/java/com/classes/dtos/Trainer/ImageResponseDTO.java new file mode 100644 index 0000000..5a97bbc --- /dev/null +++ b/src/main/java/com/classes/dtos/Trainer/ImageResponseDTO.java @@ -0,0 +1,21 @@ +package com.classes.dtos.Trainer; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ImageResponseDTO { + private String fileName; + private String fileUrl; + private String fileId; // publicId en Cloudinary + private String format; + private Long size; + private Integer width; + private Integer height; + +} \ No newline at end of file diff --git a/src/main/java/com/classes/dtos/Trainer/TrainerDTO.java b/src/main/java/com/classes/dtos/Trainer/TrainerRequestDTO.java similarity index 93% rename from src/main/java/com/classes/dtos/Trainer/TrainerDTO.java rename to src/main/java/com/classes/dtos/Trainer/TrainerRequestDTO.java index ec47f27..dd99d2d 100644 --- a/src/main/java/com/classes/dtos/Trainer/TrainerDTO.java +++ b/src/main/java/com/classes/dtos/Trainer/TrainerRequestDTO.java @@ -13,32 +13,26 @@ import java.util.Set; @Data -public class TrainerDTO { +public class TrainerRequestDTO { private String firstName; private String lastName; private String dni; - - @JsonFormat(pattern = "dd-MM-yyyy") + @JsonFormat (pattern = "dd-MM-yyyy") private LocalDate birthDate; - private Gender gender; private String phone; private String email; private String address; - private String profileImageUrl; private String specialties; private int yearsOfExperience; private List certifications; private Set availability; - @JsonFormat(pattern = "dd-MM-yyyy") private LocalDate hireDate; - private TrainerStatus status; private ContractType contractType; private BigDecimal salaryPerClass; - private String bankInfo; private String notes; -} +} \ No newline at end of file diff --git a/src/main/java/com/classes/dtos/Trainer/TrainerResponseDTO.java b/src/main/java/com/classes/dtos/Trainer/TrainerResponseDTO.java new file mode 100644 index 0000000..795b8e9 --- /dev/null +++ b/src/main/java/com/classes/dtos/Trainer/TrainerResponseDTO.java @@ -0,0 +1,39 @@ +package com.classes.dtos.Trainer; +import com.classes.enums.ContractType; +import com.classes.enums.DayAvailability; +import com.classes.enums.Gender; +import com.classes.enums.TrainerStatus; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Data +public class TrainerResponseDTO { + private UUID id; + private String firstName; + private String lastName; + private String dni; + @JsonFormat(pattern = "dd-MM-yyyy") + private LocalDate birthDate; + private Gender gender; + private String phone; + private String email; + private String address; + private String profileImageUrl; + private String specialties; + private int yearsOfExperience; + private List certifications; + private Set availability; + @JsonFormat(pattern = "dd-MM-yyyy") + private LocalDate hireDate; + private TrainerStatus status; + private ContractType contractType; + private BigDecimal salaryPerClass; + private String bankInfo; + private String notes; +} diff --git a/src/main/java/com/classes/dtos/external/MemberInfoDTO.java b/src/main/java/com/classes/dtos/external/MemberInfoDTO.java new file mode 100644 index 0000000..32fbed5 --- /dev/null +++ b/src/main/java/com/classes/dtos/external/MemberInfoDTO.java @@ -0,0 +1,26 @@ +package com.classes.dtos.external; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * DTO para recibir información del microservicio de Members + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class MemberInfoDTO { + private UUID id; + private String firstName; + private String lastName; + private String email; + private String status; // "ACTIVO", "INACTIVO", "SUSPENDIDO" + private String membershipType; // "MENSUAL", "ANUAL", "PREMIUM" + private LocalDateTime lastAccess; +} diff --git a/src/main/java/com/classes/entities/ClassEntity.java b/src/main/java/com/classes/entities/ClassEntity.java index 38dc6f2..bdca8b6 100644 --- a/src/main/java/com/classes/entities/ClassEntity.java +++ b/src/main/java/com/classes/entities/ClassEntity.java @@ -26,6 +26,7 @@ public class ClassEntity { private int duration; private int maxCapacity; + private LocalDate classDate; private LocalTime startTime; private LocalTime endTime; private boolean active; diff --git a/src/main/java/com/classes/entities/ClassReservation.java b/src/main/java/com/classes/entities/ClassReservation.java index 46c769c..ed7fbc6 100644 --- a/src/main/java/com/classes/entities/ClassReservation.java +++ b/src/main/java/com/classes/entities/ClassReservation.java @@ -29,7 +29,9 @@ public class ClassReservation { @Enumerated(EnumType.STRING) private ReservationStatus status; // RESERVADO, LISTA_ESPERA, CANCELADO private LocalDateTime reservedAt; - @Transient - private boolean attended; + @Column(name = "attended") + private Boolean attended = false; + + } diff --git a/src/main/java/com/classes/entities/TrainerEntity.java b/src/main/java/com/classes/entities/TrainerEntity.java index acbdb9b..60e8c2d 100644 --- a/src/main/java/com/classes/entities/TrainerEntity.java +++ b/src/main/java/com/classes/entities/TrainerEntity.java @@ -26,6 +26,7 @@ public class TrainerEntity { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; + private UUID userid; private String firstName; private String lastName; @@ -68,7 +69,4 @@ public class TrainerEntity { @Embedded private Audit audit; - - - } \ No newline at end of file diff --git a/src/main/java/com/classes/enums/ReservationStatus.java b/src/main/java/com/classes/enums/ReservationStatus.java index edf6e61..51619c2 100644 --- a/src/main/java/com/classes/enums/ReservationStatus.java +++ b/src/main/java/com/classes/enums/ReservationStatus.java @@ -1,7 +1,9 @@ package com.classes.enums; public enum ReservationStatus { - RESERVADO, - LISTA_ESPERA, - CANCELADO + RESERVADO, // El usuario reservó una clase + LISTA_ESPERA, // No hay cupos, está en espera + CANCELADO, // El usuario canceló + PENDIENTE, // Confirmó que asistirá + COMPLETADO } diff --git a/src/main/java/com/classes/exceptions/ImageUploadException.java b/src/main/java/com/classes/exceptions/ImageUploadException.java new file mode 100644 index 0000000..182d3b0 --- /dev/null +++ b/src/main/java/com/classes/exceptions/ImageUploadException.java @@ -0,0 +1,12 @@ +package com.classes.exceptions; + +public class ImageUploadException extends RuntimeException { + + public ImageUploadException(String message) { + super(message); + } + + public ImageUploadException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/classes/exceptions/InvalidImageFormatException.java b/src/main/java/com/classes/exceptions/InvalidImageFormatException.java new file mode 100644 index 0000000..575728b --- /dev/null +++ b/src/main/java/com/classes/exceptions/InvalidImageFormatException.java @@ -0,0 +1,7 @@ +package com.classes.exceptions; + +public class InvalidImageFormatException extends RuntimeException { + public InvalidImageFormatException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/classes/mappers/ClassMapper.java b/src/main/java/com/classes/mappers/ClassMapper.java index 46985e2..665bd8d 100644 --- a/src/main/java/com/classes/mappers/ClassMapper.java +++ b/src/main/java/com/classes/mappers/ClassMapper.java @@ -2,7 +2,6 @@ import com.classes.config.MapStructConfig; import com.classes.dtos.Class.ClassRequest; - import com.classes.dtos.Class.ClassResponse; import com.classes.entities.ClassEntity; @@ -10,25 +9,44 @@ import java.util.List; +/** + * Mapper básico para operaciones CRUD de clases + * Para estadísticas y vistas especiales, usar ClassStatsMapper + */ @Mapper(config = MapStructConfig.class) public interface ClassMapper { - // Crear entidad desde el request - + /** + * Crear entidad desde el request (para INSERT) + */ @Mapping(target = "id", ignore = true) @Mapping(target = "location", ignore = true) // se setean en el servicio @Mapping(target = "trainer", ignore = true) + @Mapping(target = "reservations", ignore = true) + @Mapping(target = "audit", ignore = true) ClassEntity toEntity(ClassRequest request); + /** + * Convertir entidad a response básico + */ @Mapping(target = "locationName", source = "location.name") - @Mapping(target = "trainerName", source = "trainer.firstName") + @Mapping(target = "trainerName", expression = "java(entity.getTrainer().getFirstName() + \" \" + entity.getTrainer().getLastName())") @Mapping(target = "schedule", expression = "java(entity.getStartTime() + \" - \" + entity.getEndTime())") ClassResponse toResponse(ClassEntity entity); - // Lista de respuestas + /** + * Lista de respuestas + */ List toResponseList(List entities); - // Actualizar entidad desde el request (solo los campos no nulos) + /** + * Actualizar entidad desde el request (para UPDATE - solo los campos no nulos) + */ @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + @Mapping(target = "id", ignore = true) + @Mapping(target = "trainer", ignore = true) + @Mapping(target = "location", ignore = true) + @Mapping(target = "reservations", ignore = true) + @Mapping(target = "audit", ignore = true) void updateFromRequest(ClassRequest request, @MappingTarget ClassEntity entity); } diff --git a/src/main/java/com/classes/mappers/ClassReservationMapper.java b/src/main/java/com/classes/mappers/ClassReservationMapper.java index 8d07511..7ceb8bd 100644 --- a/src/main/java/com/classes/mappers/ClassReservationMapper.java +++ b/src/main/java/com/classes/mappers/ClassReservationMapper.java @@ -28,7 +28,7 @@ public interface ClassReservationMapper { @Mapping(target = "locationName", source = "entity.classEntity.location.name") @Mapping(target = "schedule", expression = "java(entity.getClassEntity().getStartTime() + \" - \" + entity.getClassEntity().getEndTime())") @Mapping(target = "capacity", expression = "java(entity.getClassEntity().getReservations().stream().filter(r -> r.getStatus() == com.classes.enums.ReservationStatus.RESERVADO).count() + \"/\" + entity.getClassEntity().getMaxCapacity() + \" inscritos\")") - @Mapping(target = "action", expression = "java(entity.getStatus() == com.classes.enums.ReservationStatus.RESERVADO ? \"Cancelar\" : entity.getStatus() == com.classes.enums.ReservationStatus.LISTA_ESPERA ? \"Esperar\" : \"Reservar\")") + @Mapping(target = "action", expression = "java(entity.getStatus().name())") @Mapping(target = "alreadyReserved", expression = "java(entity.getStatus() != com.classes.enums.ReservationStatus.CANCELADO)") @Mapping(target = "completed", expression = "java(entity.getClassEntity().getStartTime().isBefore(java.time.LocalTime.now()))") ClassReservationResponse toResponse(ClassReservation entity); diff --git a/src/main/java/com/classes/mappers/ClassStatsMapper.java b/src/main/java/com/classes/mappers/ClassStatsMapper.java new file mode 100644 index 0000000..64b8464 --- /dev/null +++ b/src/main/java/com/classes/mappers/ClassStatsMapper.java @@ -0,0 +1,53 @@ +package com.classes.mappers; + +import com.classes.config.MapStructConfig; +import com.classes.dtos.Class.CalendarClassDTO; +import com.classes.dtos.Class.ClassDetailResponse; +import com.classes.dtos.Class.ClassWithStatsResponse; +import com.classes.entities.ClassEntity; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.time.DayOfWeek; +import java.time.format.TextStyle; +import java.util.List; +import java.util.Locale; + + +@Mapper(config = MapStructConfig.class) +public interface ClassStatsMapper { + + + @Mapping(target = "trainerName", expression = "java(entity.getTrainer().getFirstName() + \" \" + entity.getTrainer().getLastName())") + @Mapping(target = "locationName", source = "location.name") + @Mapping(target = "schedule", expression = "java(formatScheduleWithDay(entity))") + @Mapping(target = "currentStudents", ignore = true) // se calcula en servicio + @Mapping(target = "averageAttendance", ignore = true) // se calcula en servicio + @Mapping(target = "status", ignore = true) // se calcula en servicio + ClassWithStatsResponse toClassWithStatsResponse(ClassEntity entity); + + + @Mapping(target = "trainerName", expression = "java(entity.getTrainer().getFirstName() + \" \" + entity.getTrainer().getLastName())") + @Mapping(target = "locationName", source = "location.name") + @Mapping(target = "schedule", expression = "java(formatScheduleWithDay(entity))") + @Mapping(target = "currentStudents", ignore = true) // se calcula en servicio + @Mapping(target = "students", ignore = true) // se calcula en servicio + ClassDetailResponse toClassDetailResponse(ClassEntity entity); + + + @Mapping(target = "trainerName", expression = "java(entity.getTrainer().getFirstName() + \" \" + entity.getTrainer().getLastName())") + @Mapping(target = "locationName", source = "location.name") + @Mapping(target = "schedule", expression = "java(entity.getStartTime() + \" - \" + entity.getEndTime())") + @Mapping(target = "currentStudents", ignore = true) // se calcula en servicio + @Mapping(target = "action", ignore = true) // se calcula en servicio + CalendarClassDTO toCalendarDTO(ClassEntity entity); + + List toCalendarDTOList(List entities); + + + default String formatScheduleWithDay(ClassEntity entity) { + DayOfWeek dayOfWeek = entity.getClassDate().getDayOfWeek(); + String dayName = dayOfWeek.getDisplayName(TextStyle.SHORT, new Locale("es")); + return dayName + " " + entity.getStartTime() + " - " + entity.getEndTime(); + } +} diff --git a/src/main/java/com/classes/mappers/TrainerMapper.java b/src/main/java/com/classes/mappers/TrainerMapper.java index 666f32f..71b2ffb 100644 --- a/src/main/java/com/classes/mappers/TrainerMapper.java +++ b/src/main/java/com/classes/mappers/TrainerMapper.java @@ -1,7 +1,8 @@ package com.classes.mappers; import com.classes.config.MapStructConfig; -import com.classes.dtos.Trainer.TrainerDTO; +import com.classes.dtos.Trainer.TrainerRequestDTO; +import com.classes.dtos.Trainer.TrainerResponseDTO; import com.classes.entities.TrainerEntity; import org.mapstruct.BeanMapping; import org.mapstruct.Mapper; @@ -12,9 +13,12 @@ @Mapper(config = MapStructConfig.class) public interface TrainerMapper { - TrainerEntity toEntity(TrainerDTO dto); - TrainerDTO toDTO(TrainerEntity trainer); + TrainerEntity toEntity(TrainerRequestDTO dto); + + TrainerResponseDTO toResponseDTO(TrainerEntity trainer); + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) - void updateTrainerFromDTO(TrainerDTO dto, @MappingTarget TrainerEntity trainer); - List toDTOList(List trainers); + void updateTrainerFromDTO(TrainerRequestDTO dto, @MappingTarget TrainerEntity trainer); + + List toResponseDTOList(List trainers); } diff --git a/src/main/java/com/classes/repositories/ClassRepository.java b/src/main/java/com/classes/repositories/ClassRepository.java index 6e32432..434fe13 100644 --- a/src/main/java/com/classes/repositories/ClassRepository.java +++ b/src/main/java/com/classes/repositories/ClassRepository.java @@ -1,9 +1,15 @@ package com.classes.repositories; import com.classes.entities.ClassEntity; +import org.springframework.data.domain.Page; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; +import java.time.LocalDate; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -12,4 +18,22 @@ public interface ClassRepository extends JpaRepository { Optional findFirstByTrainerId(UUID trainerId); Optional findFirstByLocationId(UUID locationId); + Page findByClassNameContainingIgnoreCaseOrTrainerFirstNameContainingIgnoreCaseOrTrainerLastNameContainingIgnoreCase( + String className, String trainerFirstName, String trainerLastName, Pageable pageable); + + List findByTrainerId(UUID trainerId); + + + List findByActiveTrue(); + + + List findByClassDateBetween(LocalDate startDate, LocalDate endDate); + + + @Query("SELECT c FROM ClassEntity c WHERE c.classDate >= :currentDate ORDER BY c.classDate, c.startTime") + List findUpcomingClasses(@Param("currentDate") LocalDate currentDate); + + + @Query("SELECT COUNT(c) FROM ClassEntity c WHERE c.trainer.id = :trainerId AND YEAR(c.classDate) = :year AND MONTH(c.classDate) = :month") + long countByTrainerIdAndMonth(@Param("trainerId") UUID trainerId, @Param("year") int year, @Param("month") int month); } diff --git a/src/main/java/com/classes/repositories/ClassReservationRepository.java b/src/main/java/com/classes/repositories/ClassReservationRepository.java index 95a3e53..89b6375 100644 --- a/src/main/java/com/classes/repositories/ClassReservationRepository.java +++ b/src/main/java/com/classes/repositories/ClassReservationRepository.java @@ -1,13 +1,70 @@ package com.classes.repositories; import com.classes.entities.ClassReservation; +import com.classes.enums.ReservationStatus; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; public interface ClassReservationRepository extends JpaRepository { + // 🔹 Obtener todas las reservas de un miembro List findByMemberId(UUID memberId); + + // 🔹 Obtener todas las reservas de una clase List findByClassEntityId(UUID classId); + + // 🔹 Buscar una reserva específica por clase y miembro Optional findByClassEntityIdAndMemberId(UUID classId, UUID memberId); + + // 🔹 Contar reservas activas por clase según estado + @Query("SELECT COUNT(r) FROM ClassReservation r WHERE r.classEntity.id = :classId AND r.status = :status") + long countByClassEntityIdAndStatus(@Param("classId") UUID classId, + @Param("status") ReservationStatus status); + + // 🔹 Obtener reservas de un miembro por estado + List findByMemberIdAndStatus(UUID memberId, ReservationStatus status); + + // 🔹 Calcular asistencia promedio (%) de una clase + @Query(""" + SELECT COALESCE(AVG(CASE WHEN r.attended = true THEN 1.0 ELSE 0.0 END) * 100, 0) + FROM ClassReservation r + WHERE r.classEntity.id = :classId + """) + Double calculateAverageAttendanceByClassId(@Param("classId") UUID classId); + + // 🔹 Obtener reservas de un miembro dentro de un rango de fechas + @Query(""" + SELECT r FROM ClassReservation r + WHERE r.memberId = :memberId + AND r.reservedAt BETWEEN :startDate AND :endDate + """) + List findByMemberIdAndDateRange(@Param("memberId") UUID memberId, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate); + + // 🔹 Contar total de reservas gestionadas por un entrenador + @Query(""" + SELECT COUNT(r) FROM ClassReservation r + WHERE r.classEntity.trainer.id = :trainerId + """) + long countReservationsByTrainerId(@Param("trainerId") UUID trainerId); + + // 🔹 Calcular asistencia promedio (%) de un entrenador + @Query(""" + SELECT COALESCE(AVG(CASE WHEN r.attended = true THEN 1.0 ELSE 0.0 END) * 100, 0) + FROM ClassReservation r + WHERE r.classEntity.trainer.id = :trainerId + """) + Double calculateAverageAttendanceByTrainerId(@Param("trainerId") UUID trainerId); + + // 🔹 Obtener todas las reservas con asistencia (true) + List findByAttendedTrue(); + + // 🔹 Obtener todas las reservas sin asistencia (false) + List findByAttendedFalse(); } diff --git a/src/main/java/com/classes/repositories/TrainerRepository.java b/src/main/java/com/classes/repositories/TrainerRepository.java index 9af8498..0da0629 100644 --- a/src/main/java/com/classes/repositories/TrainerRepository.java +++ b/src/main/java/com/classes/repositories/TrainerRepository.java @@ -1,6 +1,9 @@ package com.classes.repositories; import com.classes.entities.TrainerEntity; +import com.classes.enums.TrainerStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -11,6 +14,11 @@ @Repository public interface TrainerRepository extends JpaRepository { + + Page findByFirstNameContainingIgnoreCaseOrLastNameContainingIgnoreCase( + String firstName, String lastName, Pageable pageable); + + Page findByStatus(TrainerStatus status, Pageable pageable); } diff --git a/src/main/java/com/classes/services/AzureService.java b/src/main/java/com/classes/services/AzureService.java index 0362893..9c30aae 100644 --- a/src/main/java/com/classes/services/AzureService.java +++ b/src/main/java/com/classes/services/AzureService.java @@ -1,6 +1,7 @@ package com.classes.services; import com.classes.dtos.Trainer.FileResponseDTO; +import com.classes.dtos.Trainer.ImageResponseDTO; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; diff --git a/src/main/java/com/classes/services/ClassReservationService.java b/src/main/java/com/classes/services/ClassReservationService.java index c33c0c8..0d9db30 100644 --- a/src/main/java/com/classes/services/ClassReservationService.java +++ b/src/main/java/com/classes/services/ClassReservationService.java @@ -11,5 +11,8 @@ public interface ClassReservationService { ClassReservationResponse reserveClass(ClassReservationRequest request, UUID memberId); void cancelReservation(UUID reservationId, UUID memberId); List getReservationsByMember(UUID memberId, Boolean completed); + void confirmAttendance(UUID reservationId, UUID memberId); + void completeReservation(UUID reservationId, UUID memberId); + } diff --git a/src/main/java/com/classes/services/ClassService.java b/src/main/java/com/classes/services/ClassService.java index 4d6f370..b927a91 100644 --- a/src/main/java/com/classes/services/ClassService.java +++ b/src/main/java/com/classes/services/ClassService.java @@ -1,8 +1,11 @@ package com.classes.services; +import com.classes.dtos.Class.*; +import org.springframework.data.domain.Page; import com.classes.dtos.Class.ClassRequest; import com.classes.dtos.Class.ClassResponse; +import java.time.LocalDate; import java.util.List; import java.util.UUID; @@ -13,7 +16,18 @@ public interface ClassService { List findAll(); + Page findAllPaginated(int page, int size, String search); + ClassResponse updateClass(UUID id, ClassRequest request); void deleteClass(UUID id); + + + List getClassesWithStatsByTrainer(UUID trainerId); + + ClassDetailResponse getClassDetail(UUID classId); + + List getClassesForCalendar(LocalDate startDate, LocalDate endDate); + + List getUpcomingClasses(); } diff --git a/src/main/java/com/classes/services/CloudinaryService.java b/src/main/java/com/classes/services/CloudinaryService.java index 5bc238a..26c97a9 100644 --- a/src/main/java/com/classes/services/CloudinaryService.java +++ b/src/main/java/com/classes/services/CloudinaryService.java @@ -1,12 +1,18 @@ package com.classes.services; -import com.classes.dtos.Trainer.FileResponseDTO; +import com.classes.dtos.Trainer.ImageResponseDTO; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; +import java.util.UUID; public interface CloudinaryService { - FileResponseDTO upload(MultipartFile multipartFile) throws IOException; - void delete(String id) throws IOException; + ImageResponseDTO uploadProfileImage(MultipartFile file, UUID trainerId); + + boolean deleteImage(String publicId); + + ImageResponseDTO updateProfileImage(MultipartFile file, UUID trainerId, String oldPublicId) ; + + String extractPublicIdFromUrl(String imageUrl); } + diff --git a/src/main/java/com/classes/services/Impl/AzureServiceImpl.java b/src/main/java/com/classes/services/Impl/AzureServiceImpl.java index 7cf4514..23e4765 100644 --- a/src/main/java/com/classes/services/Impl/AzureServiceImpl.java +++ b/src/main/java/com/classes/services/Impl/AzureServiceImpl.java @@ -6,7 +6,9 @@ import com.azure.storage.blob.BlobServiceClientBuilder; import com.azure.storage.common.StorageSharedKeyCredential; import com.classes.dtos.Trainer.FileResponseDTO; +import com.classes.dtos.Trainer.ImageResponseDTO; import com.classes.services.AzureService; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -15,23 +17,10 @@ import java.util.UUID; @Service +@RequiredArgsConstructor public class AzureServiceImpl implements AzureService { private final BlobContainerClient containerClient; - public AzureServiceImpl( - @Value("${azure.storage.account-name}") String accountName, - @Value("${azure.storage.account-key}") String accountKey, - @Value("${azure.storage.container-name}") String containerName) { - - String endpoint = String.format("https://%s.blob.core.windows.net", accountName); - BlobServiceClient serviceClient = new BlobServiceClientBuilder() - .endpoint(endpoint) - .credential(new StorageSharedKeyCredential(accountName, accountKey)) - .buildClient(); - - this.containerClient = serviceClient.getBlobContainerClient(containerName); - if (!containerClient.exists()) containerClient.create(); - } @Override public FileResponseDTO upload(MultipartFile multipartFile) throws IOException { String blobName = UUID.randomUUID() + "_" + multipartFile.getOriginalFilename(); diff --git a/src/main/java/com/classes/services/Impl/ClassReservationServiceImpl.java b/src/main/java/com/classes/services/Impl/ClassReservationServiceImpl.java index e27bd6f..24f9353 100644 --- a/src/main/java/com/classes/services/Impl/ClassReservationServiceImpl.java +++ b/src/main/java/com/classes/services/Impl/ClassReservationServiceImpl.java @@ -1,4 +1,5 @@ package com.classes.services.Impl; + import com.classes.dtos.reservations.ClassReservationRequest; import com.classes.dtos.reservations.ClassReservationResponse; import com.classes.entities.ClassEntity; @@ -12,6 +13,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.Comparator; import java.util.List; import java.util.Optional; @@ -26,32 +28,32 @@ public class ClassReservationServiceImpl implements ClassReservationService { private final ClassRepository classRepository; private final ClassReservationMapper mapper; + @Override public ClassReservationResponse reserveClass(ClassReservationRequest request, UUID memberId) { - // 1️⃣ Obtener la clase ClassEntity classEntity = classRepository.findById(request.getClassId()) .orElseThrow(() -> new RuntimeException("Clase no encontrada")); - // 2️⃣ Verificar si el usuario ya tiene reserva activa Optional existing = reservationRepository .findByClassEntityIdAndMemberId(classEntity.getId(), memberId); + if (existing.isPresent() && existing.get().getStatus() != ReservationStatus.CANCELADO) { - throw new RuntimeException("Ya tienes una reserva para esta clase"); + throw new RuntimeException("Ya tienes una reserva activa para esta clase"); } - // 3️⃣ Calcular estado de la reserva (RESERVADO o LISTA_ESPERA) long reservedCount = classEntity.getReservations().stream() .filter(r -> r.getStatus() == ReservationStatus.RESERVADO) .count(); + ReservationStatus status = reservedCount < classEntity.getMaxCapacity() ? ReservationStatus.RESERVADO : ReservationStatus.LISTA_ESPERA; - // 4️⃣ Crear la reserva ClassReservation reservation = mapper.toEntity(request); reservation.setClassEntity(classEntity); reservation.setMemberId(memberId); reservation.setStatus(status); + reservation.setReservedAt(LocalDateTime.now()); reservationRepository.save(reservation); @@ -70,7 +72,7 @@ public void cancelReservation(UUID reservationId, UUID memberId) { reservation.setStatus(ReservationStatus.CANCELADO); reservationRepository.save(reservation); - // 5️⃣ Lista de espera automática: pasar al primer miembro en espera + // Promover primer usuario en lista de espera List waitingList = reservationRepository.findByClassEntityId(reservation.getClassEntity().getId()) .stream() .filter(r -> r.getStatus() == ReservationStatus.LISTA_ESPERA) @@ -84,14 +86,66 @@ public void cancelReservation(UUID reservationId, UUID memberId) { } } + + @Override + public void confirmAttendance(UUID reservationId, UUID memberId) { + ClassReservation reservation = reservationRepository.findById(reservationId) + .orElseThrow(() -> new RuntimeException("Reserva no encontrada")); + + if (!reservation.getMemberId().equals(memberId)) { + throw new RuntimeException("No puedes confirmar la asistencia de otro usuario"); + } + + if (reservation.getStatus() != ReservationStatus.RESERVADO) { + throw new RuntimeException("Solo puedes confirmar reservas activas"); + } + + reservation.setStatus(ReservationStatus.PENDIENTE); + reservationRepository.save(reservation); + } + + @Override + public void completeReservation(UUID reservationId, UUID memberId) { + ClassReservation reservation = reservationRepository.findById(reservationId) + .orElseThrow(() -> new RuntimeException("Reserva no encontrada")); + + if (!reservation.getMemberId().equals(memberId)) { + throw new RuntimeException("No puedes completar la reserva de otro usuario"); + } + + LocalDateTime classEnd = LocalDateTime.of( + reservation.getClassEntity().getClassDate(), + reservation.getClassEntity().getEndTime() + ); + + if (classEnd.isAfter(LocalDateTime.now())) { + throw new RuntimeException("La clase aún no ha terminado, no se puede completar"); + } + + reservation.setStatus(ReservationStatus.COMPLETADO); + reservationRepository.save(reservation); + } + + @Override public List getReservationsByMember(UUID memberId, Boolean completed) { List reservations = reservationRepository.findByMemberId(memberId); return reservations.stream() - .filter(r -> completed == null - || r.getClassEntity().getStartTime().isBefore(java.time.LocalTime.now()) == completed) - .sorted(Comparator.comparing(r -> r.getClassEntity().getStartTime())) + .filter(r -> { + if (completed == null) return true; + + // Combinar fecha y hora en LocalDateTime + LocalDateTime classEnd = LocalDateTime.of( + r.getClassEntity().getClassDate(), + r.getClassEntity().getEndTime() + ); + + return (classEnd.isBefore(LocalDateTime.now()) == completed); + }) + .sorted(Comparator.comparing(r -> + LocalDateTime.of(r.getClassEntity().getClassDate(), r.getClassEntity().getStartTime()) + )) .map(mapper::toResponse) .toList(); } diff --git a/src/main/java/com/classes/services/Impl/ClassServiceImpl.java b/src/main/java/com/classes/services/Impl/ClassServiceImpl.java index 5274d7a..b71ede3 100644 --- a/src/main/java/com/classes/services/Impl/ClassServiceImpl.java +++ b/src/main/java/com/classes/services/Impl/ClassServiceImpl.java @@ -1,31 +1,46 @@ package com.classes.services.Impl; -import com.classes.dtos.Class.ClassRequest; -import com.classes.dtos.Class.ClassResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import com.classes.dtos.Class.*; +import com.classes.dtos.external.MemberInfoDTO; import com.classes.entities.ClassEntity; +import com.classes.entities.ClassReservation; import com.classes.entities.LocationEntity; import com.classes.entities.TrainerEntity; +import com.classes.enums.ReservationStatus; import com.classes.mappers.ClassMapper; +import com.classes.mappers.ClassStatsMapper; import com.classes.repositories.ClassRepository; +import com.classes.repositories.ClassReservationRepository; import com.classes.repositories.LocationRepository; import com.classes.repositories.TrainerRepository; import com.classes.services.ClassService; +import com.classes.services.MemberClientService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor +@Slf4j public class ClassServiceImpl implements ClassService { private final ClassRepository repository; private final ClassMapper classMapper; + private final ClassStatsMapper classStatsMapper; private final TrainerRepository trainerRepository; private final LocationRepository locationRepository; + private final ClassReservationRepository reservationRepository; + private final MemberClientService memberClientService; @Transactional @@ -49,6 +64,20 @@ public List findAll() { return classMapper.toResponseList(repository.findAll()); } + @Transactional + @Override + public Page findAllPaginated(int page, int size, String search) { + Pageable pageable = PageRequest.of(page, size); + + if (search != null && !search.trim().isEmpty()) { + return repository.findByClassNameContainingIgnoreCaseOrTrainerFirstNameContainingIgnoreCaseOrTrainerLastNameContainingIgnoreCase( + search, search, search, pageable + ).map(classMapper::toResponse); + } else { + return repository.findAll(pageable).map(classMapper::toResponse); + } + } + @Transactional @Override public ClassResponse updateClass(UUID id, ClassRequest request) { @@ -91,4 +120,153 @@ private void validateTrainerAndLocation(ClassRequest request) { throw new IllegalArgumentException("La ubicación con ID " + request.getLocationId() + " no existe"); } } + + //<---ESTADISTICAS----> + + @Override + @Transactional(readOnly = true) + public List getClassesWithStatsByTrainer(UUID trainerId) { + log.info("📊 Obteniendo clases con estadísticas para el trainer {}", trainerId); + List classes = repository.findByTrainerId(trainerId); + return classes.stream().map(classEntity -> { + ClassWithStatsResponse response = classStatsMapper.toClassWithStatsResponse(classEntity); + long currentStudents = reservationRepository.countByClassEntityIdAndStatus( + classEntity.getId(), ReservationStatus.RESERVADO); + response.setCurrentStudents((int) currentStudents); + Double avgAttendance = reservationRepository.calculateAverageAttendanceByClassId(classEntity.getId()); + response.setAverageAttendance(avgAttendance != null ? avgAttendance : 0.0); + response.setStatus(determineClassStatus(classEntity, (int) currentStudents)); + + return response; + }).collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public ClassDetailResponse getClassDetail(UUID classId) { + log.info("🔍 Obteniendo detalle de la clase {}", classId); + + ClassEntity classEntity = repository.findById(classId) + .orElseThrow(() -> new IllegalArgumentException("La clase con ID " + classId + " no existe")); + ClassDetailResponse response = classStatsMapper.toClassDetailResponse(classEntity); + List reservations = reservationRepository.findByClassEntityId(classId); + List memberIds = reservations.stream() + .map(ClassReservation::getMemberId) + .distinct() + .collect(Collectors.toList()); + List membersInfo = memberClientService.getMembersInfo(memberIds); + List students = reservations.stream() + .map(reservation -> buildStudentDTO(reservation, membersInfo)) + .collect(Collectors.toList()); + + response.setCurrentStudents(students.size()); + response.setStudents(students); + + return response; + } + + private StudentInClassDTO buildStudentDTO(ClassReservation reservation, List membersInfo) { + MemberInfoDTO memberInfo = membersInfo.stream() + .filter(m -> m.getId().equals(reservation.getMemberId())) + .findFirst() + .orElse(createDefaultMemberInfo(reservation.getMemberId())); + + List memberReservations = + reservationRepository.findByMemberId(reservation.getMemberId()); + + long totalClasses = memberReservations.size(); + + // ✅ Cambiado: uso de Boolean.TRUE.equals() para evitar errores por null + long attendedClasses = memberReservations.stream() + .filter(r -> Boolean.TRUE.equals(r.getAttended())) + .count(); + + double attendancePercentage = totalClasses > 0 + ? (attendedClasses * 100.0 / totalClasses) : 0.0; + + String initials = getInitials(memberInfo.getFirstName(), memberInfo.getLastName()); + + return StudentInClassDTO.builder() + .memberId(reservation.getMemberId()) + .name(memberInfo.getFirstName() + " " + memberInfo.getLastName()) + .email(memberInfo.getEmail()) + .avatarInitials(initials) + .status(memberInfo.getStatus()) + .membershipType(memberInfo.getMembershipType()) + .attendancePercentage(attendancePercentage) + .totalClasses((int) totalClasses) + .lastAccess(memberInfo.getLastAccess()) + .reservationId(reservation.getId()) + .build(); + } + + @Override + @Transactional(readOnly = true) + public List getClassesForCalendar(LocalDate startDate, LocalDate endDate) { + log.info("Obteniendo clases para calendario entre {} y {}", startDate, endDate); + + List classes = repository.findByClassDateBetween(startDate, endDate); + return mapToCalendarDTOs(classes); + } + + @Override + @Transactional(readOnly = true) + public List getUpcomingClasses() { + log.info("Obteniendo próximas clases"); + + List classes = repository.findUpcomingClasses(LocalDate.now()); + return mapToCalendarDTOs(classes); + } + private List mapToCalendarDTOs(List classes) { + return classes.stream().map(classEntity -> { + CalendarClassDTO dto = classStatsMapper.toCalendarDTO(classEntity); + long currentStudents = reservationRepository.countByClassEntityIdAndStatus( + classEntity.getId(), ReservationStatus.RESERVADO); + dto.setCurrentStudents((int) currentStudents); + String action = determineAction(classEntity, (int) currentStudents); + dto.setAction(action); + return dto; + }).collect(Collectors.toList()); + } + + private String determineClassStatus(ClassEntity classEntity, int currentStudents) { + if (!classEntity.isActive()) { + return "Cancelada"; + } + if (currentStudents >= classEntity.getMaxCapacity()) { + return "Llena"; + } + return "Activa"; + } + + private String determineAction(ClassEntity classEntity, int currentStudents) { + if (!classEntity.isActive()) { + return "Cancelada"; + } + if (currentStudents >= classEntity.getMaxCapacity()) { + return "Llena"; + } + if (currentStudents >= classEntity.getMaxCapacity() * 0.9) { + return "Lista de espera"; + } + return "Reservar"; + } + + + private MemberInfoDTO createDefaultMemberInfo(UUID memberId) { + return MemberInfoDTO.builder() + .id(memberId) + .firstName("Usuario") + .lastName("Desconocido") + .email("no-disponible@email.com") + .status("DESCONOCIDO") + .membershipType("N/A") + .build(); + } + + private String getInitials(String firstName, String lastName) { + String first = firstName != null && !firstName.isEmpty() ? firstName.substring(0, 1) : ""; + String last = lastName != null && !lastName.isEmpty() ? lastName.substring(0, 1) : ""; + return (first + last).toUpperCase(); + } } diff --git a/src/main/java/com/classes/services/Impl/CloudinaryServiceImpl.java b/src/main/java/com/classes/services/Impl/CloudinaryServiceImpl.java index c1f2c05..9cf5a99 100644 --- a/src/main/java/com/classes/services/Impl/CloudinaryServiceImpl.java +++ b/src/main/java/com/classes/services/Impl/CloudinaryServiceImpl.java @@ -1,61 +1,134 @@ package com.classes.services.Impl; -import com.classes.dtos.Trainer.FileResponseDTO; +import com.classes.dtos.Trainer.ImageResponseDTO; +import com.classes.exceptions.ImageUploadException; +import com.classes.exceptions.InvalidImageFormatException; import com.classes.services.CloudinaryService; import com.cloudinary.Cloudinary; +import com.cloudinary.Transformation; import com.cloudinary.utils.ObjectUtils; -import org.springframework.beans.factory.annotation.Value; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FilenameUtils; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; -import java.nio.file.Files; import java.util.Map; -import java.util.Objects; +import java.util.Set; +import java.util.UUID; @Service +@Slf4j +@RequiredArgsConstructor public class CloudinaryServiceImpl implements CloudinaryService { + private final Cloudinary cloudinary; - public CloudinaryServiceImpl( - @Value("${cloudinary.cloud_name}") String cloudName, - @Value("${cloudinary.api_key}") String apiKey, - @Value("${cloudinary.api_secret}") String apiSecret) { - this.cloudinary = new Cloudinary(ObjectUtils.asMap( - "cloud_name", cloudName, - "api_key", apiKey, - "api_secret", apiSecret - )); + private static final String TRAINERS_FOLDER = "fitdesk/classes/profile"; + private static final Set ALLOWED_EXTENSIONS = Set.of("jpg","jpeg","png","webp"); + private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + + @Override + public ImageResponseDTO uploadProfileImage(MultipartFile file, UUID trainerId) { + log.info("Subiendo imagen de perfil para trainer: {}", trainerId); + validateImage(file); + + try { + String publicId = generatePublicIdForTrainer(trainerId); + + Transformation transformation = new Transformation() + .width(400) + .height(400) + .crop("fill") + .gravity("face") + .quality("auto:good") + .fetchFormat("auto"); + + Map uploadParams = ObjectUtils.asMap( + "public_id", publicId, + "folder", TRAINERS_FOLDER, + "transformation", transformation, + "overwrite", true, + "resource_type", "image" + ); + + Map uploadResult = cloudinary.uploader().upload(file.getBytes(), uploadParams); + + return mapToResponseDto(uploadResult); + } catch (IOException e) { + log.error("Error al subir la imagen de trainer {}: {}", trainerId, e.getMessage()); + throw new ImageUploadException("Error al cargar la imagen", e); + } } @Override - public FileResponseDTO upload(MultipartFile multipartFile) throws IOException { - File file = convert(multipartFile); - Map result = cloudinary.uploader().upload(file, ObjectUtils.emptyMap()); - Files.deleteIfExists(file.toPath()); - - return new FileResponseDTO( - (String) result.get("original_filename"), - (String) result.get("url"), - (String) result.get("public_id") - ); + public boolean deleteImage(String publicId) { + if (publicId == null || publicId.isBlank()) return false; + + try { + Map result = cloudinary.uploader().destroy(publicId, ObjectUtils.emptyMap()); + return "ok".equals(result.get("result")); + } catch (IOException e) { + log.error("Error al eliminar la imagen {}: {}", publicId, e.getMessage()); + return false; + } } + @Override + public ImageResponseDTO updateProfileImage(MultipartFile file, UUID trainerId, String oldPublicId) { + log.info("Actualizando imagen de trainer {}", trainerId); + if (oldPublicId != null && !oldPublicId.isBlank()) { + deleteImage(oldPublicId); + } + return uploadProfileImage(file, trainerId); + } @Override - public void delete(String id) throws IOException { - cloudinary.uploader().destroy(id, ObjectUtils.emptyMap()); + public String extractPublicIdFromUrl(String imageUrl) { + if (imageUrl == null || imageUrl.isBlank()) return null; + try { + String[] parts = imageUrl.split("/upload/"); + if (parts.length > 1) { + String pathWithVersion = parts[1]; + String path = pathWithVersion.replaceFirst("v\\d+/", ""); + int lastDot = path.lastIndexOf('.'); + return lastDot > 0 ? path.substring(0,lastDot) : path; + } + } catch (Exception e) { + log.warn("No se pudo extraer publicId de: {}", imageUrl); + } + return null; + } + + private String generatePublicIdForTrainer(UUID trainerId) { + return String.format("%s_%s", trainerId, System.currentTimeMillis()); } - private File convert(MultipartFile multipartFile) throws IOException { - File file = new File(Objects.requireNonNull(multipartFile.getOriginalFilename())); - try (FileOutputStream fo = new FileOutputStream(file)) { - fo.write(multipartFile.getBytes()); + private void validateImage(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new InvalidImageFormatException("El archivo está vacío"); + } + if (file.getSize() > MAX_FILE_SIZE) { + throw new InvalidImageFormatException("Archivo excede 5MB"); + } + String extension = FilenameUtils.getExtension(file.getOriginalFilename()); + if (extension == null || !ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) { + throw new InvalidImageFormatException("Formato no permitido: "+String.join(", ", ALLOWED_EXTENSIONS)); } - return file; + } + + private ImageResponseDTO mapToResponseDto(Map uploadResult) { + return ImageResponseDTO.builder() + .fileUrl((String) uploadResult.get("secure_url")) + .fileId((String) uploadResult.get("public_id")) + .fileName((String) uploadResult.get("original_filename")) + .format((String) uploadResult.get("format")) + .size(((Number) uploadResult.get("bytes")).longValue()) + .width((Integer) uploadResult.get("width")) + .height((Integer) uploadResult.get("height")) + .build(); } } diff --git a/src/main/java/com/classes/services/Impl/DashboardServiceImpl.java b/src/main/java/com/classes/services/Impl/DashboardServiceImpl.java index 2c22608..dacbdaa 100644 --- a/src/main/java/com/classes/services/Impl/DashboardServiceImpl.java +++ b/src/main/java/com/classes/services/Impl/DashboardServiceImpl.java @@ -25,22 +25,20 @@ public class DashboardServiceImpl { public MemberDashboardDTO getDashboardForMember(UUID memberId) { List reservations = reservationRepository.findByMemberId(memberId); - // 🔹 Filtrar solo reservas activas (RESERVADO o atendidas) List activeReservations = reservations.stream() - .filter(r -> r.getStatus() == ReservationStatus.RESERVADO || r.isAttended()) + .filter(r -> r.getStatus() == ReservationStatus.RESERVADO || Boolean.TRUE.equals(r.getAttended())) .toList(); LocalDateTime now = LocalDateTime.now(); - // 🔹 En clase actualmente (entre hora inicio y fin) boolean inClass = activeReservations.stream() .anyMatch(r -> { LocalDateTime start = LocalDateTime.of(LocalDate.now(), r.getClassEntity().getStartTime()); LocalDateTime end = LocalDateTime.of(LocalDate.now(), r.getClassEntity().getEndTime()); - return !now.isBefore(start) && !now.isAfter(end) && r.getStatus() == ReservationStatus.RESERVADO; + return !now.isBefore(start) && !now.isAfter(end) + && r.getStatus() == ReservationStatus.RESERVADO; }); - // 🔹 Próxima clase (según hora y fecha más cercana futura) Optional nextClassOpt = activeReservations.stream() .filter(r -> { LocalDateTime start = LocalDateTime.of(LocalDate.now(), r.getClassEntity().getStartTime()); @@ -49,31 +47,25 @@ public MemberDashboardDTO getDashboardForMember(UUID memberId) { .sorted(Comparator.comparing(r -> r.getClassEntity().getStartTime())) .findFirst(); - // 🔹 Clases restantes = reservadas - asistidas int totalReserved = (int) activeReservations.stream() .filter(r -> r.getStatus() == ReservationStatus.RESERVADO) .count(); int attended = (int) activeReservations.stream() - .filter(ClassReservation::isAttended) + .filter(r -> Boolean.TRUE.equals(r.getAttended())) .count(); int remaining = Math.max(totalReserved - attended, 0); - - // 🔹 Días consecutivos de asistencia int consecutiveDays = calcularDiasConsecutivos(activeReservations); - - // 🔹 Actividad semanal List weeklyActivity = calcularActividadSemanal(activeReservations); - // 🔹 Próximas clases (2 o 3 siguientes después de la próxima) List upcoming = activeReservations.stream() .filter(r -> { LocalDateTime start = LocalDateTime.of(LocalDate.now(), r.getClassEntity().getStartTime()); return start.isAfter(now); }) .sorted(Comparator.comparing(r -> r.getClassEntity().getStartTime())) - .skip(1) // omitir la primera (ya es la "nextClass") + .skip(1) .limit(3) .map(r -> UpcomingClassDTO.builder() .className(r.getClassEntity().getClassName()) @@ -84,7 +76,6 @@ public MemberDashboardDTO getDashboardForMember(UUID memberId) { .build()) .toList(); - // 🔹 Construcción del DTO final return MemberDashboardDTO.builder() .inClass(inClass) .remainingClasses(remaining) @@ -96,9 +87,10 @@ public MemberDashboardDTO getDashboardForMember(UUID memberId) { .build(); } + private int calcularDiasConsecutivos(List reservations) { var dates = reservations.stream() - .filter(ClassReservation::isAttended) + .filter(r -> Boolean.TRUE.equals(r.getAttended())) .map(r -> r.getReservedAt().toLocalDate()) .distinct() .sorted(Comparator.reverseOrder()) @@ -117,7 +109,7 @@ private int calcularDiasConsecutivos(List reservations) { private List calcularActividadSemanal(List reservations) { Map map = reservations.stream() - .filter(ClassReservation::isAttended) + .filter(r -> Boolean.TRUE.equals(r.getAttended())) .collect(Collectors.groupingBy(r -> r.getReservedAt().getDayOfWeek(), Collectors.counting())); return Arrays.stream(DayOfWeek.values()) diff --git a/src/main/java/com/classes/services/Impl/MemberClientServiceImpl.java b/src/main/java/com/classes/services/Impl/MemberClientServiceImpl.java new file mode 100644 index 0000000..22cd230 --- /dev/null +++ b/src/main/java/com/classes/services/Impl/MemberClientServiceImpl.java @@ -0,0 +1,60 @@ +package com.classes.services.Impl; + +import com.classes.dtos.external.MemberInfoDTO; +import com.classes.services.MemberClientService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MemberClientServiceImpl implements MemberClientService { + + private final RestTemplate restTemplate; + private static final String MEMBERS_SERVICE_URL = "http://msvc-members"; + + @Override + public MemberInfoDTO getMemberInfo(UUID memberId) { + try { + String url = MEMBERS_SERVICE_URL + "/members/" + memberId; + log.debug("🔗 Llamando al microservicio de members: {}", url); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + MemberInfoDTO.class + ); + return response.getBody(); + } catch (HttpClientErrorException.NotFound e) { + log.warn("❌ Miembro {} no encontrado en el microservicio de members", memberId); + return null; + } catch (Exception e) { + log.error("❌ Error al consultar el microservicio de members para el miembro {}: {}", + memberId, e.getMessage()); + return null; + } + } + + @Override + public List getMembersInfo(List memberIds) { + List members = new ArrayList<>(); + for (UUID memberId : memberIds) { + MemberInfoDTO member = getMemberInfo(memberId); + if (member != null) { + members.add(member); + } + } + return members; + } +} diff --git a/src/main/java/com/classes/services/Impl/TrainerDashboardServiceImpl.java b/src/main/java/com/classes/services/Impl/TrainerDashboardServiceImpl.java new file mode 100644 index 0000000..8667a71 --- /dev/null +++ b/src/main/java/com/classes/services/Impl/TrainerDashboardServiceImpl.java @@ -0,0 +1,116 @@ +package com.classes.services.Impl; + +import com.classes.dtos.Dashboard.StudentTrendDTO; +import com.classes.dtos.Dashboard.TrainerDashboardDTO; +import com.classes.entities.ClassEntity; +import com.classes.entities.ClassReservation; +import com.classes.repositories.ClassRepository; +import com.classes.repositories.ClassReservationRepository; +import com.classes.services.TrainerDashboardService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.temporal.WeekFields; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TrainerDashboardServiceImpl implements TrainerDashboardService { + + private final ClassRepository classRepository; + private final ClassReservationRepository reservationRepository; + + @Override + @Transactional(readOnly = true) + public TrainerDashboardDTO getDashboardForTrainer(UUID trainerId) { + log.info("📊 Generando dashboard para el trainer {}", trainerId); + List trainerClasses = classRepository.findByTrainerId(trainerId); + Set uniqueStudents = new HashSet<>(); + for (ClassEntity classEntity : trainerClasses) { + List reservations = reservationRepository.findByClassEntityId(classEntity.getId()); + uniqueStudents.addAll(reservations.stream() + .map(ClassReservation::getMemberId) + .collect(Collectors.toSet())); + } + int totalStudents = uniqueStudents.size(); + Double avgAttendance = reservationRepository.calculateAverageAttendanceByTrainerId(trainerId); + double averageAttendance = avgAttendance != null ? avgAttendance : 0.0; + LocalDate now = LocalDate.now(); + long classesThisMonth = classRepository.countByTrainerIdAndMonth( + trainerId, now.getYear(), now.getMonthValue()); + LocalDate lastMonth = now.minusMonths(1); + long classesLastMonth = classRepository.countByTrainerIdAndMonth( + trainerId, lastMonth.getYear(), lastMonth.getMonthValue()); + double attendanceChange = 0.0; + if (classesLastMonth > 0) { + attendanceChange = ((double) classesThisMonth - classesLastMonth) / classesLastMonth * 100; + } else if (classesThisMonth > 0) { + attendanceChange = 100.0; + } + + List studentTrends = calculateStudentTrends(trainerClasses); + + return TrainerDashboardDTO.builder() + .totalStudents(totalStudents) + .averageAttendance(averageAttendance) + .classesThisMonth((int) classesThisMonth) + .attendanceChange(attendanceChange) + .studentTrends(studentTrends) + .build(); + } + + private List calculateStudentTrends(List classes) { + Map> activeByWeek = new HashMap<>(); + Map> inactiveByWeek = new HashMap<>(); + LocalDate now = LocalDate.now(); + WeekFields weekFields = WeekFields.of(Locale.getDefault()); + for (int i = 3; i >= 0; i--) { + LocalDate weekDate = now.minusWeeks(i); + int weekNumber = weekDate.get(weekFields.weekOfWeekBasedYear()); + activeByWeek.put(weekNumber, new HashSet<>()); + inactiveByWeek.put(weekNumber, new HashSet<>()); + } + + for (ClassEntity classEntity : classes) { + LocalDate classDate = classEntity.getClassDate(); + if (classDate.isAfter(now.minusWeeks(4)) && !classDate.isAfter(now)) { + int weekNumber = classDate.get(weekFields.weekOfWeekBasedYear()); + if (activeByWeek.containsKey(weekNumber)) { + List reservations = reservationRepository.findByClassEntityId(classEntity.getId()); + for (ClassReservation reservation : reservations) { + if (Boolean.TRUE.equals(reservation.getAttended())) { + activeByWeek.get(weekNumber).add(reservation.getMemberId()); + } else { + inactiveByWeek.get(weekNumber).add(reservation.getMemberId()); + } + } + } + } + } + List trends = new ArrayList<>(); + List sortedWeeks = new ArrayList<>(activeByWeek.keySet()); + Collections.sort(sortedWeeks); + + int weekIndex = 1; + for (Integer weekNumber : sortedWeeks) { + int active = activeByWeek.get(weekNumber).size(); + int inactive = inactiveByWeek.get(weekNumber).size(); + + trends.add(StudentTrendDTO.builder() + .week("Sem " + weekIndex) + .activeStudents(active) + .inactiveStudents(inactive) + .label("Semana " + weekIndex + ": " + active + " activos, " + inactive + " inactivos") + .build()); + + weekIndex++; + } + + return trends; + } +} diff --git a/src/main/java/com/classes/services/Impl/TrainerServiceImpl.java b/src/main/java/com/classes/services/Impl/TrainerServiceImpl.java index 973edba..409b63f 100644 --- a/src/main/java/com/classes/services/Impl/TrainerServiceImpl.java +++ b/src/main/java/com/classes/services/Impl/TrainerServiceImpl.java @@ -1,8 +1,11 @@ package com.classes.services.Impl; import com.classes.dtos.Trainer.FileResponseDTO; -import com.classes.dtos.Trainer.TrainerDTO; +import com.classes.dtos.Trainer.ImageResponseDTO; +import com.classes.dtos.Trainer.TrainerRequestDTO; +import com.classes.dtos.Trainer.TrainerResponseDTO; import com.classes.entities.TrainerEntity; +import com.classes.enums.TrainerStatus; import com.classes.mappers.TrainerMapper; import com.classes.repositories.ClassRepository; import com.classes.repositories.TrainerRepository; @@ -10,6 +13,10 @@ import com.classes.services.CloudinaryService; import com.classes.services.TrainerService; import jakarta.persistence.EntityNotFoundException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -32,14 +39,14 @@ public class TrainerServiceImpl implements TrainerService { @Transactional @Override - public TrainerDTO createTrainer(TrainerDTO trainerDTO, - MultipartFile profileImage, - List certifications) throws IOException { - + public TrainerResponseDTO createTrainer(TrainerRequestDTO trainerDTO, + MultipartFile profileImage, + List certifications) throws IOException { TrainerEntity trainer = trainerMapper.toEntity(trainerDTO); + TrainerEntity savedTrainer = trainerRepository.save(trainer); if (profileImage != null && !profileImage.isEmpty()) { - FileResponseDTO profileFile = cloudinaryService.upload(profileImage); - trainer.setProfileImageUrl(profileFile.getFileUrl()); + ImageResponseDTO profileFile = cloudinaryService.uploadProfileImage(profileImage, savedTrainer.getId()); + savedTrainer.setProfileImageUrl(profileFile.getFileUrl()); } if (certifications != null && !certifications.isEmpty()) { List certUrls = new ArrayList<>(); @@ -47,40 +54,63 @@ public TrainerDTO createTrainer(TrainerDTO trainerDTO, FileResponseDTO fileResponse = azureStorageService.upload(cert); certUrls.add(fileResponse.getFileUrl()); } - trainer.setCertifications(certUrls); + savedTrainer.setCertifications(certUrls); } - TrainerEntity savedTrainer = trainerRepository.save(trainer); - return trainerMapper.toDTO(savedTrainer); + + trainerRepository.save(savedTrainer); + return trainerMapper.toResponseDTO(savedTrainer); } @Transactional(readOnly = true) @Override - public TrainerDTO getTrainerById(UUID id) { + public TrainerResponseDTO getTrainerById(UUID id) { TrainerEntity trainer = trainerRepository.findById(id) .orElseThrow(() -> new EntityNotFoundException("Trainer not found with id: " + id)); - return trainerMapper.toDTO(trainer); + return trainerMapper.toResponseDTO(trainer); } @Transactional(readOnly = true) @Override - public List getAllTrainers() { - return trainerMapper.toDTOList(trainerRepository.findAll()); + public Page getAllTrainers(int page, int size, String search, String status) { + Pageable pageable = PageRequest.of(page, size, Sort.by("firstName").ascending()); + + if (search != null && !search.trim().isEmpty()) { + + return trainerRepository.findByFirstNameContainingIgnoreCaseOrLastNameContainingIgnoreCase(search, search, pageable) + .map(trainerMapper::toResponseDTO); + } else if (status != null && !status.trim().isEmpty()) { + + TrainerStatus trainerStatus = TrainerStatus.valueOf(status.toUpperCase()); + return trainerRepository.findByStatus(trainerStatus, pageable) + .map(trainerMapper::toResponseDTO); + } else { + + return trainerRepository.findAll(pageable) + .map(trainerMapper::toResponseDTO); + } } @Transactional @Override - public TrainerDTO updateTrainer(UUID id, - TrainerDTO trainerDTO, - MultipartFile profileImage, - List certifications) throws IOException { + public TrainerResponseDTO updateTrainer(UUID id, + TrainerRequestDTO trainerDTO, + MultipartFile profileImage, + List certifications) throws IOException { TrainerEntity trainer = trainerRepository.findById(id) .orElseThrow(() -> new EntityNotFoundException("Trainer not found with id: " + id)); + trainerMapper.updateTrainerFromDTO(trainerDTO, trainer); + if (profileImage != null && !profileImage.isEmpty()) { - FileResponseDTO profileFile = cloudinaryService.upload(profileImage); + String oldPublicId = trainer.getProfileImageUrl() != null + ? cloudinaryService.extractPublicIdFromUrl(trainer.getProfileImageUrl()) + : null; + + ImageResponseDTO profileFile = cloudinaryService.updateProfileImage(profileImage, trainer.getId(), oldPublicId); trainer.setProfileImageUrl(profileFile.getFileUrl()); } + if (certifications != null && !certifications.isEmpty()) { List urls = new ArrayList<>(); for (MultipartFile cert : certifications) { @@ -91,8 +121,9 @@ public TrainerDTO updateTrainer(UUID id, } TrainerEntity updated = trainerRepository.save(trainer); - return trainerMapper.toDTO(updated); + return trainerMapper.toResponseDTO(updated); } + @Transactional @Override public void deleteTrainer(UUID id) throws IOException { @@ -101,19 +132,57 @@ public void deleteTrainer(UUID id) throws IOException { boolean hasClasses = classRepository.findFirstByTrainerId(id).isPresent(); if (hasClasses) { - throw new IllegalArgumentException( - "No se puede eliminar el trainer porque tiene clases asignadas" - ); + throw new IllegalArgumentException("No se puede eliminar el trainer porque tiene clases asignadas"); } + if (trainer.getProfileImageUrl() != null) { - cloudinaryService.delete(trainer.getProfileImageUrl()); + String publicId = cloudinaryService.extractPublicIdFromUrl(trainer.getProfileImageUrl()); + cloudinaryService.deleteImage(publicId); } + if (trainer.getCertifications() != null) { for (String url : trainer.getCertifications()) { String blobName = url.substring(url.lastIndexOf("/") + 1); azureStorageService.delete(blobName); } } + trainerRepository.delete(trainer); } + + @Transactional + @Override + public ImageResponseDTO updateTrainerProfile(UUID trainerId, MultipartFile file) { + TrainerEntity trainer = trainerRepository.findById(trainerId) + .orElseThrow(() -> new EntityNotFoundException("Trainer not found with id: " + trainerId)); + + String oldPublicId = cloudinaryService.extractPublicIdFromUrl(trainer.getProfileImageUrl()); + ImageResponseDTO uploadResponse = cloudinaryService.updateProfileImage(file, trainerId, oldPublicId); + + trainer.setProfileImageUrl(uploadResponse.getFileUrl()); + trainerRepository.save(trainer); + + return uploadResponse; + } + + @Transactional + @Override + public boolean deleteTrainerProfileImage(UUID trainerId) { + TrainerEntity trainer = trainerRepository.findById(trainerId) + .orElseThrow(() -> new EntityNotFoundException("Trainer not found with id: " + trainerId)); + + String currentImageUrl = trainer.getProfileImageUrl(); + if (currentImageUrl == null) return false; + + String publicId = cloudinaryService.extractPublicIdFromUrl(currentImageUrl); + boolean deleted = false; + if (publicId != null) { + deleted = cloudinaryService.deleteImage(publicId); + } + + trainer.setProfileImageUrl(null); + trainerRepository.save(trainer); + + return deleted; + } } \ No newline at end of file diff --git a/src/main/java/com/classes/services/MemberClientService.java b/src/main/java/com/classes/services/MemberClientService.java new file mode 100644 index 0000000..7af17bc --- /dev/null +++ b/src/main/java/com/classes/services/MemberClientService.java @@ -0,0 +1,13 @@ +package com.classes.services; + +import com.classes.dtos.external.MemberInfoDTO; + +import java.util.List; +import java.util.UUID; + +public interface MemberClientService { + + MemberInfoDTO getMemberInfo(UUID memberId); + List getMembersInfo(List memberIds); + +} diff --git a/src/main/java/com/classes/services/TrainerDashboardService.java b/src/main/java/com/classes/services/TrainerDashboardService.java new file mode 100644 index 0000000..eaa6f5f --- /dev/null +++ b/src/main/java/com/classes/services/TrainerDashboardService.java @@ -0,0 +1,10 @@ +package com.classes.services; + +import com.classes.dtos.Dashboard.TrainerDashboardDTO; + +import java.util.UUID; + +public interface TrainerDashboardService { + + TrainerDashboardDTO getDashboardForTrainer(UUID trainerId); +} diff --git a/src/main/java/com/classes/services/TrainerService.java b/src/main/java/com/classes/services/TrainerService.java index f2b6a44..8c328d7 100644 --- a/src/main/java/com/classes/services/TrainerService.java +++ b/src/main/java/com/classes/services/TrainerService.java @@ -1,6 +1,9 @@ package com.classes.services; -import com.classes.dtos.Trainer.TrainerDTO; +import com.classes.dtos.Trainer.ImageResponseDTO; +import com.classes.dtos.Trainer.TrainerRequestDTO; +import com.classes.dtos.Trainer.TrainerResponseDTO; +import org.springframework.data.domain.Page; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; @@ -8,23 +11,30 @@ import java.util.UUID; public interface TrainerService { - TrainerDTO createTrainer(TrainerDTO trainerDTO, - MultipartFile profileImage, - List certifications) throws IOException; + TrainerResponseDTO createTrainer(TrainerRequestDTO trainerDTO, + MultipartFile profileImage, + List certifications) throws IOException; - TrainerDTO getTrainerById(UUID id); - List getAllTrainers(); + TrainerResponseDTO getTrainerById(UUID id); - TrainerDTO updateTrainer(UUID id, - TrainerDTO trainerDTO, - MultipartFile profileImage, - List certifications) throws IOException; - void deleteTrainer(UUID id) throws IOException; + Page getAllTrainers(int page, int size, String search, String status); + + TrainerResponseDTO updateTrainer(UUID id, + TrainerRequestDTO trainerDTO, + MultipartFile profileImage, + List certifications) throws IOException; + void deleteTrainer(UUID id) throws IOException; + + ImageResponseDTO updateTrainerProfile(UUID trainerId, MultipartFile file) throws IOException; + boolean deleteTrainerProfileImage(UUID trainerId) ; } + + +