diff --git a/src/main/java/com/classes/controllers/ClassReservationController.java b/src/main/java/com/classes/controllers/ClassReservationController.java new file mode 100644 index 0000000..11f6d70 --- /dev/null +++ b/src/main/java/com/classes/controllers/ClassReservationController.java @@ -0,0 +1,75 @@ +package com.classes.controllers; + +import com.classes.dtos.reservations.ClassReservationRequest; +import com.classes.dtos.reservations.ClassReservationResponse; +import com.classes.services.AuthorizationService; +import com.classes.services.ClassReservationService; +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.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/reservations") +@RequiredArgsConstructor +@Slf4j +public class ClassReservationController { + + private final ClassReservationService reservationService; + private final AuthorizationService authorizationService; // Para obtener userId desde el token/cookie + + // ✅ 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 = "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); + + 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/DashboardController.java b/src/main/java/com/classes/controllers/DashboardController.java new file mode 100644 index 0000000..c2f6c34 --- /dev/null +++ b/src/main/java/com/classes/controllers/DashboardController.java @@ -0,0 +1,36 @@ +package com.classes.controllers; + + +import com.classes.dtos.Dashboard.MemberDashboardDTO; +import com.classes.services.AuthorizationService; +import com.classes.services.Impl.DashboardServiceImpl; +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.*; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/dashboard") +@RequiredArgsConstructor +@Slf4j +public class DashboardController { + + private final DashboardServiceImpl dashboardService; + private final AuthorizationService authorizationService; + + @Operation(summary = "Obtener dashboard del miembro logueado") + @PreAuthorize("hasRole('USER') or hasRole('ADMIN')") + @GetMapping("/member") + public ResponseEntity getMemberDashboard(Authentication authentication) { + UUID memberId = authorizationService.getUserId(authentication); + log.info("📊 Usuario {} consultando su dashboard", memberId); + + MemberDashboardDTO dashboard = dashboardService.getDashboardForMember(memberId); + return ResponseEntity.ok(dashboard); + } +} diff --git a/src/main/java/com/classes/dtos/Dashboard/MemberDashboardDTO.java b/src/main/java/com/classes/dtos/Dashboard/MemberDashboardDTO.java new file mode 100644 index 0000000..1d3ca3f --- /dev/null +++ b/src/main/java/com/classes/dtos/Dashboard/MemberDashboardDTO.java @@ -0,0 +1,18 @@ +package com.classes.dtos.Dashboard; + +import lombok.Builder; +import lombok.Data; +import java.util.List; + +@Data +@Builder +public class MemberDashboardDTO { + + private boolean inClass; // true si está actualmente en una clase activa + private int remainingClasses; // cuántas clases le quedan del plan + private String nextClassName; // nombre de la próxima clase + private String nextClassTime; // hora de la próxima clase + private int consecutiveDays; // días consecutivos asistidos + private List weeklyActivity; // gráfico semanal + private List upcomingClasses; // próximas clases +} \ No newline at end of file diff --git a/src/main/java/com/classes/dtos/Dashboard/UpcomingClassDTO.java b/src/main/java/com/classes/dtos/Dashboard/UpcomingClassDTO.java new file mode 100644 index 0000000..a212f09 --- /dev/null +++ b/src/main/java/com/classes/dtos/Dashboard/UpcomingClassDTO.java @@ -0,0 +1,14 @@ +package com.classes.dtos.Dashboard; + + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class UpcomingClassDTO { + private String className; + private String trainerName; + private String schedule; + private String location; +} \ No newline at end of file diff --git a/src/main/java/com/classes/dtos/Dashboard/WeeklyActivityDTO.java b/src/main/java/com/classes/dtos/Dashboard/WeeklyActivityDTO.java new file mode 100644 index 0000000..75c569b --- /dev/null +++ b/src/main/java/com/classes/dtos/Dashboard/WeeklyActivityDTO.java @@ -0,0 +1,11 @@ +package com.classes.dtos.Dashboard; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class WeeklyActivityDTO { + private String day; // Lunes, Martes, etc. + private int sessions; // cantidad de clases asistidas ese día +} \ No newline at end of file diff --git a/src/main/java/com/classes/dtos/reservations/ClassReservationRequest.java b/src/main/java/com/classes/dtos/reservations/ClassReservationRequest.java new file mode 100644 index 0000000..365cd1e --- /dev/null +++ b/src/main/java/com/classes/dtos/reservations/ClassReservationRequest.java @@ -0,0 +1,11 @@ +package com.classes.dtos.reservations; + +import java.util.UUID; +import lombok.*; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ClassReservationRequest { + private UUID classId; +} \ No newline at end of file diff --git a/src/main/java/com/classes/dtos/reservations/ClassReservationResponse.java b/src/main/java/com/classes/dtos/reservations/ClassReservationResponse.java new file mode 100644 index 0000000..836df10 --- /dev/null +++ b/src/main/java/com/classes/dtos/reservations/ClassReservationResponse.java @@ -0,0 +1,25 @@ +package com.classes.dtos.reservations; + +import java.util.UUID; + +import lombok.*; +import lombok.Data; + + + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ClassReservationResponse { + private UUID reservationId; + private UUID classId; + private String className; + private String trainerName; + private String schedule; + private String locationName; + private String capacity; + private String action; + private boolean alreadyReserved; + private boolean completed; +} diff --git a/src/main/java/com/classes/entities/ClassEntity.java b/src/main/java/com/classes/entities/ClassEntity.java index 962b09e..38dc6f2 100644 --- a/src/main/java/com/classes/entities/ClassEntity.java +++ b/src/main/java/com/classes/entities/ClassEntity.java @@ -7,6 +7,8 @@ import java.time.LocalDate; import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; @Entity @@ -20,8 +22,6 @@ public class ClassEntity { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; - private String name; - private String className; private int duration; @@ -39,6 +39,11 @@ public class ClassEntity { @JoinColumn(name = "trainer_id") private TrainerEntity trainer; + @OneToMany(mappedBy = "classEntity", cascade = CascadeType.ALL) + @Builder.Default + private List reservations = new ArrayList<>(); + + @Embedded private Audit audit; } diff --git a/src/main/java/com/classes/entities/ClassReservation.java b/src/main/java/com/classes/entities/ClassReservation.java new file mode 100644 index 0000000..46c769c --- /dev/null +++ b/src/main/java/com/classes/entities/ClassReservation.java @@ -0,0 +1,35 @@ +package com.classes.entities; + +import com.classes.enums.ReservationStatus; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "class_reservations") +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ClassReservation { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne + @JoinColumn(name = "class_id") + private ClassEntity classEntity; + + private UUID memberId; + + @Enumerated(EnumType.STRING) + private ReservationStatus status; // RESERVADO, LISTA_ESPERA, CANCELADO + private LocalDateTime reservedAt; + @Transient + private boolean attended; + +} diff --git a/src/main/java/com/classes/enums/ReservationStatus.java b/src/main/java/com/classes/enums/ReservationStatus.java new file mode 100644 index 0000000..edf6e61 --- /dev/null +++ b/src/main/java/com/classes/enums/ReservationStatus.java @@ -0,0 +1,7 @@ +package com.classes.enums; + +public enum ReservationStatus { + RESERVADO, + LISTA_ESPERA, + CANCELADO +} diff --git a/src/main/java/com/classes/mappers/ClassReservationMapper.java b/src/main/java/com/classes/mappers/ClassReservationMapper.java new file mode 100644 index 0000000..8d07511 --- /dev/null +++ b/src/main/java/com/classes/mappers/ClassReservationMapper.java @@ -0,0 +1,37 @@ +package com.classes.mappers; + + +import com.classes.config.MapStructConfig; + +import com.classes.dtos.reservations.ClassReservationRequest; +import com.classes.dtos.reservations.ClassReservationResponse; +import com.classes.entities.ClassReservation; +import org.mapstruct.*; + +import java.util.List; + +@Mapper(config = MapStructConfig.class) +public interface ClassReservationMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "classEntity", ignore = true) // Service asigna la clase + @Mapping(target = "memberId", ignore = true) // Service asigna desde token + @Mapping(target = "status", ignore = true) + @Mapping(target = "reservedAt", expression = "java(java.time.LocalDateTime.now())") + @Mapping(target = "attended", constant = "false") + ClassReservation toEntity(ClassReservationRequest request); + + @Mapping(target = "reservationId", source = "entity.id") + @Mapping(target = "classId", source = "entity.classEntity.id") + @Mapping(target = "className", source = "entity.classEntity.className") + @Mapping(target = "trainerName", expression = "java(entity.getClassEntity().getTrainer().getFirstName() + \" \" + entity.getClassEntity().getTrainer().getLastName())") + @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 = "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); + + List toResponseList(List entities); +} diff --git a/src/main/java/com/classes/repositories/ClassReservationRepository.java b/src/main/java/com/classes/repositories/ClassReservationRepository.java new file mode 100644 index 0000000..95a3e53 --- /dev/null +++ b/src/main/java/com/classes/repositories/ClassReservationRepository.java @@ -0,0 +1,13 @@ +package com.classes.repositories; + +import com.classes.entities.ClassReservation; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ClassReservationRepository extends JpaRepository { + List findByMemberId(UUID memberId); + List findByClassEntityId(UUID classId); + Optional findByClassEntityIdAndMemberId(UUID classId, UUID memberId); +} diff --git a/src/main/java/com/classes/services/ClassReservationService.java b/src/main/java/com/classes/services/ClassReservationService.java new file mode 100644 index 0000000..c33c0c8 --- /dev/null +++ b/src/main/java/com/classes/services/ClassReservationService.java @@ -0,0 +1,15 @@ +package com.classes.services; + + + +import com.classes.dtos.reservations.ClassReservationRequest; +import com.classes.dtos.reservations.ClassReservationResponse; +import java.util.List; +import java.util.UUID; + +public interface ClassReservationService { + ClassReservationResponse reserveClass(ClassReservationRequest request, UUID memberId); + void cancelReservation(UUID reservationId, UUID memberId); + List getReservationsByMember(UUID memberId, Boolean completed); +} + diff --git a/src/main/java/com/classes/services/Impl/ClassReservationServiceImpl.java b/src/main/java/com/classes/services/Impl/ClassReservationServiceImpl.java new file mode 100644 index 0000000..e27bd6f --- /dev/null +++ b/src/main/java/com/classes/services/Impl/ClassReservationServiceImpl.java @@ -0,0 +1,98 @@ +package com.classes.services.Impl; +import com.classes.dtos.reservations.ClassReservationRequest; +import com.classes.dtos.reservations.ClassReservationResponse; +import com.classes.entities.ClassEntity; +import com.classes.entities.ClassReservation; +import com.classes.enums.ReservationStatus; +import com.classes.mappers.ClassReservationMapper; +import com.classes.repositories.ClassRepository; +import com.classes.repositories.ClassReservationRepository; +import com.classes.services.ClassReservationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional +public class ClassReservationServiceImpl implements ClassReservationService { + + private final ClassReservationRepository reservationRepository; + 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"); + } + + // 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); + + reservationRepository.save(reservation); + + return mapper.toResponse(reservation); + } + + @Override + public void cancelReservation(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 cancelar la reserva de otro usuario"); + } + + reservation.setStatus(ReservationStatus.CANCELADO); + reservationRepository.save(reservation); + + // 5️⃣ Lista de espera automática: pasar al primer miembro en espera + List waitingList = reservationRepository.findByClassEntityId(reservation.getClassEntity().getId()) + .stream() + .filter(r -> r.getStatus() == ReservationStatus.LISTA_ESPERA) + .sorted(Comparator.comparing(ClassReservation::getReservedAt)) + .toList(); + + if (!waitingList.isEmpty()) { + ClassReservation firstInLine = waitingList.get(0); + firstInLine.setStatus(ReservationStatus.RESERVADO); + reservationRepository.save(firstInLine); + } + } + + @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())) + .map(mapper::toResponse) + .toList(); + } +} diff --git a/src/main/java/com/classes/services/Impl/DashboardServiceImpl.java b/src/main/java/com/classes/services/Impl/DashboardServiceImpl.java new file mode 100644 index 0000000..2c22608 --- /dev/null +++ b/src/main/java/com/classes/services/Impl/DashboardServiceImpl.java @@ -0,0 +1,130 @@ +package com.classes.services.Impl; + +import com.classes.dtos.Dashboard.MemberDashboardDTO; +import com.classes.dtos.Dashboard.UpcomingClassDTO; +import com.classes.dtos.Dashboard.WeeklyActivityDTO; +import com.classes.entities.ClassReservation; +import com.classes.enums.ReservationStatus; +import com.classes.repositories.ClassReservationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.*; +import java.util.stream.Collectors; + + +@Service +@RequiredArgsConstructor +public class DashboardServiceImpl { + private final ClassReservationRepository reservationRepository; + + 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()) + .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; + }); + + // 🔹 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()); + return start.isAfter(now); + }) + .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) + .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") + .limit(3) + .map(r -> UpcomingClassDTO.builder() + .className(r.getClassEntity().getClassName()) + .trainerName(r.getClassEntity().getTrainer().getFirstName() + " " + + r.getClassEntity().getTrainer().getLastName()) + .schedule(r.getClassEntity().getStartTime() + " - " + r.getClassEntity().getEndTime()) + .location(r.getClassEntity().getLocation().getName()) + .build()) + .toList(); + + // 🔹 Construcción del DTO final + return MemberDashboardDTO.builder() + .inClass(inClass) + .remainingClasses(remaining) + .nextClassName(nextClassOpt.map(r -> r.getClassEntity().getClassName()).orElse(null)) + .nextClassTime(nextClassOpt.map(r -> r.getClassEntity().getStartTime().toString()).orElse(null)) + .consecutiveDays(consecutiveDays) + .weeklyActivity(weeklyActivity) + .upcomingClasses(upcoming) + .build(); + } + + private int calcularDiasConsecutivos(List reservations) { + var dates = reservations.stream() + .filter(ClassReservation::isAttended) + .map(r -> r.getReservedAt().toLocalDate()) + .distinct() + .sorted(Comparator.reverseOrder()) + .toList(); + + int streak = 0; + LocalDate prev = LocalDate.now(); + for (LocalDate date : dates) { + if (date.equals(prev) || date.equals(prev.minusDays(1))) { + streak++; + prev = date; + } else break; + } + return streak; + } + + private List calcularActividadSemanal(List reservations) { + Map map = reservations.stream() + .filter(ClassReservation::isAttended) + .collect(Collectors.groupingBy(r -> r.getReservedAt().getDayOfWeek(), Collectors.counting())); + + return Arrays.stream(DayOfWeek.values()) + .map(day -> WeeklyActivityDTO.builder() + .day(day.name()) + .sessions(map.getOrDefault(day, 0L).intValue()) + .build()) + .toList(); + } +}