ClassService.java
package com.wavii.service;
import com.stripe.exception.StripeException;
import com.stripe.model.PaymentIntent;
import com.wavii.model.ClassEnrollment;
import com.wavii.model.ClassMessage;
import com.wavii.model.ClassPost;
import com.wavii.model.ClassSession;
import com.wavii.model.TeacherReport;
import com.wavii.model.User;
import com.wavii.model.enums.Role;
import com.wavii.repository.ClassEnrollmentRepository;
import com.wavii.repository.ClassMessageRepository;
import com.wavii.repository.ClassPostRepository;
import com.wavii.repository.ClassSessionRepository;
import com.wavii.repository.TeacherReportRepository;
import com.wavii.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Slf4j
public class ClassService {
private static final String STATUS_PENDING = "pending";
private static final String STATUS_ACCEPTED = "accepted";
private static final String STATUS_REJECTED = "rejected";
private static final String STATUS_PAID = "paid";
private static final String STATUS_SCHEDULED = "scheduled";
private static final String STATUS_COMPLETED = "completed";
private static final String STATUS_REFUND_REQUESTED = "refund_requested";
private static final String STATUS_REFUNDED = "refunded";
private static final String STATUS_CANCELLED = "cancelled";
private final UserRepository userRepository;
private final ClassEnrollmentRepository enrollmentRepository;
private final ClassMessageRepository messageRepository;
private final ClassPostRepository postRepository;
private final ClassSessionRepository sessionRepository;
private final TeacherReportRepository teacherReportRepository;
private final StripeService stripeService;
private final OdooService odooService;
private final NotificationService notificationService;
private final EmailService emailService;
/**
* Lista todas las clases (inscripciones) del alumno actual.
*
* @param currentUser Usuario alumno.
* @return Lista de mapas con datos de inscripciones.
*/
@Transactional(readOnly = true)
public List<Map<String, Object>> listClasses(User currentUser) {
List<ClassEnrollment> enrollments = enrollmentRepository.findByStudentOrderByCreatedAtDesc(currentUser);
return enrollments.stream()
.map(this::toEnrollmentMap)
.toList();
}
/**
* Obtiene una visión general de las clases, sesiones y noticias para un profesor.
*
* @param currentUser Usuario profesor.
* @return Mapa con listas de clases, sesiones y noticias.
*/
@Transactional(readOnly = true)
public Map<String, Object> getManageOverview(User currentUser) {
if (!isTeacher(currentUser)) {
throw new IllegalArgumentException("Solo un profesor puede gestionar sus clases");
}
List<Map<String, Object>> classes = enrollmentRepository.findByTeacherOrderByCreatedAtDesc(currentUser)
.stream()
.map(this::toEnrollmentMap)
.toList();
List<Map<String, Object>> sessions = sessionRepository.findByTeacherOrderByScheduledAtAsc(currentUser)
.stream()
.map(this::toSessionMap)
.toList();
List<Map<String, Object>> posts = postRepository.findByTeacherOrderByCreatedAtDesc(currentUser).stream()
.map(this::toPostMap)
.toList();
return Map.of(
"classes", classes,
"sessions", sessions,
"posts", posts
);
}
/**
* Inicia el proceso de pago para una hora de clase con un profesor.
*
* @param teacherId ID del profesor.
* @param student Usuario alumno.
* @return Datos para completar el pago con Stripe.
*/
@Transactional
public Map<String, Object> checkout(UUID teacherId, User student) throws Exception {
if (student == null) {
throw new IllegalArgumentException("Sesion no valida");
}
User teacher = userRepository.findById(teacherId)
.orElseThrow(() -> new IllegalArgumentException("Profesor no encontrado"));
if (!isTeacher(teacher)) {
throw new IllegalArgumentException("El usuario no es profesor");
}
if (teacher.getId().equals(student.getId())) {
throw new IllegalArgumentException("No puedes suscribirte a tu propia clase");
}
if (teacher.getPricePerHour() == null) {
throw new IllegalArgumentException("El profesor no tiene precio configurado");
}
ClassEnrollment enrollment = buildEnrollment(teacher, student);
enrollment = enrollmentRepository.save(enrollment);
if (teacher.getPricePerHour().compareTo(BigDecimal.ZERO) <= 0) {
markPaidEnrollment(enrollment, "free_class_" + enrollment.getId(), "free_receipt_" + enrollment.getId());
return checkoutResponse(enrollment, "free_class_no_payment", true);
}
if (!stripeService.isConfigured()) {
markPaidEnrollment(enrollment, "dev_pi_" + enrollment.getId(), "dev_receipt_" + enrollment.getId());
return checkoutResponse(enrollment, "dev_class_client_secret", true);
}
String customerId = stripeService.createOrGetCustomer(student);
student.setStripeCustomerId(customerId);
userRepository.save(student);
long amountCents = teacher.getPricePerHour().movePointRight(2).longValueExact();
Map<String, Object> intent = stripeService.createClassPaymentIntent(
customerId,
teacher.getId().toString(),
teacher.getName(),
student.getName(),
teacher.getInstrument(),
teacher.getClassModality(),
teacher.getCity(),
amountCents);
enrollment.setStripePaymentIntentId((String) intent.get("paymentIntentId"));
enrollmentRepository.save(enrollment);
Map<String, Object> response = new LinkedHashMap<>();
response.put("enrollmentId", enrollment.getId().toString());
response.put("paymentIntentId", intent.get("paymentIntentId"));
response.put("clientSecret", intent.get("clientSecret"));
response.put("devMode", false);
return response;
}
/**
* Envía una solicitud de clase a un profesor sin pago previo.
*
* @param teacherId ID del profesor.
* @param student Usuario alumno.
* @param body Datos con el mensaje, disponibilidad y modalidad solicitada.
* @return Datos de la solicitud creada.
*/
@Transactional
public Map<String, Object> requestClass(UUID teacherId, User student, Map<String, String> body) {
if (student == null) {
throw new IllegalArgumentException("Sesion no valida");
}
User teacher = userRepository.findById(teacherId)
.orElseThrow(() -> new IllegalArgumentException("Profesor no encontrado"));
if (!isTeacher(teacher)) {
throw new IllegalArgumentException("El usuario no es profesor");
}
if (teacher.getId().equals(student.getId())) {
throw new IllegalArgumentException("No puedes solicitar clases a tu propio perfil");
}
String message = normalize(body.get("message"));
String availability = normalize(body.get("availability"));
String requestedModality = normalize(body.get("requestedModality"));
enrollmentRepository.findFirstByTeacherAndStudentOrderByCreatedAtDesc(teacher, student)
.filter(existing -> isActiveRequestStatus(existing.getPaymentStatus()))
.ifPresent(existing -> {
throw new IllegalArgumentException("Ya tienes una solicitud activa con este profesor");
});
ClassEnrollment enrollment = ClassEnrollment.builder()
.teacher(teacher)
.student(student)
.teacherName(teacher.getName())
.studentName(student.getName())
.instrument(teacher.getInstrument())
.city(teacher.getCity())
.province(teacher.getProvince())
.modality(teacher.getClassModality())
.requestedModality(requestedModality != null ? requestedModality : teacher.getClassModality())
.unitPrice(teacher.getPricePerHour())
.paymentStatus(STATUS_PENDING)
.requestMessage(message)
.requestAvailability(availability)
.hoursPurchased(1)
.hoursUsed(0)
.build();
enrollment = enrollmentRepository.save(enrollment);
notifyRequestCreated(enrollment);
return toEnrollmentMap(enrollment);
}
/**
* Confirma el pago de una clase tras la transacción en Stripe.
*
* @param enrollmentId ID de la clase.
* @param currentUser Usuario alumno.
* @return Datos actualizados de la clase.
*/
@Transactional
public Map<String, Object> confirm(UUID enrollmentId, User currentUser) {
ClassEnrollment enrollment = getEnrollment(enrollmentId);
ensureParticipant(enrollment, currentUser);
if (isPaidState(enrollment.getPaymentStatus())) {
return toEnrollmentMap(enrollment);
}
if (!STATUS_PENDING.equalsIgnoreCase(nullSafeStatus(enrollment))) {
throw new IllegalArgumentException("Esta clase no se puede confirmar en su estado actual");
}
if (!stripeService.isConfigured()) {
markPaidEnrollment(enrollment,
blankToDefault(enrollment.getStripePaymentIntentId(), "manual_" + enrollment.getId()),
"receipt_" + enrollment.getId());
return toEnrollmentMap(enrollment);
}
String paymentIntentId = enrollment.getStripePaymentIntentId();
if (paymentIntentId == null || paymentIntentId.isBlank()) {
throw new IllegalArgumentException("No hay un pago pendiente para esta clase");
}
try {
PaymentIntent paymentIntent = stripeService.retrievePaymentIntent(paymentIntentId);
if (!"succeeded".equalsIgnoreCase(paymentIntent.getStatus())) {
throw new IllegalArgumentException("El pago no se ha completado todavia");
}
markPaidEnrollment(enrollment, paymentIntentId, paymentIntent.getLatestCharge());
return toEnrollmentMap(enrollment);
} catch (StripeException e) {
log.warn("Error confirmando pago Stripe de clase {}: {}", enrollmentId, e.getMessage());
throw new IllegalArgumentException("No se pudo validar el pago con Stripe");
}
}
/**
* Actualiza el estado de una solicitud (aceptar, rechazar, cancelar, completar).
*
* @param enrollmentId ID de la solicitud.
* @param currentUser Usuario que realiza la acción.
* @param body Contiene el nuevo "status" y opcionalmente un "reason".
* @return Datos actualizados de la solicitud.
*/
@Transactional
public Map<String, Object> updateStatus(UUID enrollmentId, User currentUser, Map<String, String> body) {
ClassEnrollment enrollment = getEnrollment(enrollmentId);
ensureParticipant(enrollment, currentUser);
String nextStatus = normalize(body.get("status"));
String reason = normalize(body.get("reason"));
if (nextStatus == null) {
throw new IllegalArgumentException("Debes indicar un estado");
}
if (!isAllowedStatus(nextStatus)) {
throw new IllegalArgumentException("Estado no valido");
}
boolean teacherAction = enrollment.getTeacher().getId().equals(currentUser.getId());
if (!teacherAction && !STATUS_CANCELLED.equalsIgnoreCase(nextStatus)) {
throw new IllegalArgumentException("Solo el profesor puede aceptar o completar una solicitud");
}
if (STATUS_ACCEPTED.equalsIgnoreCase(nextStatus) && !teacherAction) {
throw new IllegalArgumentException("Solo el profesor puede aceptar una solicitud");
}
if ((STATUS_REJECTED.equalsIgnoreCase(nextStatus) || STATUS_COMPLETED.equalsIgnoreCase(nextStatus))
&& !teacherAction) {
throw new IllegalArgumentException("Solo el profesor puede modificar este estado");
}
if (STATUS_PENDING.equalsIgnoreCase(nullSafeStatus(enrollment)) && STATUS_COMPLETED.equalsIgnoreCase(nextStatus)) {
throw new IllegalArgumentException("Primero debes aceptar la solicitud");
}
enrollment.setPaymentStatus(nextStatus.toLowerCase());
enrollmentRepository.save(enrollment);
notifyStatusChange(enrollment, nextStatus.toLowerCase(), currentUser, reason);
return toEnrollmentMap(enrollment);
}
/**
* Obtiene todos los mensajes de chat de una clase.
*
* @param enrollmentId ID de la clase.
* @param currentUser Usuario participante.
* @return Lista de mensajes.
*/
@Transactional(readOnly = true)
public List<Map<String, Object>> getMessages(UUID enrollmentId, User currentUser) {
ClassEnrollment enrollment = getEnrollment(enrollmentId);
ensureParticipant(enrollment, currentUser);
ensureChatEnabled(enrollment);
return messageRepository.findByEnrollmentOrderByCreatedAtAsc(enrollment)
.stream()
.map(this::toMessageMap)
.toList();
}
/**
* Envía un mensaje de chat dentro de una clase.
*
* @param enrollmentId ID de la clase.
* @param sender Usuario remitente.
* @param content Contenido del mensaje.
* @return Datos del mensaje enviado.
*/
@Transactional
public Map<String, Object> sendMessage(UUID enrollmentId, User sender, String content) {
ClassEnrollment enrollment = getEnrollment(enrollmentId);
ensureParticipant(enrollment, sender);
ensureChatEnabled(enrollment);
String cleaned = content == null ? "" : content.trim();
if (cleaned.isBlank()) {
throw new IllegalArgumentException("El mensaje no puede estar vacio");
}
ClassMessage message = ClassMessage.builder()
.enrollment(enrollment)
.sender(sender)
.content(cleaned)
.build();
ClassMessage saved = messageRepository.save(message);
notifyTeacherAboutStudentMessage(enrollment, sender, cleaned);
return toMessageMap(saved);
}
/**
* Obtiene las noticias publicadas por un profesor.
*
* @param teacher Usuario profesor.
* @return Lista de noticias.
*/
@Transactional(readOnly = true)
public List<Map<String, Object>> getPosts(User teacher) {
if (!isTeacher(teacher)) {
return List.of();
}
return postRepository.findByTeacherOrderByCreatedAtDesc(teacher).stream()
.map(this::toPostMap)
.toList();
}
/**
* Obtiene las noticias de un profesor para un espectador (alumno o él mismo).
*
* @param viewer Usuario que consulta.
* @param teacherId ID del profesor.
* @return Lista de noticias si tiene permiso.
*/
@Transactional(readOnly = true)
public List<Map<String, Object>> getPostsForViewer(User viewer, UUID teacherId) {
User teacher = userRepository.findById(teacherId)
.orElseThrow(() -> new IllegalArgumentException("Profesor no encontrado"));
if (!isTeacher(teacher)) {
return List.of();
}
if (viewer == null) {
throw new IllegalArgumentException("Sesion no valida");
}
boolean canView = viewer.getId().equals(teacherId)
|| enrollmentRepository.existsByTeacherAndStudentAndPaymentStatusIgnoreCase(teacher, viewer, STATUS_ACCEPTED)
|| enrollmentRepository.existsByTeacherAndStudentAndPaymentStatusIgnoreCase(teacher, viewer, STATUS_PAID)
|| enrollmentRepository.existsByTeacherAndStudentAndPaymentStatusIgnoreCase(teacher, viewer, STATUS_SCHEDULED)
|| enrollmentRepository.existsByTeacherAndStudentAndPaymentStatusIgnoreCase(teacher, viewer, STATUS_COMPLETED);
if (!canView) {
throw new IllegalArgumentException("No tienes acceso a estas noticias");
}
return postRepository.findByTeacherOrderByCreatedAtDesc(teacher).stream()
.map(this::toPostMap)
.toList();
}
/**
* Obtiene las noticias de todos los profesores con los que el alumno tiene clases.
*
* @param student Usuario alumno.
* @return Lista de noticias ordenadas por fecha.
*/
@Transactional(readOnly = true)
public List<Map<String, Object>> getPostsForStudent(User student) {
if (student == null) {
throw new IllegalArgumentException("Sesion no valida");
}
Map<UUID, User> activeTeachers = new LinkedHashMap<>();
enrollmentRepository.findByStudentOrderByCreatedAtDesc(student).stream()
.filter(enrollment -> {
String status = nullSafeStatus(enrollment);
return STATUS_ACCEPTED.equalsIgnoreCase(status)
|| STATUS_PAID.equalsIgnoreCase(status)
|| STATUS_SCHEDULED.equalsIgnoreCase(status)
|| STATUS_COMPLETED.equalsIgnoreCase(status);
})
.forEach(enrollment -> {
if (enrollment.getTeacher() != null) {
activeTeachers.putIfAbsent(enrollment.getTeacher().getId(), enrollment.getTeacher());
}
});
return activeTeachers.values().stream()
.flatMap(teacher -> postRepository.findByTeacherOrderByCreatedAtDesc(teacher).stream())
.sorted((left, right) -> right.getCreatedAt().compareTo(left.getCreatedAt()))
.map(this::toPostMap)
.toList();
}
/**
* Crea una nueva noticia (post) en el muro del profesor.
*
* @param teacher Usuario profesor.
* @param title Título de la noticia.
* @param content Contenido de la noticia.
* @return Datos de la noticia creada.
*/
@Transactional
public Map<String, Object> createPost(User teacher, String title, String content) {
if (!isTeacher(teacher)) {
throw new IllegalArgumentException("Solo un profesor puede publicar noticias");
}
String cleanTitle = title == null ? "" : title.trim();
String cleanContent = content == null ? "" : content.trim();
if (cleanTitle.isBlank() || cleanContent.isBlank()) {
throw new IllegalArgumentException("El titulo y el contenido son obligatorios");
}
ClassPost post = ClassPost.builder()
.teacher(teacher)
.title(cleanTitle)
.content(cleanContent)
.build();
ClassPost saved = postRepository.save(post);
notifyStudentsAboutPost(saved);
return toPostMap(saved);
}
/**
* Solicita y paga una hora extra de clase.
*
* @param enrollmentId ID de la inscripción actual.
* @param currentUser Usuario alumno.
* @return Datos del nuevo proceso de pago.
*/
@Transactional
public Map<String, Object> requestExtraHour(UUID enrollmentId, User currentUser) throws Exception {
ClassEnrollment enrollment = getEnrollment(enrollmentId);
ensureStudent(enrollment, currentUser);
return checkout(enrollment.getTeacher().getId(), currentUser);
}
/**
* Solicita el reembolso de una clase pagada y no completada.
*
* @param enrollmentId ID de la clase.
* @param currentUser Usuario alumno.
* @return Datos actualizados tras el reembolso.
*/
@Transactional
public Map<String, Object> requestRefund(UUID enrollmentId, User currentUser) {
ClassEnrollment enrollment = getEnrollment(enrollmentId);
ensureStudent(enrollment, currentUser);
if (!isPaidState(enrollment.getPaymentStatus())) {
throw new IllegalArgumentException("Solo puedes pedir reembolso de una clase pagada");
}
if (STATUS_COMPLETED.equalsIgnoreCase(nullSafeStatus(enrollment))) {
throw new IllegalArgumentException("No puedes pedir reembolso de una clase ya completada");
}
String paymentIntentId = enrollment.getStripePaymentIntentId();
if (stripeService.isConfigured() && paymentIntentId != null && !paymentIntentId.startsWith("free_class_")) {
try {
stripeService.refundPaymentIntent(paymentIntentId);
} catch (StripeException e) {
log.warn("Error solicitando refund Stripe para {}: {}", enrollmentId, e.getMessage());
throw new IllegalArgumentException("No se pudo solicitar el reembolso");
}
}
enrollment.setPaymentStatus(STATUS_REFUNDED);
enrollmentRepository.save(enrollment);
BigDecimal amount = enrollment.getUnitPrice() != null ? enrollment.getUnitPrice() : BigDecimal.ZERO;
odooService.processClassRefund(
enrollment.getStudent().getEmail(),
enrollment.getStudent().getName(),
enrollment.getTeacher().getName(),
amount.doubleValue());
notificationService.create(
enrollment.getTeacher(),
"class_refund",
"Se ha reembolsado una clase",
enrollment.getStudent().getName() + " ha solicitado y recibido el reembolso de una hora.",
Map.of("enrollmentId", enrollment.getId().toString()));
return toEnrollmentMap(enrollment);
}
/**
* Agenda una sesión específica (fecha, hora, duración) para una clase.
*
* @param enrollmentId ID de la clase.
* @param currentUser Usuario profesor.
* @param body Datos de la sesión (scheduledAt, durationMinutes, meetingUrl, notes).
* @return Datos de la sesión creada.
*/
@Transactional
public Map<String, Object> createSession(UUID enrollmentId, User currentUser, Map<String, String> body) {
ClassEnrollment enrollment = getEnrollment(enrollmentId);
ensureTeacherOwner(enrollment.getTeacher(), currentUser);
if (!isSchedulableState(enrollment.getPaymentStatus())) {
throw new IllegalArgumentException("La solicitud debe estar aceptada antes de agendarla");
}
String scheduledAtRaw = body.get("scheduledAt");
if (scheduledAtRaw == null || scheduledAtRaw.isBlank()) {
throw new IllegalArgumentException("La fecha y hora son obligatorias");
}
LocalDateTime scheduledAt = LocalDateTime.parse(scheduledAtRaw);
Integer duration = parseDuration(body.get("durationMinutes"));
String meetingUrl = normalize(body.get("meetingUrl"));
String notes = normalize(body.get("notes"));
ClassSession session = ClassSession.builder()
.enrollment(enrollment)
.teacher(enrollment.getTeacher())
.student(enrollment.getStudent())
.scheduledAt(scheduledAt)
.durationMinutes(duration)
.meetingUrl(meetingUrl)
.notes(notes)
.status(STATUS_SCHEDULED)
.build();
session = sessionRepository.save(session);
enrollment.setPaymentStatus(STATUS_SCHEDULED);
enrollment.setClassLink(meetingUrl);
enrollmentRepository.save(enrollment);
notifyScheduling(enrollment, session);
return toSessionMap(session);
}
/**
* Actualiza los datos de una sesión agendada.
*
* @param sessionId ID de la sesión.
* @param currentUser Usuario profesor.
* @param body Datos a actualizar.
* @return Datos actualizados de la sesión.
*/
@Transactional
public Map<String, Object> updateSession(UUID sessionId, User currentUser, Map<String, String> body) {
ClassSession session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Sesion no encontrada"));
ensureTeacherOwner(session.getTeacher(), currentUser);
String nextStatus = normalize(body.get("status"));
if (body.containsKey("scheduledAt") && body.get("scheduledAt") != null && !body.get("scheduledAt").isBlank()) {
session.setScheduledAt(LocalDateTime.parse(body.get("scheduledAt")));
}
if (body.containsKey("durationMinutes")) {
session.setDurationMinutes(parseDuration(body.get("durationMinutes")));
}
if (body.containsKey("meetingUrl")) {
session.setMeetingUrl(normalize(body.get("meetingUrl")));
}
if (body.containsKey("notes")) {
session.setNotes(normalize(body.get("notes")));
}
if (nextStatus != null) {
session.setStatus(nextStatus.toLowerCase());
}
session = sessionRepository.save(session);
syncEnrollmentWithSessions(session.getEnrollment(), nextStatus);
notifySessionUpdate(session);
return toSessionMap(session);
}
/**
* Registra un reporte contra un profesor.
*
* @param teacherId ID del profesor reportado.
* @param reporter Usuario que reporta.
* @param body Contiene "reason" y "details".
* @return ID del reporte creado.
*/
@Transactional
public Map<String, Object> createTeacherReport(UUID teacherId, User reporter, Map<String, String> body) {
User teacher = userRepository.findById(teacherId)
.orElseThrow(() -> new IllegalArgumentException("Profesor no encontrado"));
if (!isTeacher(teacher)) {
throw new IllegalArgumentException("Solo puedes reportar a un profesor");
}
if (reporter == null) {
throw new IllegalArgumentException("Sesion no valida");
}
if (teacher.getId().equals(reporter.getId())) {
throw new IllegalArgumentException("No puedes reportarte a ti mismo");
}
String reason = normalize(body.get("reason"));
if (reason == null) {
throw new IllegalArgumentException("Debes indicar un motivo");
}
TeacherReport report = TeacherReport.builder()
.teacher(teacher)
.reporter(reporter)
.reason(reason)
.details(normalize(body.get("details")))
.build();
report = teacherReportRepository.save(report);
odooService.createModerationReport(
"teacher",
reporter.getName(),
reporter.getEmail(),
teacher.getName(),
"teacher:" + teacher.getId(),
report.getReason(),
report.getDetails()
);
return Map.of(
"id", report.getId().toString(),
"status", report.getStatus()
);
}
/** Valida que el usuario sea el profesor dueño de la clase. */
public void ensureTeacherOwner(User teacher, User currentUser) {
if (currentUser == null || !teacher.getId().equals(currentUser.getId())) {
throw new IllegalArgumentException("No tienes permiso para gestionar esta clase");
}
}
private boolean isTeacher(User user) {
return user != null && (user.getRole() == Role.PROFESOR_PARTICULAR || user.getRole() == Role.PROFESOR_CERTIFICADO);
}
private boolean isStudentVisible(ClassEnrollment enrollment) {
return true;
}
private boolean isPaidState(String status) {
String normalized = nullSafeStatus(status);
return STATUS_PAID.equalsIgnoreCase(normalized)
|| STATUS_ACCEPTED.equalsIgnoreCase(normalized)
|| STATUS_SCHEDULED.equalsIgnoreCase(normalized)
|| STATUS_COMPLETED.equalsIgnoreCase(normalized)
|| STATUS_REFUND_REQUESTED.equalsIgnoreCase(normalized);
}
private void ensureChatEnabled(ClassEnrollment enrollment) {
if (!isChatOpenState(enrollment.getPaymentStatus())) {
throw new IllegalArgumentException("La solicitud todavia no ha sido aceptada");
}
if (STATUS_REFUNDED.equalsIgnoreCase(nullSafeStatus(enrollment))) {
throw new IllegalArgumentException("Esta clase ya ha sido reembolsada");
}
}
private void ensureParticipant(ClassEnrollment enrollment, User currentUser) {
if (currentUser == null) {
throw new IllegalArgumentException("Sesion no valida");
}
boolean allowed = enrollment.getTeacher().getId().equals(currentUser.getId())
|| enrollment.getStudent().getId().equals(currentUser.getId());
if (!allowed) {
throw new IllegalArgumentException("No tienes acceso a esta clase");
}
}
private void ensureStudent(ClassEnrollment enrollment, User currentUser) {
if (currentUser == null || !enrollment.getStudent().getId().equals(currentUser.getId())) {
throw new IllegalArgumentException("Solo el alumno puede realizar esta accion");
}
}
private ClassEnrollment getEnrollment(UUID enrollmentId) {
return enrollmentRepository.findById(enrollmentId)
.orElseThrow(() -> new IllegalArgumentException("Clase no encontrada"));
}
private ClassEnrollment buildEnrollment(User teacher, User student) {
return ClassEnrollment.builder()
.teacher(teacher)
.student(student)
.teacherName(teacher.getName())
.studentName(student.getName())
.instrument(teacher.getInstrument())
.city(teacher.getCity())
.province(teacher.getProvince())
.modality(teacher.getClassModality())
.unitPrice(teacher.getPricePerHour())
.paymentStatus(STATUS_PENDING)
.hoursPurchased(1)
.hoursUsed(0)
.build();
}
private void markPaidEnrollment(ClassEnrollment enrollment, String paymentIntentId, String receiptNumber) {
enrollment.setPaymentStatus(STATUS_PAID);
enrollment.setStripePaymentIntentId(paymentIntentId);
enrollment.setPaymentReceiptNumber(receiptNumber);
enrollmentRepository.save(enrollment);
odooService.processClassPayment(
enrollment.getStudent().getEmail(),
enrollment.getStudent().getName(),
enrollment.getTeacher().getName(),
enrollment.getInstrument(),
enrollment.getModality(),
enrollment.getCity(),
enrollment.getUnitPrice() != null ? enrollment.getUnitPrice().doubleValue() : 0.0);
}
private Map<String, Object> checkoutResponse(ClassEnrollment enrollment, String clientSecret, boolean devMode) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("enrollmentId", enrollment.getId().toString());
response.put("paymentIntentId", enrollment.getStripePaymentIntentId());
response.put("clientSecret", clientSecret);
response.put("devMode", devMode);
return response;
}
private void syncEnrollmentWithSessions(ClassEnrollment enrollment, String requestedStatus) {
List<ClassSession> sessions = sessionRepository.findByEnrollmentOrderByScheduledAtAsc(enrollment);
boolean hasCompleted = sessions.stream().anyMatch(session -> STATUS_COMPLETED.equalsIgnoreCase(session.getStatus()));
boolean hasScheduled = sessions.stream().anyMatch(session -> STATUS_SCHEDULED.equalsIgnoreCase(session.getStatus()));
if (hasCompleted) {
enrollment.setHoursUsed(Math.max(1, nullToZero(enrollment.getHoursPurchased())));
enrollment.setPaymentStatus(STATUS_COMPLETED);
} else if (hasScheduled) {
enrollment.setPaymentStatus(STATUS_SCHEDULED);
} else if (STATUS_CANCELLED.equalsIgnoreCase(requestedStatus) && STATUS_SCHEDULED.equalsIgnoreCase(nullSafeStatus(enrollment))) {
enrollment.setPaymentStatus(STATUS_ACCEPTED);
}
enrollmentRepository.save(enrollment);
}
private void notifyRequestCreated(ClassEnrollment enrollment) {
String title = "Nueva solicitud de clase";
String body = enrollment.getStudent().getName() + " te ha enviado una solicitud para clases.";
notificationService.create(
enrollment.getTeacher(),
"class_request_created",
title,
body,
Map.of(
"enrollmentId", enrollment.getId().toString(),
"studentId", enrollment.getStudent().getId().toString()
));
emailService.sendClassNotificationEmail(
enrollment.getTeacher().getEmail(),
enrollment.getTeacher().getName(),
title,
body
);
}
private void notifyStatusChange(ClassEnrollment enrollment, String status, User actor, String reason) {
User recipient = STATUS_CANCELLED.equalsIgnoreCase(status) && actor != null
&& enrollment.getStudent().getId().equals(actor.getId())
? enrollment.getTeacher()
: enrollment.getStudent();
String title;
String body;
String notificationType;
switch (status) {
case STATUS_ACCEPTED -> {
title = "Solicitud aceptada";
body = enrollment.getTeacher().getName() + " ha aceptado tu solicitud de clase.";
notificationType = "class_request_accepted";
}
case STATUS_REJECTED -> {
title = "Solicitud rechazada";
body = enrollment.getTeacher().getName() + " ha rechazado tu solicitud de clase."
+ (reason == null || reason.isBlank() ? "" : " Motivo: " + reason);
notificationType = "class_request_rejected";
}
case STATUS_COMPLETED -> {
title = "Clase completada";
body = "Tu clase con " + enrollment.getTeacher().getName() + " se ha marcado como completada.";
notificationType = "class_request_completed";
}
case STATUS_CANCELLED -> {
title = "Solicitud cancelada";
body = "La solicitud con " + enrollment.getTeacher().getName() + " se ha cancelado.";
notificationType = "class_request_cancelled";
}
default -> {
return;
}
}
notificationService.create(
recipient,
notificationType,
title,
body,
Map.of(
"enrollmentId", enrollment.getId().toString(),
"teacherId", enrollment.getTeacher().getId().toString()
));
emailService.sendClassNotificationEmail(
recipient.getEmail(),
recipient.getName(),
title,
body
);
}
private void notifyStudentsAboutPost(ClassPost post) {
Map<UUID, User> recipients = new LinkedHashMap<>();
enrollmentRepository.findByTeacherOrderByCreatedAtDesc(post.getTeacher()).stream()
.filter(enrollment -> {
String status = nullSafeStatus(enrollment);
return STATUS_ACCEPTED.equalsIgnoreCase(status)
|| STATUS_PAID.equalsIgnoreCase(status)
|| STATUS_SCHEDULED.equalsIgnoreCase(status)
|| STATUS_COMPLETED.equalsIgnoreCase(status);
})
.forEach(enrollment -> recipients.putIfAbsent(enrollment.getStudent().getId(), enrollment.getStudent()));
if (recipients.isEmpty()) {
return;
}
String title = "Nueva noticia del profesor";
String body = post.getTeacher().getName() + " ha publicado una noticia: " + post.getTitle();
for (User student : recipients.values()) {
notificationService.create(
student,
"class_post_published",
title,
body,
Map.of(
"teacherId", post.getTeacher().getId().toString(),
"postId", post.getId().toString()
));
emailService.sendClassNotificationEmail(
student.getEmail(),
student.getName(),
title,
body
);
}
}
private void notifyTeacherAboutStudentMessage(ClassEnrollment enrollment, User sender, String content) {
if (enrollment == null || sender == null || enrollment.getStudent() == null || enrollment.getTeacher() == null) {
return;
}
if (!enrollment.getStudent().getId().equals(sender.getId())) {
return;
}
User teacher = enrollment.getTeacher();
String preview = content.length() > 140 ? content.substring(0, 140) + "..." : content;
String title = "Nuevo mensaje del alumno";
String body = sender.getName() + " te ha enviado un mensaje: " + preview;
notificationService.create(
teacher,
"class_chat_message",
title,
body,
Map.of(
"enrollmentId", enrollment.getId().toString(),
"studentId", enrollment.getStudent().getId().toString(),
"teacherId", teacher.getId().toString()
));
emailService.sendClassNotificationEmail(
teacher.getEmail(),
teacher.getName(),
title,
body
);
}
private boolean isAllowedStatus(String status) {
String normalized = nullSafeStatus(status);
return STATUS_PENDING.equalsIgnoreCase(normalized)
|| STATUS_ACCEPTED.equalsIgnoreCase(normalized)
|| STATUS_REJECTED.equalsIgnoreCase(normalized)
|| STATUS_COMPLETED.equalsIgnoreCase(normalized)
|| STATUS_CANCELLED.equalsIgnoreCase(normalized)
|| STATUS_PAID.equalsIgnoreCase(normalized)
|| STATUS_SCHEDULED.equalsIgnoreCase(normalized)
|| STATUS_REFUND_REQUESTED.equalsIgnoreCase(normalized)
|| STATUS_REFUNDED.equalsIgnoreCase(normalized);
}
private boolean isActiveRequestStatus(String status) {
String normalized = nullSafeStatus(status);
return STATUS_PENDING.equalsIgnoreCase(normalized)
|| STATUS_ACCEPTED.equalsIgnoreCase(normalized)
|| STATUS_SCHEDULED.equalsIgnoreCase(normalized)
|| STATUS_PAID.equalsIgnoreCase(normalized);
}
private boolean isChatOpenState(String status) {
String normalized = nullSafeStatus(status);
return STATUS_ACCEPTED.equalsIgnoreCase(normalized)
|| STATUS_PAID.equalsIgnoreCase(normalized)
|| STATUS_SCHEDULED.equalsIgnoreCase(normalized)
|| STATUS_COMPLETED.equalsIgnoreCase(normalized)
|| STATUS_REFUND_REQUESTED.equalsIgnoreCase(normalized);
}
private boolean isSchedulableState(String status) {
String normalized = nullSafeStatus(status);
return STATUS_ACCEPTED.equalsIgnoreCase(normalized)
|| STATUS_PAID.equalsIgnoreCase(normalized)
|| STATUS_SCHEDULED.equalsIgnoreCase(normalized);
}
private void notifyScheduling(ClassEnrollment enrollment, ClassSession session) {
String title = "Clase agendada";
String body = enrollment.getTeacher().getName() + " te ha propuesto una clase para el "
+ session.getScheduledAt() + ".";
notificationService.create(
enrollment.getStudent(),
"class_scheduled",
title,
body,
Map.of(
"enrollmentId", enrollment.getId().toString(),
"sessionId", session.getId().toString()
));
emailService.sendClassNotificationEmail(
enrollment.getStudent().getEmail(),
enrollment.getStudent().getName(),
title,
body
);
}
private void notifySessionUpdate(ClassSession session) {
String title = STATUS_COMPLETED.equalsIgnoreCase(session.getStatus())
? "Clase completada"
: "Actualizacion de tu clase";
String body = STATUS_COMPLETED.equalsIgnoreCase(session.getStatus())
? "Tu clase con " + session.getTeacher().getName() + " ha sido marcada como completada."
: "Tu profesor ha actualizado la sesion del " + session.getScheduledAt() + ".";
notificationService.create(
session.getStudent(),
"class_session_update",
title,
body,
Map.of(
"enrollmentId", session.getEnrollment().getId().toString(),
"sessionId", session.getId().toString()
));
emailService.sendClassNotificationEmail(
session.getStudent().getEmail(),
session.getStudent().getName(),
title,
body
);
}
private Map<String, Object> toEnrollmentMap(ClassEnrollment enrollment) {
ClassSession nextSession = sessionRepository
.findFirstByEnrollmentAndStatusOrderByScheduledAtAsc(enrollment, STATUS_SCHEDULED)
.orElse(null);
int hoursPurchased = nullToZero(enrollment.getHoursPurchased());
int hoursUsed = nullToZero(enrollment.getHoursUsed());
int hoursRemaining = Math.max(hoursPurchased - hoursUsed, 0);
Map<String, Object> result = new LinkedHashMap<>();
result.put("id", value(enrollment.getId()));
result.put("teacherId", value(enrollment.getTeacher() != null ? enrollment.getTeacher().getId() : null));
result.put("teacherName", safeString(enrollment.getTeacherName()));
result.put("studentId", value(enrollment.getStudent() != null ? enrollment.getStudent().getId() : null));
result.put("studentName", safeString(enrollment.getStudentName()));
result.put("instrument", safeNullable(enrollment.getInstrument()));
result.put("city", safeString(enrollment.getCity()));
result.put("province", safeString(enrollment.getProvince()));
result.put("modality", safeString(enrollment.getModality()));
result.put("requestedModality", safeString(enrollment.getRequestedModality()));
result.put("unitPrice", enrollment.getUnitPrice() != null ? enrollment.getUnitPrice() : BigDecimal.ZERO);
result.put("paymentStatus", safeString(enrollment.getPaymentStatus()));
result.put("requestMessage", safeString(enrollment.getRequestMessage()));
result.put("requestAvailability", safeString(enrollment.getRequestAvailability()));
result.put("classLink", safeString(enrollment.getClassLink()));
result.put("createdAt", enrollment.getCreatedAt() != null ? enrollment.getCreatedAt().toString() : "");
result.put("teacherRole", enrollment.getTeacher() != null && enrollment.getTeacher().getRole() != null
? enrollment.getTeacher().getRole().name().toLowerCase()
: "");
result.put("hoursPurchased", hoursPurchased);
result.put("hoursUsed", hoursUsed);
result.put("hoursRemaining", hoursRemaining);
result.put("canRefund", false);
result.put("canChat", isChatOpenState(enrollment.getPaymentStatus()) && !STATUS_REFUNDED.equalsIgnoreCase(nullSafeStatus(enrollment)));
result.put("nextSession", nextSession != null ? toSessionMap(nextSession) : null);
return result;
}
private Map<String, Object> toMessageMap(ClassMessage message) {
return Map.of(
"id", message.getId().toString(),
"enrollmentId", message.getEnrollment().getId().toString(),
"senderId", message.getSender().getId().toString(),
"senderName", message.getSender().getName(),
"content", message.getContent(),
"createdAt", message.getCreatedAt().toString()
);
}
private Map<String, Object> toPostMap(ClassPost post) {
return Map.of(
"id", post.getId().toString(),
"teacherId", post.getTeacher().getId().toString(),
"teacherName", post.getTeacher().getName(),
"title", post.getTitle(),
"content", post.getContent(),
"createdAt", post.getCreatedAt().toString()
);
}
private Map<String, Object> toSessionMap(ClassSession session) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("id", session.getId().toString());
result.put("enrollmentId", session.getEnrollment().getId().toString());
result.put("teacherId", session.getTeacher().getId().toString());
result.put("teacherName", session.getTeacher().getName());
result.put("studentId", session.getStudent().getId().toString());
result.put("studentName", session.getStudent().getName());
result.put("scheduledAt", session.getScheduledAt().toString());
result.put("durationMinutes", session.getDurationMinutes());
result.put("status", safeString(session.getStatus()));
result.put("meetingUrl", safeString(session.getMeetingUrl()));
result.put("notes", safeString(session.getNotes()));
return result;
}
private int nullToZero(Integer value) {
return value != null ? value : 0;
}
private String normalize(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private Integer parseDuration(String raw) {
if (raw == null || raw.isBlank()) {
return 60;
}
try {
int parsed = Integer.parseInt(raw.trim());
return parsed > 0 ? parsed : 60;
} catch (NumberFormatException e) {
return 60;
}
}
private String nullSafeStatus(ClassEnrollment enrollment) {
return nullSafeStatus(enrollment != null ? enrollment.getPaymentStatus() : null);
}
private String nullSafeStatus(String status) {
return status == null ? "" : status.trim().toLowerCase();
}
private String blankToDefault(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
private String safeString(String value) {
return value != null ? value : "";
}
private Object safeNullable(Object value) {
return value;
}
private String value(UUID id) {
return id != null ? id.toString() : "";
}
}