UserController.java
package com.wavii.controller;
import com.wavii.dto.pdf.PdfResponseDto;
import com.wavii.dto.user.BlockedUserDto;
import com.wavii.dto.user.PublicUserProfileDto;
import com.wavii.model.User;
import com.wavii.model.UserBlock;
import com.wavii.model.UserReport;
import com.wavii.repository.PdfDocumentRepository;
import com.wavii.repository.UserBlockRepository;
import com.wavii.repository.UserRepository;
import com.wavii.repository.UserReportRepository;
import com.wavii.service.OdooService;
import com.wavii.service.PdfStorageService;
import com.wavii.service.StripeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* Controlador REST para la gestión de perfiles de usuario.
* Proporciona endpoints para consultar, actualizar y gestionar la cuenta del usuario.
*
* @author eduglezexp
*/
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Slf4j
public class UserController {
private static final Pattern STRONG_PASSWORD_PATTERN =
Pattern.compile("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z\\d]).{8,}$");
private final UserRepository userRepository;
private final UserReportRepository userReportRepository;
private final UserBlockRepository userBlockRepository;
private final PdfDocumentRepository pdfDocumentRepository;
private final PdfStorageService pdfStorageService;
private final PasswordEncoder passwordEncoder;
private final StripeService stripeService;
private final OdooService odooService;
/**
* Actualiza los datos del perfil del usuario actual (nombre y ciudad).
*
* @param currentUser Usuario autenticado.
* @param body Mapa con los campos a actualizar.
* @return 200 OK con los datos actualizados o error de validación.
*/
@PatchMapping("/me")
public ResponseEntity<?> updateMe(
@AuthenticationPrincipal User currentUser,
@RequestBody Map<String, String> body
) {
if (currentUser == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
String name = body.get("name");
String city = body.get("city");
if ((name == null || name.isBlank()) && (city == null || city.isBlank())) {
return ResponseEntity.badRequest()
.body(Map.of("code", "VALIDATION_ERROR",
"message", "Debes indicar al menos un campo para actualizar"));
}
if (name != null && !name.isBlank() && name.trim().length() < 3) {
return ResponseEntity.badRequest()
.body(Map.of("code", "VALIDATION_ERROR",
"message", "El nombre debe tener al menos 3 caracteres"));
}
if (name != null && !name.isBlank()) {
String trimmedName = name.trim();
if (!currentUser.getName().equalsIgnoreCase(trimmedName)
&& userRepository.existsByNameIgnoreCase(trimmedName)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("code", "CONFLICT",
"field", "name",
"message", "Este nombre de usuario ya está en uso"));
}
currentUser.setName(trimmedName);
}
if (city != null) {
currentUser.setCity(city.isBlank() ? null : city.trim());
}
userRepository.save(currentUser);
odooService.createSubscriptionTask(
currentUser.getName(),
currentUser.getEmail(),
"Actualizacion de perfil",
"El usuario ha actualizado su perfil visible. Nombre: " + currentUser.getName()
+ "\nCiudad: " + (currentUser.getCity() != null ? currentUser.getCity() : "no informada")
);
return ResponseEntity.ok(Map.of(
"name", currentUser.getName(),
"city", currentUser.getCity() != null ? currentUser.getCity() : ""
));
}
/**
* Cambia la contraseña del usuario actual.
*
* @param currentUser Usuario autenticado.
* @param req Datos con la contraseña actual y la nueva.
* @return 200 OK si se cambió correctamente.
*/
@PatchMapping("/me/password")
public ResponseEntity<?> changePassword(
@AuthenticationPrincipal User currentUser,
@RequestBody ChangePasswordRequest req
) {
if (currentUser == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
if (currentUser.getPasswordHash() == null || currentUser.getPasswordHash().isBlank()) {
return ResponseEntity.badRequest()
.body(Map.of("code", "NO_PASSWORD",
"message", "Tu cuenta no tiene contraseña configurada (inicio con Google)"));
}
if (!passwordEncoder.matches(req.currentPassword(), currentUser.getPasswordHash())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("code", "WRONG_PASSWORD",
"message", "La contraseña actual es incorrecta"));
}
if (req.newPassword() == null || !STRONG_PASSWORD_PATTERN.matcher(req.newPassword()).matches()) {
return ResponseEntity.badRequest()
.body(Map.of("code", "VALIDATION_ERROR",
"message", "La nueva contraseña debe tener al menos 8 caracteres, una mayúscula, una minúscula, un número y un carácter especial"));
}
if (req.newPassword().equals(req.currentPassword())) {
return ResponseEntity.badRequest()
.body(Map.of("code", "SAME_PASSWORD",
"message", "La nueva contraseña debe ser diferente a la actual"));
}
currentUser.setPasswordHash(passwordEncoder.encode(req.newPassword()));
userRepository.save(currentUser);
log.info("Contrasena actualizada para usuario {}", currentUser.getEmail());
return ResponseEntity.ok(Map.of("message", "Contraseña actualizada correctamente"));
}
/**
* Programa la eliminación de la cuenta del usuario actual (en 15 días).
*
* @param currentUser Usuario autenticado.
* @return 200 OK con la fecha programada.
*/
@DeleteMapping("/me")
public ResponseEntity<?> deleteMe(@AuthenticationPrincipal User currentUser) {
try {
if (stripeService.isConfigured()
&& currentUser.getStripeSubscriptionId() != null
&& !currentUser.getStripeSubscriptionId().isBlank()
&& !"canceled".equals(currentUser.getSubscriptionStatus())) {
try {
stripeService.cancelAtPeriodEnd(currentUser.getStripeSubscriptionId());
currentUser.setSubscriptionCancelAtPeriodEnd(true);
log.info("Suscripcion de {} cancelada al final del periodo por eliminacion de cuenta",
currentUser.getEmail());
} catch (Exception e) {
log.warn("No se pudo cancelar suscripcion Stripe para {}: {}",
currentUser.getEmail(), e.getMessage());
}
}
LocalDateTime deletionDate = LocalDateTime.now().plusDays(15);
if (currentUser.getSubscriptionCurrentPeriodEnd() != null
&& currentUser.getSubscriptionCurrentPeriodEnd().isAfter(deletionDate)) {
deletionDate = currentUser.getSubscriptionCurrentPeriodEnd();
}
currentUser.setDeletionScheduledAt(deletionDate);
userRepository.save(currentUser);
odooService.createSubscriptionTask(
currentUser.getName(),
currentUser.getEmail(),
"Eliminacion de cuenta programada",
"Fecha prevista de eliminacion: " + deletionDate
+ "\nCancelacion de suscripcion al fin de periodo: " + currentUser.isSubscriptionCancelAtPeriodEnd()
);
log.info("Eliminacion programada para {} el {}", currentUser.getEmail(), deletionDate);
return ResponseEntity.ok(Map.of(
"deletionScheduledAt", deletionDate.toString(),
"message", "Tu cuenta se eliminara el " + deletionDate.toLocalDate()
));
} catch (Exception e) {
log.error("Error programando eliminacion para {}: {}", currentUser.getEmail(), e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "No se pudo programar la eliminacion de la cuenta"));
}
}
/**
* Cancela la eliminación programada de la cuenta.
*
* @param currentUser Usuario autenticado.
* @return 200 OK si se canceló correctamente.
*/
@PatchMapping("/me/deletion-cancel")
public ResponseEntity<?> cancelDeletion(@AuthenticationPrincipal User currentUser) {
if (currentUser == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
if (currentUser.getDeletionScheduledAt() == null) {
return ResponseEntity.badRequest()
.body(Map.of("message", "No hay ninguna eliminacion programada"));
}
if (stripeService.isConfigured()
&& currentUser.getStripeSubscriptionId() != null
&& !currentUser.getStripeSubscriptionId().isBlank()
&& currentUser.isSubscriptionCancelAtPeriodEnd()) {
try {
stripeService.reactivateSubscription(currentUser.getStripeSubscriptionId());
currentUser.setSubscriptionCancelAtPeriodEnd(false);
log.info("Suscripcion de {} reactivada tras cancelar eliminacion", currentUser.getEmail());
} catch (Exception e) {
log.warn("No se pudo reactivar suscripcion Stripe para {}: {}",
currentUser.getEmail(), e.getMessage());
}
}
currentUser.setDeletionScheduledAt(null);
userRepository.save(currentUser);
odooService.createSubscriptionTask(
currentUser.getName(),
currentUser.getEmail(),
"Eliminacion de cuenta cancelada",
"El usuario ha cancelado la eliminacion programada de su cuenta."
);
return ResponseEntity.ok(Map.of("message", "La eliminacion de tu cuenta ha sido cancelada"));
}
/**
* Comprueba si un nombre de usuario está disponible para actualización.
*
* @param name Nombre a comprobar.
* @param currentUser Usuario autenticado.
* @return 200 OK con { taken: boolean }.
*/
@GetMapping("/me/check-name")
public ResponseEntity<Map<String, Boolean>> checkNameForUpdate(
@RequestParam String name,
@AuthenticationPrincipal User currentUser) {
if (currentUser == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).<Map<String, Boolean>>build();
}
String trimmed = name.strip();
boolean taken = userRepository.existsByNameIgnoreCase(trimmed)
&& !currentUser.getName().equalsIgnoreCase(trimmed);
return ResponseEntity.ok(Map.of("taken", taken));
}
/**
* Obtiene el perfil público de un usuario.
*
* @param id ID del usuario.
* @param currentUser Usuario autenticado.
* @return Perfil público del usuario.
*/
@GetMapping("/{id}")
public ResponseEntity<?> getPublicProfile(
@PathVariable UUID id,
@AuthenticationPrincipal User currentUser) {
return userRepository.findById(id)
.map(user -> {
int tabs = (int) pdfDocumentRepository.countByOwnerId(id);
boolean blockedByMe = currentUser != null
&& userBlockRepository.existsByBlockerIdAndBlockedId(currentUser.getId(), id);
boolean teacherProfileAvailable = hasPublishedTeacherProfile(user);
return ResponseEntity.ok(PublicUserProfileDto.from(user, tabs, blockedByMe, teacherProfileAvailable));
})
.orElse(ResponseEntity.notFound().build());
}
/**
* Obtiene la lista de usuarios bloqueados por el usuario autenticado.
*
* @param currentUser Usuario autenticado.
* @return Lista de usuarios bloqueados.
*/
@GetMapping("/me/blocked")
public ResponseEntity<List<BlockedUserDto>> getBlockedUsers(@AuthenticationPrincipal User currentUser) {
if (currentUser == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
List<BlockedUserDto> blockedUsers = userBlockRepository.findByBlockerIdOrderByCreatedAtDesc(currentUser.getId())
.stream()
.map(block -> new BlockedUserDto(
block.getBlocked().getId(),
block.getBlocked().getName(),
block.getCreatedAt() != null ? block.getCreatedAt().toString() : null
))
.toList();
return ResponseEntity.ok(blockedUsers);
}
/**
* Obtiene las tablaturas públicas de un usuario.
*
* @param id ID del usuario.
* @param currentUser Usuario autenticado.
* @return Lista de tablaturas públicas.
*/
@GetMapping("/{id}/tabs")
public ResponseEntity<List<PdfResponseDto>> getUserTabs(
@PathVariable UUID id,
@AuthenticationPrincipal User currentUser) {
List<PdfResponseDto> tabs = pdfStorageService.getPublicTabsByUser(id, currentUser);
return ResponseEntity.ok(tabs);
}
/**
* Reporta a un usuario por una razón específica.
*
* @param id ID del usuario reportado.
* @param body Mapa con el campo "reason".
* @param currentUser Usuario que reporta.
* @return 200 OK si se envió el reporte.
*/
@PostMapping("/{id}/report")
public ResponseEntity<?> reportUser(
@PathVariable UUID id,
@RequestBody Map<String, String> body,
@AuthenticationPrincipal User currentUser) {
if (currentUser == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
return userRepository.findById(id).map(reported -> {
UserReport report = UserReport.builder()
.reporter(currentUser)
.reported(reported)
.reason(body.get("reason"))
.build();
userReportRepository.save(report);
odooService.createModerationReport(
"user",
currentUser.getName(),
currentUser.getEmail(),
reported.getName(),
"user:" + reported.getId(),
body.get("reason"),
null
);
return ResponseEntity.ok(Map.of("message", "Reporte enviado"));
}).orElse(ResponseEntity.notFound().build());
}
/**
* Bloquea a un usuario.
*
* @param id ID del usuario a bloquear.
* @param currentUser Usuario que bloquea.
* @return 200 OK si se bloqueó correctamente.
*/
@PostMapping("/{id}/block")
public ResponseEntity<?> blockUser(
@PathVariable UUID id,
@AuthenticationPrincipal User currentUser) {
if (currentUser == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
return userRepository.findById(id).map(blocked -> {
if (!userBlockRepository.existsByBlockerIdAndBlockedId(currentUser.getId(), id)) {
userBlockRepository.save(UserBlock.builder()
.blocker(currentUser)
.blocked(blocked)
.build());
}
return ResponseEntity.ok(Map.of("message", "Usuario bloqueado"));
}).orElse(ResponseEntity.notFound().build());
}
/**
* Desbloquea a un usuario.
*
* @param id ID del usuario a desbloquear.
* @param currentUser Usuario que desbloquea.
* @return 200 OK si se desbloqueó correctamente.
*/
@DeleteMapping("/{id}/block")
public ResponseEntity<?> unblockUser(
@PathVariable UUID id,
@AuthenticationPrincipal User currentUser) {
if (currentUser == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
userBlockRepository.findByBlockerIdAndBlockedId(currentUser.getId(), id)
.ifPresent(userBlockRepository::delete);
return ResponseEntity.ok(Map.of("message", "Usuario desbloqueado"));
}
/**
* Activa o desactiva la recepción de mensajes privados.
*
* @param body Mapa con el campo "acceptsMessages".
* @param currentUser Usuario autenticado.
* @return 200 OK.
*/
@PatchMapping("/me/accepts-messages")
public ResponseEntity<?> toggleAcceptsMessages(
@RequestBody Map<String, Boolean> body,
@AuthenticationPrincipal User currentUser) {
if (currentUser == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Boolean accepts = body.get("acceptsMessages");
if (accepts == null) {
return ResponseEntity.badRequest()
.body(Map.of("message", "Campo acceptsMessages requerido"));
}
currentUser.setAcceptsMessages(accepts);
userRepository.save(currentUser);
return ResponseEntity.ok().build();
}
record ChangePasswordRequest(String currentPassword, String newPassword) {}
private boolean hasPublishedTeacherProfile(User user) {
if (user == null || user.getRole() == null) {
return false;
}
boolean isTeacher = user.getRole() == com.wavii.model.enums.Role.PROFESOR_PARTICULAR
|| user.getRole() == com.wavii.model.enums.Role.PROFESOR_CERTIFICADO;
if (!isTeacher) {
return false;
}
return user.getInstrument() != null && !user.getInstrument().isBlank()
&& user.getContactEmail() != null && !user.getContactEmail().isBlank()
&& user.getContactPhone() != null && !user.getContactPhone().isBlank();
}
}