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) ;
}
+
+
+