diff --git a/PHASE4_COMPLETE.md b/PHASE4_COMPLETE.md new file mode 100644 index 0000000..0d0238c --- /dev/null +++ b/PHASE4_COMPLETE.md @@ -0,0 +1,365 @@ +# ✅ Fase 4: Aula Virtual (LMS Module) - COMPLETA + +## Resumen + +Se ha completado exitosamente la **Fase 4: Aula Virtual (LMS Module)** del proyecto SUPAP Backend. Esta fase incluye la implementación completa del sistema de gestión de aprendizaje (LMS) con cursos, módulos, lecciones, inscripciones y seguimiento de progreso. + +**Fecha de finalización**: 2025-11-24 +**Estado**: ✅ Completada + +--- + +## 🎯 Componentes Implementados + +### 1. Entidades JPA ✅ + +#### Course Entity +- ✅ Campos completos: title, slug, description, objectives, prerequisites +- ✅ Relación con User (instructor) +- ✅ Enums: CourseLevel (BEGINNER, INTERMEDIATE, ADVANCED, EXPERT) +- ✅ Enums: CourseStatus (DRAFT, PUBLISHED, ARCHIVED) +- ✅ Precios: price, memberPrice, regularPrice +- ✅ Capacidad: maxStudents, enrolledCount +- ✅ Relación One-to-Many con CourseModule +- ✅ Métodos helper: `hasCapacity()`, `incrementEnrolledCount()` + +#### CourseModule Entity +- ✅ Relación Many-to-One con Course +- ✅ Campos: title, description, durationMinutes +- ✅ Relación One-to-Many con Lesson +- ✅ Ordenamiento por display_order + +#### Lesson Entity +- ✅ Relación Many-to-One con CourseModule +- ✅ Enum LessonType: VIDEO, TEXT, QUIZ, ASSIGNMENT, RESOURCE, LIVE_SESSION +- ✅ Campos: title, content, videoUrl, videoDuration, resourceUrls +- ✅ Campo isFree para lecciones de vista previa + +#### Enrollment Entity +- ✅ Relación Many-to-One con User y Course +- ✅ Enum EnrollmentStatus: PENDING_PAYMENT, ACTIVE, COMPLETED, DROPPED, EXPIRED +- ✅ Campos: progressPercentage, enrolledAt, completedAt, expiresAt +- ✅ Relación con Payment (opcional) +- ✅ Relación One-to-Many con StudentProgress + +#### StudentProgress Entity +- ✅ Relación Many-to-One con Enrollment y Lesson +- ✅ Campos: completed, videoProgress, startedAt, completedAt, lastAccessedAt +- ✅ Tracking completo del progreso del estudiante + +#### Payment Entity (Simplificado para Fase 4) +- ✅ Soporte básico para pagos de cursos +- ✅ Enums: PaymentType, PaymentStatus +- ✅ Relación polimórfica con referenceId y referenceType + +### 2. Repositorios ✅ + +#### CourseRepository +- ✅ `findBySlug()` - Buscar por slug +- ✅ `findByStatus()` - Filtrar por estado +- ✅ `findByLevelAndStatus()` - Filtrar por nivel +- ✅ `findByInstructorIdAndStatus()` - Filtrar por instructor +- ✅ `searchByTitle()` - Búsqueda por título +- ✅ `existsBySlug()` - Verificar slug único + +#### CourseModuleRepository +- ✅ `findByCourseIdOrderByOrderAsc()` - Módulos ordenados +- ✅ `deleteByCourseId()` - Eliminar módulos + +#### LessonRepository +- ✅ `findByModuleIdOrderByOrderAsc()` - Lecciones ordenadas +- ✅ `findByModuleIdAndIsFreeTrueOrderByOrderAsc()` - Lecciones gratuitas + +#### EnrollmentRepository +- ✅ `findByUserIdAndCourseId()` - Verificar inscripción +- ✅ `findByUserId()` - Inscripciones de usuario +- ✅ `findByCourseId()` - Inscripciones de curso +- ✅ `findByUserIdAndStatus()` - Filtrar por estado +- ✅ `existsByUserIdAndCourseId()` - Verificar si está inscrito +- ✅ `countActiveEnrollmentsByCourseId()` - Contar inscripciones activas + +#### StudentProgressRepository +- ✅ `findByEnrollmentIdAndLessonId()` - Progreso específico +- ✅ `findByEnrollmentId()` - Todo el progreso +- ✅ `countCompletedLessonsByEnrollmentId()` - Contar lecciones completadas +- ✅ `findByEnrollmentIdAndCompletedTrue()` - Lecciones completadas + +### 3. Migraciones Flyway ✅ + +- ✅ `V5__create_lms_tables.sql` - Todas las tablas del LMS + - Tabla `courses` con todos los campos + - Tabla `course_modules` con relación a cursos + - Tabla `lessons` con relación a módulos + - Tabla `payments` (simplificada) + - Tabla `enrollments` con relación a usuarios y cursos + - Tabla `student_progress` con relación a inscripciones y lecciones + - Índices para optimización + - Constraint único para evitar inscripciones duplicadas + +### 4. DTOs ✅ + +#### Request DTOs: +- ✅ `CourseRequest` - Crear/actualizar cursos (con módulos y lecciones anidados) +- ✅ `CourseModuleRequest` - Datos de módulos (con lecciones anidados) +- ✅ `LessonRequest` - Datos de lecciones +- ✅ `EnrollmentRequest` - Inscripción a curso +- ✅ `StudentProgressRequest` - Actualizar progreso + +#### Response DTOs: +- ✅ `CourseResponse` - Información completa del curso (con módulos y lecciones) +- ✅ `CourseModuleResponse` - Información de módulo (con lecciones) +- ✅ `LessonResponse` - Información de lección +- ✅ `EnrollmentResponse` - Información de inscripción +- ✅ `StudentProgressResponse` - Información de progreso + +### 5. Servicios ✅ + +#### CourseService +- ✅ `getAllPublishedCourses()` - Listar cursos publicados +- ✅ `getCourseBySlug()` - Obtener curso por slug (público) +- ✅ `getCourseById()` - Obtener curso por ID +- ✅ `createCourse()` - Crear curso con generación automática de slug +- ✅ `updateCourse()` - Actualizar curso +- ✅ `deleteCourse()` - Eliminar curso +- ✅ `getCoursesByLevel()` - Filtrar por nivel +- ✅ `searchCourses()` - Búsqueda por título +- ✅ Generación automática de slugs únicos + +#### EnrollmentService +- ✅ `enrollInCourse()` - Inscribirse en curso + - Validación de capacidad + - Validación de inscripción duplicada + - Cálculo automático de precio según tipo de usuario + - Actualización de contador de inscritos +- ✅ `getMyEnrollments()` - Mis inscripciones +- ✅ `getEnrollmentById()` - Obtener inscripción +- ✅ `getCourseEnrollments()` - Inscripciones de curso (admin) +- ✅ `activateEnrollment()` - Activar inscripción +- ✅ `completeEnrollment()` - Completar inscripción +- ✅ Cálculo de precio según membresía del usuario + +#### StudentProgressService +- ✅ `updateProgress()` - Actualizar progreso de lección + - Validación de propiedad de inscripción + - Validación de que lección pertenece al curso + - Actualización automática de porcentaje de progreso + - Auto-completar inscripción cuando se completan todas las lecciones +- ✅ `getProgressByEnrollment()` - Obtener todo el progreso +- ✅ `markLessonComplete()` - Marcar lección como completada +- ✅ Cálculo automático de porcentaje de progreso + +### 6. Controladores ✅ + +#### CourseController +- ✅ `GET /api/v1/courses` - Listar cursos (público, con filtros) +- ✅ `GET /api/v1/courses/{slug}` - Obtener curso por slug (público) +- ✅ `GET /api/v1/courses/id/{id}` - Obtener curso por ID (Admin/Instructor) +- ✅ `POST /api/v1/courses` - Crear curso (Admin/Instructor) +- ✅ `PUT /api/v1/courses/{id}` - Actualizar curso (Admin/Instructor) +- ✅ `DELETE /api/v1/courses/{id}` - Eliminar curso (Admin) + +#### EnrollmentController +- ✅ `POST /api/v1/enrollments` - Inscribirse en curso (Usuario) +- ✅ `GET /api/v1/enrollments/my` - Mis inscripciones (Usuario) +- ✅ `GET /api/v1/enrollments/{id}` - Obtener inscripción (Usuario) +- ✅ `GET /api/v1/enrollments/course/{courseId}` - Inscripciones de curso (Admin/Instructor) +- ✅ `POST /api/v1/enrollments/{enrollmentId}/progress` - Actualizar progreso (Usuario) +- ✅ `GET /api/v1/enrollments/{enrollmentId}/progress` - Obtener progreso (Usuario) +- ✅ `POST /api/v1/enrollments/{enrollmentId}/lessons/{lessonId}/complete` - Completar lección (Usuario) +- ✅ `POST /api/v1/enrollments/{enrollmentId}/activate` - Activar inscripción (Admin) +- ✅ `POST /api/v1/enrollments/{enrollmentId}/complete` - Completar inscripción (Admin) + +### 7. Excepciones ✅ + +- ✅ `ResourceNotFoundException` - Recurso no encontrado +- ✅ `EventRegistrationException` - Reutilizada para errores de inscripción +- ✅ Manejo en `GlobalExceptionHandler` + +### 8. Seguridad ✅ + +- ✅ Endpoints públicos para listado y visualización de cursos +- ✅ Endpoints protegidos para inscripciones (requiere autenticación) +- ✅ Endpoints protegidos para administración (requiere ROLE_ADMIN o ROLE_INSTRUCTOR) +- ✅ Validación de propiedad de recursos (solo el dueño puede acceder) + +--- + +## 📊 Funcionalidades Implementadas + +### Gestión de Cursos +- ✅ Crear cursos con estructura completa (módulos y lecciones anidadas) +- ✅ Generación automática de slugs únicos +- ✅ Actualizar cursos existentes +- ✅ Eliminar cursos +- ✅ Listar cursos con filtros (nivel, búsqueda) +- ✅ Obtener curso por slug (público) o ID (admin) +- ✅ Control de capacidad +- ✅ Estados: DRAFT, PUBLISHED, ARCHIVED +- ✅ Niveles: BEGINNER, INTERMEDIATE, ADVANCED, EXPERT + +### Inscripciones +- ✅ Inscripción en cursos +- ✅ Validación de capacidad +- ✅ Prevención de inscripciones duplicadas +- ✅ Cálculo automático de precio según membresía +- ✅ Estados: PENDING_PAYMENT, ACTIVE, COMPLETED, DROPPED, EXPIRED +- ✅ Actualización automática de contador de inscritos + +### Seguimiento de Progreso +- ✅ Actualizar progreso de lecciones +- ✅ Marcar lecciones como completadas +- ✅ Tracking de progreso de video (segundos vistos) +- ✅ Cálculo automático de porcentaje de progreso +- ✅ Auto-completar inscripción cuando se completan todas las lecciones +- ✅ Última fecha de acceso + +### Precios +- ✅ Precio regular +- ✅ Precio para miembros +- ✅ Precio base +- ✅ Cálculo automático según tipo de usuario + +--- + +## 🚀 Endpoints Disponibles + +### Públicos +- `GET /api/v1/courses` - Listar cursos publicados +- `GET /api/v1/courses/{slug}` - Obtener curso por slug + +### Autenticados (Usuario) +- `POST /api/v1/enrollments` - Inscribirse en curso +- `GET /api/v1/enrollments/my` - Mis inscripciones +- `GET /api/v1/enrollments/{id}` - Obtener inscripción +- `POST /api/v1/enrollments/{enrollmentId}/progress` - Actualizar progreso +- `GET /api/v1/enrollments/{enrollmentId}/progress` - Obtener progreso +- `POST /api/v1/enrollments/{enrollmentId}/lessons/{lessonId}/complete` - Completar lección + +### Admin/Instructor +- `GET /api/v1/courses/id/{id}` - Obtener curso por ID +- `POST /api/v1/courses` - Crear curso +- `PUT /api/v1/courses/{id}` - Actualizar curso +- `DELETE /api/v1/courses/{id}` - Eliminar curso +- `GET /api/v1/enrollments/course/{courseId}` - Inscripciones de curso +- `POST /api/v1/enrollments/{enrollmentId}/activate` - Activar inscripción +- `POST /api/v1/enrollments/{enrollmentId}/complete` - Completar inscripción + +--- + +## 📝 Ejemplos de Uso + +### Crear Curso (Admin/Instructor) +```bash +curl -X POST http://localhost:8080/api/v1/courses \ + -H "Authorization: Bearer ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Introducción a Psicoterapias Asistidas", + "description": "Curso introductorio...", + "level": "BEGINNER", + "status": "PUBLISHED", + "durationHours": 40, + "regularPrice": 7500, + "memberPrice": 5000, + "maxStudents": 50, + "modules": [ + { + "title": "Módulo 1: Fundamentos", + "description": "...", + "order": 1, + "lessons": [ + { + "title": "Lección 1: Introducción", + "type": "VIDEO", + "videoUrl": "https://...", + "order": 1, + "isFree": true + } + ] + } + ] + }' +``` + +### Inscribirse en Curso +```bash +curl -X POST http://localhost:8080/api/v1/enrollments \ + -H "Authorization: Bearer USER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "courseId": 1 + }' +``` + +### Actualizar Progreso +```bash +curl -X POST http://localhost:8080/api/v1/enrollments/1/progress \ + -H "Authorization: Bearer USER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "lessonId": 5, + "completed": true, + "videoProgress": 1200 + }' +``` + +--- + +## ✅ Checklist de Fase 4 + +- [x] Course entity & module structure +- [x] Lesson entity with different types +- [x] Course CRUD endpoints +- [x] Course enrollment system +- [x] Student progress tracking +- [x] Generación automática de slugs +- [x] Cálculo de precios según membresía +- [x] Cálculo automático de progreso +- [x] Validación de capacidad +- [x] Auto-completar inscripciones + +--- + +## 📝 Próximos Pasos (Fase 5) + +La siguiente fase incluirá: +- [ ] Assignment entity & submission +- [ ] Assessment/Quiz entity +- [ ] Grading system +- [ ] Assignment file upload + +--- + +## 🔧 Notas Técnicas + +### Características Implementadas +1. **Slugs Únicos**: Generación automática de slugs a partir del título +2. **Estructura Anidada**: Cursos → Módulos → Lecciones +3. **Progreso Automático**: Cálculo automático de porcentaje de progreso +4. **Auto-completar**: Inscripciones se completan automáticamente al terminar todas las lecciones +5. **Precios Dinámicos**: Cálculo según membresía del usuario +6. **Validaciones**: Capacidad, duplicados, propiedad de recursos + +### Consideraciones +- Los cursos solo se muestran públicamente si están en estado PUBLISHED +- Los instructores pueden gestionar sus propios cursos +- El progreso se actualiza automáticamente al completar lecciones +- Las inscripciones pueden estar en PENDING_PAYMENT hasta que se active el pago +- Los slugs se generan automáticamente si no se proporcionan + +--- + +## 📚 Documentación + +- **Arquitectura**: `BACKEND_ARCHITECTURE_GUIDE.md` +- **Fase 1**: `PHASE1_COMPLETE.md` +- **Fase 2**: `PHASE2_COMPLETE.md` +- **Fase 3**: `PHASE3_COMPLETE.md` +- **API Docs**: Swagger UI en `/swagger-ui.html` + +--- + +**Fase 4 Completada** ✅ +**Fecha**: 2025-11-24 +**Próxima Fase**: Fase 5 - Assessments & Assignments + diff --git a/src/main/java/uy/supap/controller/CourseController.java b/src/main/java/uy/supap/controller/CourseController.java new file mode 100644 index 0000000..cdd9588 --- /dev/null +++ b/src/main/java/uy/supap/controller/CourseController.java @@ -0,0 +1,91 @@ +package uy.supap.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import uy.supap.model.dto.request.CourseRequest; +import uy.supap.model.dto.response.CourseResponse; +import uy.supap.model.entity.Course; +import uy.supap.service.CourseService; + +/** + * Course controller. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/courses") +@RequiredArgsConstructor +@Tag(name = "Courses", description = "Course management endpoints") +public class CourseController { + + private final CourseService courseService; + + @GetMapping + @Operation(summary = "Get all published courses", description = "Returns paginated list of published courses") + public ResponseEntity> getAllCourses( + @PageableDefault(size = 10) Pageable pageable, + @RequestParam(required = false) Course.CourseLevel level, + @RequestParam(required = false) String search) { + + if (search != null && !search.isEmpty()) { + return ResponseEntity.ok(courseService.searchCourses(search, pageable)); + } + + if (level != null) { + return ResponseEntity.ok(courseService.getCoursesByLevel(level, pageable)); + } + + return ResponseEntity.ok(courseService.getAllPublishedCourses(pageable)); + } + + @GetMapping("/{slug}") + @Operation(summary = "Get course by slug", description = "Returns course details by slug (public)") + public ResponseEntity getCourseBySlug(@PathVariable String slug) { + return ResponseEntity.ok(courseService.getCourseBySlug(slug)); + } + + @GetMapping("/id/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'INSTRUCTOR')") + @Operation(summary = "Get course by ID", description = "Returns course details by ID (Admin/Instructor)") + public ResponseEntity getCourseById(@PathVariable Long id) { + return ResponseEntity.ok(courseService.getCourseById(id)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'INSTRUCTOR')") + @Operation(summary = "Create course", description = "Creates a new course (Admin/Instructor)") + public ResponseEntity createCourse(@Valid @RequestBody CourseRequest request) { + log.info("Creating course: {}", request.getTitle()); + CourseResponse course = courseService.createCourse(request); + return ResponseEntity.status(HttpStatus.CREATED).body(course); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'INSTRUCTOR')") + @Operation(summary = "Update course", description = "Updates an existing course (Admin/Instructor)") + public ResponseEntity updateCourse( + @PathVariable Long id, + @Valid @RequestBody CourseRequest request) { + log.info("Updating course: {}", id); + return ResponseEntity.ok(courseService.updateCourse(id, request)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Delete course", description = "Deletes a course (Admin only)") + public ResponseEntity deleteCourse(@PathVariable Long id) { + log.info("Deleting course: {}", id); + courseService.deleteCourse(id); + return ResponseEntity.noContent().build(); + } +} + diff --git a/src/main/java/uy/supap/controller/EnrollmentController.java b/src/main/java/uy/supap/controller/EnrollmentController.java new file mode 100644 index 0000000..bf016d9 --- /dev/null +++ b/src/main/java/uy/supap/controller/EnrollmentController.java @@ -0,0 +1,110 @@ +package uy.supap.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import uy.supap.model.dto.request.EnrollmentRequest; +import uy.supap.model.dto.request.StudentProgressRequest; +import uy.supap.model.dto.response.EnrollmentResponse; +import uy.supap.model.dto.response.StudentProgressResponse; +import uy.supap.service.EnrollmentService; +import uy.supap.service.StudentProgressService; + +import java.util.List; + +/** + * Enrollment controller. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/enrollments") +@RequiredArgsConstructor +@Tag(name = "Enrollments", description = "Course enrollment endpoints") +public class EnrollmentController { + + private final EnrollmentService enrollmentService; + private final StudentProgressService progressService; + + @PostMapping + @PreAuthorize("hasRole('USER')") + @Operation(summary = "Enroll in course", description = "Enrolls the current user in a course") + public ResponseEntity enrollInCourse(@Valid @RequestBody EnrollmentRequest request) { + log.info("Enrollment request for course: {}", request.getCourseId()); + EnrollmentResponse enrollment = enrollmentService.enrollInCourse(request); + return ResponseEntity.status(HttpStatus.CREATED).body(enrollment); + } + + @GetMapping("/my") + @PreAuthorize("hasRole('USER')") + @Operation(summary = "Get my enrollments", description = "Returns current user's enrollments") + public ResponseEntity> getMyEnrollments( + @PageableDefault(size = 10) Pageable pageable) { + return ResponseEntity.ok(enrollmentService.getMyEnrollments(pageable)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasRole('USER')") + @Operation(summary = "Get enrollment by ID", description = "Returns enrollment details") + public ResponseEntity getEnrollmentById(@PathVariable Long id) { + return ResponseEntity.ok(enrollmentService.getEnrollmentById(id)); + } + + @GetMapping("/course/{courseId}") + @PreAuthorize("hasAnyRole('ADMIN', 'INSTRUCTOR')") + @Operation(summary = "Get course enrollments", description = "Returns all enrollments for a course (Admin/Instructor)") + public ResponseEntity> getCourseEnrollments( + @PathVariable Long courseId, + @PageableDefault(size = 20) Pageable pageable) { + return ResponseEntity.ok(enrollmentService.getCourseEnrollments(courseId, pageable)); + } + + @PostMapping("/{enrollmentId}/progress") + @PreAuthorize("hasRole('USER')") + @Operation(summary = "Update progress", description = "Updates student progress for a lesson") + public ResponseEntity updateProgress( + @PathVariable Long enrollmentId, + @Valid @RequestBody StudentProgressRequest request) { + return ResponseEntity.ok(progressService.updateProgress(enrollmentId, request)); + } + + @GetMapping("/{enrollmentId}/progress") + @PreAuthorize("hasRole('USER')") + @Operation(summary = "Get progress", description = "Returns all progress for an enrollment") + public ResponseEntity> getProgress( + @PathVariable Long enrollmentId) { + return ResponseEntity.ok(progressService.getProgressByEnrollment(enrollmentId)); + } + + @PostMapping("/{enrollmentId}/lessons/{lessonId}/complete") + @PreAuthorize("hasRole('USER')") + @Operation(summary = "Mark lesson as complete", description = "Marks a lesson as completed") + public ResponseEntity markLessonComplete( + @PathVariable Long enrollmentId, + @PathVariable Long lessonId) { + return ResponseEntity.ok(progressService.markLessonComplete(enrollmentId, lessonId)); + } + + @PostMapping("/{enrollmentId}/activate") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Activate enrollment", description = "Activates an enrollment (Admin only)") + public ResponseEntity activateEnrollment(@PathVariable Long enrollmentId) { + return ResponseEntity.ok(enrollmentService.activateEnrollment(enrollmentId)); + } + + @PostMapping("/{enrollmentId}/complete") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Complete enrollment", description = "Marks an enrollment as completed (Admin only)") + public ResponseEntity completeEnrollment(@PathVariable Long enrollmentId) { + return ResponseEntity.ok(enrollmentService.completeEnrollment(enrollmentId)); + } +} + diff --git a/src/main/java/uy/supap/model/dto/request/CourseModuleRequest.java b/src/main/java/uy/supap/model/dto/request/CourseModuleRequest.java new file mode 100644 index 0000000..5addb24 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/request/CourseModuleRequest.java @@ -0,0 +1,35 @@ +package uy.supap.model.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Course module request DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CourseModuleRequest { + + @NotBlank(message = "Module title is required") + @Size(max = 255, message = "Title must not exceed 255 characters") + private String title; + + private String description; + + private Integer order; + + private Integer durationMinutes; + + @Valid + private List lessons; +} + diff --git a/src/main/java/uy/supap/model/dto/request/CourseRequest.java b/src/main/java/uy/supap/model/dto/request/CourseRequest.java new file mode 100644 index 0000000..bf68f39 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/request/CourseRequest.java @@ -0,0 +1,69 @@ +package uy.supap.model.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import uy.supap.model.entity.Course; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * Course creation/update request DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CourseRequest { + + @NotBlank(message = "Title is required") + @Size(max = 255, message = "Title must not exceed 255 characters") + private String title; + + @Size(max = 255, message = "Slug must not exceed 255 characters") + private String slug; + + private String description; + + private String objectives; + + private String prerequisites; + + private Long instructorId; + + @Size(max = 500, message = "Thumbnail URL must not exceed 500 characters") + private String thumbnailUrl; + + @Size(max = 500, message = "Preview video URL must not exceed 500 characters") + private String previewVideoUrl; + + @NotNull(message = "Level is required") + private Course.CourseLevel level; + + private Course.CourseStatus status; + + private Integer durationHours; + + private BigDecimal price; + + private BigDecimal memberPrice; + + private BigDecimal regularPrice; + + private Integer maxStudents; + + @Valid + private List modules; + + private LocalDateTime startDate; + + private LocalDateTime endDate; +} + diff --git a/src/main/java/uy/supap/model/dto/request/EnrollmentRequest.java b/src/main/java/uy/supap/model/dto/request/EnrollmentRequest.java new file mode 100644 index 0000000..233777e --- /dev/null +++ b/src/main/java/uy/supap/model/dto/request/EnrollmentRequest.java @@ -0,0 +1,21 @@ +package uy.supap.model.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Enrollment request DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EnrollmentRequest { + + @NotNull(message = "Course ID is required") + private Long courseId; +} + diff --git a/src/main/java/uy/supap/model/dto/request/LessonRequest.java b/src/main/java/uy/supap/model/dto/request/LessonRequest.java new file mode 100644 index 0000000..a582942 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/request/LessonRequest.java @@ -0,0 +1,41 @@ +package uy.supap.model.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import uy.supap.model.entity.Lesson; + +/** + * Lesson request DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LessonRequest { + + @NotBlank(message = "Lesson title is required") + @Size(max = 255, message = "Title must not exceed 255 characters") + private String title; + + private String content; + + @NotNull(message = "Lesson type is required") + private Lesson.LessonType type; + + @Size(max = 500, message = "Video URL must not exceed 500 characters") + private String videoUrl; + + private Integer videoDuration; + + private String resourceUrls; // JSON array + + private Integer order; + + private Boolean isFree; +} + diff --git a/src/main/java/uy/supap/model/dto/request/StudentProgressRequest.java b/src/main/java/uy/supap/model/dto/request/StudentProgressRequest.java new file mode 100644 index 0000000..a888a63 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/request/StudentProgressRequest.java @@ -0,0 +1,25 @@ +package uy.supap.model.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Student progress request DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StudentProgressRequest { + + @NotNull(message = "Lesson ID is required") + private Long lessonId; + + private Boolean completed; + + private Integer videoProgress; // seconds watched +} + diff --git a/src/main/java/uy/supap/model/dto/response/CourseModuleResponse.java b/src/main/java/uy/supap/model/dto/response/CourseModuleResponse.java new file mode 100644 index 0000000..9cf71bf --- /dev/null +++ b/src/main/java/uy/supap/model/dto/response/CourseModuleResponse.java @@ -0,0 +1,51 @@ +package uy.supap.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import uy.supap.model.entity.CourseModule; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Course module response DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CourseModuleResponse { + + private Long id; + private Long courseId; + private String title; + private String description; + private List lessons; + private Integer order; + private Integer durationMinutes; + + /** + * Convert CourseModule entity to CourseModuleResponse DTO. + * + * @param module the module entity + * @return CourseModuleResponse DTO + */ + public static CourseModuleResponse fromEntity(CourseModule module) { + return CourseModuleResponse.builder() + .id(module.getId()) + .courseId(module.getCourse() != null ? module.getCourse().getId() : null) + .title(module.getTitle()) + .description(module.getDescription()) + .lessons(module.getLessons() != null + ? module.getLessons().stream() + .map(LessonResponse::fromEntity) + .collect(Collectors.toList()) + : null) + .order(module.getOrder()) + .durationMinutes(module.getDurationMinutes()) + .build(); + } +} + diff --git a/src/main/java/uy/supap/model/dto/response/CourseResponse.java b/src/main/java/uy/supap/model/dto/response/CourseResponse.java new file mode 100644 index 0000000..e7b8290 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/response/CourseResponse.java @@ -0,0 +1,89 @@ +package uy.supap.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import uy.supap.model.entity.Course; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Course response DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CourseResponse { + + private Long id; + private String title; + private String slug; + private String description; + private String objectives; + private String prerequisites; + private Long instructorId; + private String instructorName; + private String thumbnailUrl; + private String previewVideoUrl; + private Course.CourseLevel level; + private Course.CourseStatus status; + private Integer durationHours; + private BigDecimal price; + private BigDecimal memberPrice; + private BigDecimal regularPrice; + private Integer maxStudents; + private Integer enrolledCount; + private List modules; + private LocalDateTime startDate; + private LocalDateTime endDate; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Boolean hasCapacity; + + /** + * Convert Course entity to CourseResponse DTO. + * + * @param course the course entity + * @return CourseResponse DTO + */ + public static CourseResponse fromEntity(Course course) { + return CourseResponse.builder() + .id(course.getId()) + .title(course.getTitle()) + .slug(course.getSlug()) + .description(course.getDescription()) + .objectives(course.getObjectives()) + .prerequisites(course.getPrerequisites()) + .instructorId(course.getInstructor() != null ? course.getInstructor().getId() : null) + .instructorName(course.getInstructor() != null + ? (course.getInstructor().getFirstName() + " " + course.getInstructor().getLastName()).trim() + : null) + .thumbnailUrl(course.getThumbnailUrl()) + .previewVideoUrl(course.getPreviewVideoUrl()) + .level(course.getLevel()) + .status(course.getStatus()) + .durationHours(course.getDurationHours()) + .price(course.getPrice()) + .memberPrice(course.getMemberPrice()) + .regularPrice(course.getRegularPrice()) + .maxStudents(course.getMaxStudents()) + .enrolledCount(course.getEnrolledCount()) + .modules(course.getModules() != null + ? course.getModules().stream() + .map(CourseModuleResponse::fromEntity) + .collect(Collectors.toList()) + : null) + .startDate(course.getStartDate()) + .endDate(course.getEndDate()) + .createdAt(course.getCreatedAt()) + .updatedAt(course.getUpdatedAt()) + .hasCapacity(course.hasCapacity()) + .build(); + } +} + diff --git a/src/main/java/uy/supap/model/dto/response/EnrollmentResponse.java b/src/main/java/uy/supap/model/dto/response/EnrollmentResponse.java new file mode 100644 index 0000000..d5101d6 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/response/EnrollmentResponse.java @@ -0,0 +1,54 @@ +package uy.supap.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import uy.supap.model.entity.Enrollment; + +import java.time.LocalDateTime; + +/** + * Enrollment response DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EnrollmentResponse { + + private Long id; + private Long userId; + private Long courseId; + private String courseTitle; + private String courseSlug; + private Enrollment.EnrollmentStatus status; + private LocalDateTime enrolledAt; + private LocalDateTime completedAt; + private LocalDateTime expiresAt; + private Integer progressPercentage; + private Long paymentId; + + /** + * Convert Enrollment entity to EnrollmentResponse DTO. + * + * @param enrollment the enrollment entity + * @return EnrollmentResponse DTO + */ + public static EnrollmentResponse fromEntity(Enrollment enrollment) { + return EnrollmentResponse.builder() + .id(enrollment.getId()) + .userId(enrollment.getUser() != null ? enrollment.getUser().getId() : null) + .courseId(enrollment.getCourse() != null ? enrollment.getCourse().getId() : null) + .courseTitle(enrollment.getCourse() != null ? enrollment.getCourse().getTitle() : null) + .courseSlug(enrollment.getCourse() != null ? enrollment.getCourse().getSlug() : null) + .status(enrollment.getStatus()) + .enrolledAt(enrollment.getEnrolledAt()) + .completedAt(enrollment.getCompletedAt()) + .expiresAt(enrollment.getExpiresAt()) + .progressPercentage(enrollment.getProgressPercentage()) + .paymentId(enrollment.getPayment() != null ? enrollment.getPayment().getId() : null) + .build(); + } +} + diff --git a/src/main/java/uy/supap/model/dto/response/LessonResponse.java b/src/main/java/uy/supap/model/dto/response/LessonResponse.java new file mode 100644 index 0000000..11dc6f2 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/response/LessonResponse.java @@ -0,0 +1,50 @@ +package uy.supap.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import uy.supap.model.entity.Lesson; + +/** + * Lesson response DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LessonResponse { + + private Long id; + private Long moduleId; + private String title; + private String content; + private Lesson.LessonType type; + private String videoUrl; + private Integer videoDuration; + private String resourceUrls; + private Integer order; + private Boolean isFree; + + /** + * Convert Lesson entity to LessonResponse DTO. + * + * @param lesson the lesson entity + * @return LessonResponse DTO + */ + public static LessonResponse fromEntity(Lesson lesson) { + return LessonResponse.builder() + .id(lesson.getId()) + .moduleId(lesson.getModule() != null ? lesson.getModule().getId() : null) + .title(lesson.getTitle()) + .content(lesson.getContent()) + .type(lesson.getType()) + .videoUrl(lesson.getVideoUrl()) + .videoDuration(lesson.getVideoDuration()) + .resourceUrls(lesson.getResourceUrls()) + .order(lesson.getOrder()) + .isFree(lesson.getIsFree()) + .build(); + } +} + diff --git a/src/main/java/uy/supap/model/dto/response/StudentProgressResponse.java b/src/main/java/uy/supap/model/dto/response/StudentProgressResponse.java new file mode 100644 index 0000000..d8aab69 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/response/StudentProgressResponse.java @@ -0,0 +1,50 @@ +package uy.supap.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import uy.supap.model.entity.StudentProgress; + +import java.time.LocalDateTime; + +/** + * Student progress response DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StudentProgressResponse { + + private Long id; + private Long enrollmentId; + private Long lessonId; + private String lessonTitle; + private Boolean completed; + private Integer videoProgress; + private LocalDateTime startedAt; + private LocalDateTime completedAt; + private LocalDateTime lastAccessedAt; + + /** + * Convert StudentProgress entity to StudentProgressResponse DTO. + * + * @param progress the progress entity + * @return StudentProgressResponse DTO + */ + public static StudentProgressResponse fromEntity(StudentProgress progress) { + return StudentProgressResponse.builder() + .id(progress.getId()) + .enrollmentId(progress.getEnrollment() != null ? progress.getEnrollment().getId() : null) + .lessonId(progress.getLesson() != null ? progress.getLesson().getId() : null) + .lessonTitle(progress.getLesson() != null ? progress.getLesson().getTitle() : null) + .completed(progress.getCompleted()) + .videoProgress(progress.getVideoProgress()) + .startedAt(progress.getStartedAt()) + .completedAt(progress.getCompletedAt()) + .lastAccessedAt(progress.getLastAccessedAt()) + .build(); + } +} + diff --git a/src/main/java/uy/supap/model/entity/Course.java b/src/main/java/uy/supap/model/entity/Course.java new file mode 100644 index 0000000..9ee0d0e --- /dev/null +++ b/src/main/java/uy/supap/model/entity/Course.java @@ -0,0 +1,146 @@ +package uy.supap.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Course entity representing courses in the Aula Virtual LMS. + */ +@Entity +@Table(name = "courses", indexes = { + @Index(name = "idx_courses_slug", columnList = "slug"), + @Index(name = "idx_courses_status", columnList = "status"), + @Index(name = "idx_courses_level", columnList = "level"), + @Index(name = "idx_courses_instructor", columnList = "instructor_id") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Course { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 255) + private String title; + + @Column(unique = true, length = 255) + private String slug; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(columnDefinition = "TEXT") + private String objectives; + + @Column(columnDefinition = "TEXT") + private String prerequisites; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "instructor_id") + private User instructor; + + @Column(name = "thumbnail_url", length = 500) + private String thumbnailUrl; + + @Column(name = "preview_video_url", length = 500) + private String previewVideoUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private CourseLevel level; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private CourseStatus status = CourseStatus.DRAFT; + + @Column(name = "duration_hours") + private Integer durationHours; + + @Column(precision = 10, scale = 2) + private BigDecimal price; + + // Membership pricing + @Column(name = "member_price", precision = 10, scale = 2) + private BigDecimal memberPrice; + + @Column(name = "regular_price", precision = 10, scale = 2) + private BigDecimal regularPrice; + + @Column(name = "max_students") + private Integer maxStudents; + + @Column(name = "enrolled_count") + @Builder.Default + private Integer enrolledCount = 0; + + @OneToMany(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("order ASC") + @Builder.Default + private List modules = new ArrayList<>(); + + @OneToMany(mappedBy = "course") + @Builder.Default + private List enrollments = new ArrayList<>(); + + @Column(name = "start_date") + private LocalDateTime startDate; + + @Column(name = "end_date") + private LocalDateTime endDate; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * Course level enum. + */ + public enum CourseLevel { + BEGINNER, + INTERMEDIATE, + ADVANCED, + EXPERT + } + + /** + * Course status enum. + */ + public enum CourseStatus { + DRAFT, + PUBLISHED, + ARCHIVED + } + + /** + * Check if course has available capacity. + * + * @return true if has capacity, false otherwise + */ + public boolean hasCapacity() { + return maxStudents == null || enrolledCount < maxStudents; + } + + /** + * Increment enrolled count. + */ + public void incrementEnrolledCount() { + this.enrolledCount++; + } +} + diff --git a/src/main/java/uy/supap/model/entity/CourseModule.java b/src/main/java/uy/supap/model/entity/CourseModule.java new file mode 100644 index 0000000..6365c8b --- /dev/null +++ b/src/main/java/uy/supap/model/entity/CourseModule.java @@ -0,0 +1,49 @@ +package uy.supap.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.util.ArrayList; +import java.util.List; + +/** + * CourseModule entity representing modules within a course. + */ +@Entity +@Table(name = "course_modules", indexes = { + @Index(name = "idx_modules_course", columnList = "course_id"), + @Index(name = "idx_modules_order", columnList = "display_order") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CourseModule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "course_id", nullable = false) + private Course course; + + @Column(nullable = false, length = 255) + private String title; + + @Column(columnDefinition = "TEXT") + private String description; + + @OneToMany(mappedBy = "module", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("order ASC") + @Builder.Default + private List lessons = new ArrayList<>(); + + @Column(name = "display_order") + @Builder.Default + private Integer order = 0; + + @Column(name = "duration_minutes") + private Integer durationMinutes; +} + diff --git a/src/main/java/uy/supap/model/entity/Enrollment.java b/src/main/java/uy/supap/model/entity/Enrollment.java new file mode 100644 index 0000000..96e98ab --- /dev/null +++ b/src/main/java/uy/supap/model/entity/Enrollment.java @@ -0,0 +1,77 @@ +package uy.supap.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Enrollment entity representing student enrollments in courses. + */ +@Entity +@Table(name = "enrollments", indexes = { + @Index(name = "idx_enrollments_user", columnList = "user_id"), + @Index(name = "idx_enrollments_course", columnList = "course_id"), + @Index(name = "idx_enrollments_status", columnList = "status") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Enrollment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "course_id", nullable = false) + private Course course; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private EnrollmentStatus status = EnrollmentStatus.PENDING_PAYMENT; + + @CreationTimestamp + @Column(name = "enrolled_at", nullable = false, updatable = false) + private LocalDateTime enrolledAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; + + @Column(name = "progress_percentage") + @Builder.Default + private Integer progressPercentage = 0; + + @OneToMany(mappedBy = "enrollment", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List progress = new ArrayList<>(); + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "payment_id") + private Payment payment; + + /** + * Enrollment status enum. + */ + public enum EnrollmentStatus { + PENDING_PAYMENT, + ACTIVE, + COMPLETED, + DROPPED, + EXPIRED + } +} + diff --git a/src/main/java/uy/supap/model/entity/Lesson.java b/src/main/java/uy/supap/model/entity/Lesson.java new file mode 100644 index 0000000..6c98369 --- /dev/null +++ b/src/main/java/uy/supap/model/entity/Lesson.java @@ -0,0 +1,69 @@ +package uy.supap.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +/** + * Lesson entity representing lessons within a course module. + */ +@Entity +@Table(name = "lessons", indexes = { + @Index(name = "idx_lessons_module", columnList = "module_id"), + @Index(name = "idx_lessons_order", columnList = "display_order"), + @Index(name = "idx_lessons_type", columnList = "type") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Lesson { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "module_id", nullable = false) + private CourseModule module; + + @Column(nullable = false, length = 255) + private String title; + + @Column(columnDefinition = "TEXT") + private String content; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private LessonType type; + + @Column(name = "video_url", length = 500) + private String videoUrl; + + @Column(name = "video_duration") + private Integer videoDuration; // seconds + + @Column(name = "resource_urls", columnDefinition = "TEXT") + private String resourceUrls; // JSON array + + @Column(name = "display_order") + @Builder.Default + private Integer order = 0; + + @Column(name = "is_free", nullable = false) + @Builder.Default + private Boolean isFree = false; // Preview lesson + + /** + * Lesson type enum. + */ + public enum LessonType { + VIDEO, + TEXT, + QUIZ, + ASSIGNMENT, + RESOURCE, + LIVE_SESSION + } +} + diff --git a/src/main/java/uy/supap/model/entity/Payment.java b/src/main/java/uy/supap/model/entity/Payment.java new file mode 100644 index 0000000..ac4c27b --- /dev/null +++ b/src/main/java/uy/supap/model/entity/Payment.java @@ -0,0 +1,111 @@ +package uy.supap.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * Payment entity for course and event payments. + * + * Note: This is a simplified version for Phase 4. + * Full payment integration will be implemented in Phase 6. + */ +@Entity +@Table(name = "payments", indexes = { + @Index(name = "idx_payments_user", columnList = "user_id"), + @Index(name = "idx_payments_status", columnList = "status"), + @Index(name = "idx_payments_type", columnList = "type") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Payment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentType type; + + // Polymorphic relationship + @Column(name = "reference_id") + private Long referenceId; // Course ID, Event ID, etc. + + @Column(name = "reference_type", length = 50) + private String referenceType; // "COURSE", "EVENT", "MEMBERSHIP" + + @Column(nullable = false, precision = 10, scale = 2) + private BigDecimal amount; + + @Column(nullable = false, length = 3) + @Builder.Default + private String currency = "UYU"; + + @Enumerated(EnumType.STRING) + @Column(name = "payment_method", nullable = false, length = 20) + private PaymentMethod method; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private PaymentStatus status = PaymentStatus.PENDING; + + @Column(name = "transaction_id", length = 100) + private String transactionId; + + @Column(name = "receipt_url", length = 500) + private String receiptUrl; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + /** + * Payment type enum. + */ + public enum PaymentType { + COURSE, + EVENT, + MEMBERSHIP, + DONATION + } + + /** + * Payment method enum. + */ + public enum PaymentMethod { + CREDIT_CARD, + DEBIT_CARD, + BANK_TRANSFER, + CASH, + MERCADOPAGO, + STRIPE + } + + /** + * Payment status enum. + */ + public enum PaymentStatus { + PENDING, + PROCESSING, + COMPLETED, + FAILED, + REFUNDED, + CANCELLED + } +} + diff --git a/src/main/java/uy/supap/model/entity/StudentProgress.java b/src/main/java/uy/supap/model/entity/StudentProgress.java new file mode 100644 index 0000000..bcbfe65 --- /dev/null +++ b/src/main/java/uy/supap/model/entity/StudentProgress.java @@ -0,0 +1,56 @@ +package uy.supap.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +/** + * StudentProgress entity tracking student progress through lessons. + */ +@Entity +@Table(name = "student_progress", indexes = { + @Index(name = "idx_progress_enrollment", columnList = "enrollment_id"), + @Index(name = "idx_progress_lesson", columnList = "lesson_id"), + @Index(name = "idx_progress_completed", columnList = "completed") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class StudentProgress { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "enrollment_id", nullable = false) + private Enrollment enrollment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "lesson_id", nullable = false) + private Lesson lesson; + + @Column(nullable = false) + @Builder.Default + private Boolean completed = false; + + @Column(name = "video_progress") + private Integer videoProgress; // seconds watched + + @CreationTimestamp + @Column(name = "started_at", nullable = false, updatable = false) + private LocalDateTime startedAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @UpdateTimestamp + @Column(name = "last_accessed_at", nullable = false) + private LocalDateTime lastAccessedAt; +} + diff --git a/src/main/java/uy/supap/repository/CourseModuleRepository.java b/src/main/java/uy/supap/repository/CourseModuleRepository.java new file mode 100644 index 0000000..c6e3176 --- /dev/null +++ b/src/main/java/uy/supap/repository/CourseModuleRepository.java @@ -0,0 +1,30 @@ +package uy.supap.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uy.supap.model.entity.CourseModule; + +import java.util.List; + +/** + * Repository interface for CourseModule entity. + */ +@Repository +public interface CourseModuleRepository extends JpaRepository { + + /** + * Find all modules for a course, ordered by display order. + * + * @param courseId the course ID + * @return list of modules + */ + List findByCourseIdOrderByOrderAsc(Long courseId); + + /** + * Delete all modules for a course. + * + * @param courseId the course ID + */ + void deleteByCourseId(Long courseId); +} + diff --git a/src/main/java/uy/supap/repository/CourseRepository.java b/src/main/java/uy/supap/repository/CourseRepository.java new file mode 100644 index 0000000..8b66493 --- /dev/null +++ b/src/main/java/uy/supap/repository/CourseRepository.java @@ -0,0 +1,77 @@ +package uy.supap.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import uy.supap.model.entity.Course; + +import java.util.Optional; + +/** + * Repository interface for Course entity. + */ +@Repository +public interface CourseRepository extends JpaRepository { + + /** + * Find course by slug. + * + * @param slug the course slug + * @return Optional containing course if found + */ + Optional findBySlug(String slug); + + /** + * Find all published courses. + * + * @param pageable pagination information + * @return page of published courses + */ + Page findByStatus(Course.CourseStatus status, Pageable pageable); + + /** + * Find courses by level. + * + * @param level the course level + * @param pageable pagination information + * @return page of courses + */ + Page findByLevelAndStatus( + Course.CourseLevel level, + Course.CourseStatus status, + Pageable pageable); + + /** + * Find courses by instructor. + * + * @param instructorId the instructor ID + * @param pageable pagination information + * @return page of courses + */ + Page findByInstructorIdAndStatus( + Long instructorId, + Course.CourseStatus status, + Pageable pageable); + + /** + * Search courses by title (case-insensitive). + * + * @param title search term + * @param pageable pagination information + * @return page of matching courses + */ + @Query("SELECT c FROM Course c WHERE LOWER(c.title) LIKE LOWER(CONCAT('%', :title, '%')) AND c.status = :status") + Page searchByTitle(@Param("title") String title, @Param("status") Course.CourseStatus status, Pageable pageable); + + /** + * Check if slug exists. + * + * @param slug the slug to check + * @return true if exists, false otherwise + */ + boolean existsBySlug(String slug); +} + diff --git a/src/main/java/uy/supap/repository/EnrollmentRepository.java b/src/main/java/uy/supap/repository/EnrollmentRepository.java new file mode 100644 index 0000000..83e3300 --- /dev/null +++ b/src/main/java/uy/supap/repository/EnrollmentRepository.java @@ -0,0 +1,74 @@ +package uy.supap.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import uy.supap.model.entity.Enrollment; + +import java.util.Optional; + +/** + * Repository interface for Enrollment entity. + */ +@Repository +public interface EnrollmentRepository extends JpaRepository { + + /** + * Find enrollment by user and course. + * + * @param userId the user ID + * @param courseId the course ID + * @return Optional containing enrollment if found + */ + Optional findByUserIdAndCourseId(Long userId, Long courseId); + + /** + * Find all enrollments for a user. + * + * @param userId the user ID + * @param pageable pagination information + * @return page of enrollments + */ + Page findByUserId(Long userId, Pageable pageable); + + /** + * Find all enrollments for a course. + * + * @param courseId the course ID + * @param pageable pagination information + * @return page of enrollments + */ + Page findByCourseId(Long courseId, Pageable pageable); + + /** + * Find enrollments by status for a user. + * + * @param userId the user ID + * @param status the enrollment status + * @param pageable pagination information + * @return page of enrollments + */ + Page findByUserIdAndStatus(Long userId, Enrollment.EnrollmentStatus status, Pageable pageable); + + /** + * Check if user is enrolled in a course. + * + * @param userId the user ID + * @param courseId the course ID + * @return true if enrolled, false otherwise + */ + boolean existsByUserIdAndCourseId(Long userId, Long courseId); + + /** + * Count active enrollments for a course. + * + * @param courseId the course ID + * @return count of active enrollments + */ + @Query("SELECT COUNT(e) FROM Enrollment e WHERE e.course.id = :courseId AND e.status = 'ACTIVE'") + long countActiveEnrollmentsByCourseId(@Param("courseId") Long courseId); +} + diff --git a/src/main/java/uy/supap/repository/LessonRepository.java b/src/main/java/uy/supap/repository/LessonRepository.java new file mode 100644 index 0000000..0488e19 --- /dev/null +++ b/src/main/java/uy/supap/repository/LessonRepository.java @@ -0,0 +1,31 @@ +package uy.supap.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uy.supap.model.entity.Lesson; + +import java.util.List; + +/** + * Repository interface for Lesson entity. + */ +@Repository +public interface LessonRepository extends JpaRepository { + + /** + * Find all lessons for a module, ordered by display order. + * + * @param moduleId the module ID + * @return list of lessons + */ + List findByModuleIdOrderByOrderAsc(Long moduleId); + + /** + * Find free/preview lessons for a module. + * + * @param moduleId the module ID + * @return list of free lessons + */ + List findByModuleIdAndIsFreeTrueOrderByOrderAsc(Long moduleId); +} + diff --git a/src/main/java/uy/supap/repository/PaymentRepository.java b/src/main/java/uy/supap/repository/PaymentRepository.java new file mode 100644 index 0000000..643ccc2 --- /dev/null +++ b/src/main/java/uy/supap/repository/PaymentRepository.java @@ -0,0 +1,61 @@ +package uy.supap.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uy.supap.model.entity.Payment; + +import java.util.Optional; + +/** + * Repository interface for Payment entity. + */ +@Repository +public interface PaymentRepository extends JpaRepository { + + /** + * Find payment by transaction ID. + * + * @param transactionId the transaction ID + * @return Optional containing payment if found + */ + Optional findByTransactionId(String transactionId); + + /** + * Find all payments for a user. + * + * @param userId the user ID + * @param pageable pagination information + * @return page of payments + */ + Page findByUserId(Long userId, Pageable pageable); + + /** + * Find payments by status. + * + * @param status the payment status + * @param pageable pagination information + * @return page of payments + */ + Page findByStatus(Payment.PaymentStatus status, Pageable pageable); + + /** + * Find payments by type. + * + * @param type the payment type + * @param pageable pagination information + * @return page of payments + */ + Page findByType(Payment.PaymentType type, Pageable pageable); + + /** + * Find payments by reference. + * + * @param referenceId the reference ID + * @param referenceType the reference type + * @return list of payments + */ + List findByReferenceIdAndReferenceType(Long referenceId, String referenceType); +} + diff --git a/src/main/java/uy/supap/repository/StudentProgressRepository.java b/src/main/java/uy/supap/repository/StudentProgressRepository.java new file mode 100644 index 0000000..df14322 --- /dev/null +++ b/src/main/java/uy/supap/repository/StudentProgressRepository.java @@ -0,0 +1,52 @@ +package uy.supap.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import uy.supap.model.entity.StudentProgress; + +import java.util.List; +import java.util.Optional; + +/** + * Repository interface for StudentProgress entity. + */ +@Repository +public interface StudentProgressRepository extends JpaRepository { + + /** + * Find progress by enrollment and lesson. + * + * @param enrollmentId the enrollment ID + * @param lessonId the lesson ID + * @return Optional containing progress if found + */ + Optional findByEnrollmentIdAndLessonId(Long enrollmentId, Long lessonId); + + /** + * Find all progress for an enrollment. + * + * @param enrollmentId the enrollment ID + * @return list of progress records + */ + List findByEnrollmentId(Long enrollmentId); + + /** + * Count completed lessons for an enrollment. + * + * @param enrollmentId the enrollment ID + * @return count of completed lessons + */ + @Query("SELECT COUNT(sp) FROM StudentProgress sp WHERE sp.enrollment.id = :enrollmentId AND sp.completed = true") + long countCompletedLessonsByEnrollmentId(@Param("enrollmentId") Long enrollmentId); + + /** + * Find all completed progress for an enrollment. + * + * @param enrollmentId the enrollment ID + * @return list of completed progress + */ + List findByEnrollmentIdAndCompletedTrue(Long enrollmentId); +} + diff --git a/src/main/java/uy/supap/service/CourseService.java b/src/main/java/uy/supap/service/CourseService.java new file mode 100644 index 0000000..cfb72fd --- /dev/null +++ b/src/main/java/uy/supap/service/CourseService.java @@ -0,0 +1,260 @@ +package uy.supap.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import uy.supap.exception.EventRegistrationException; +import uy.supap.exception.ResourceNotFoundException; +import uy.supap.model.dto.request.CourseModuleRequest; +import uy.supap.model.dto.request.CourseRequest; +import uy.supap.model.dto.request.LessonRequest; +import uy.supap.model.dto.response.CourseResponse; +import uy.supap.model.entity.Course; +import uy.supap.model.entity.CourseModule; +import uy.supap.model.entity.Lesson; +import uy.supap.model.entity.User; +import uy.supap.repository.CourseRepository; +import uy.supap.repository.UserRepository; + +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Service for managing courses. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CourseService { + + private final CourseRepository courseRepository; + private final UserRepository userRepository; + private static final Pattern NONLATIN = Pattern.compile("[^\\w-]"); + private static final Pattern WHITESPACE = Pattern.compile("[\\s]"); + + @Transactional(readOnly = true) + public Page getAllPublishedCourses(Pageable pageable) { + log.debug("Fetching all published courses"); + return courseRepository.findByStatus(Course.CourseStatus.PUBLISHED, pageable) + .map(CourseResponse::fromEntity); + } + + @Transactional(readOnly = true) + public CourseResponse getCourseBySlug(String slug) { + log.debug("Fetching course by slug: {}", slug); + Course course = courseRepository.findBySlug(slug) + .orElseThrow(() -> { + log.warn("Course not found with slug: {}", slug); + return new ResourceNotFoundException("Course not found with slug: " + slug); + }); + + if (course.getStatus() != Course.CourseStatus.PUBLISHED) { + log.warn("Attempt to access non-published course: {}", slug); + throw new ResourceNotFoundException("Course not found with slug: " + slug); + } + + return CourseResponse.fromEntity(course); + } + + @Transactional(readOnly = true) + public CourseResponse getCourseById(Long id) { + log.debug("Fetching course by id: {}", id); + Course course = courseRepository.findById(id) + .orElseThrow(() -> { + log.warn("Course not found: {}", id); + return new ResourceNotFoundException("Course not found with id: " + id); + }); + return CourseResponse.fromEntity(course); + } + + @Transactional + public CourseResponse createCourse(CourseRequest request) { + log.info("Creating new course: {}", request.getTitle()); + + // Generate slug if not provided + String slug = request.getSlug(); + if (slug == null || slug.isEmpty()) { + slug = generateSlug(request.getTitle()); + } + + // Ensure slug is unique + String finalSlug = slug; + int counter = 1; + while (courseRepository.existsBySlug(finalSlug)) { + finalSlug = slug + "-" + counter; + counter++; + } + + // Get instructor if provided + User instructor = null; + if (request.getInstructorId() != null) { + instructor = userRepository.findById(request.getInstructorId()) + .orElseThrow(() -> new ResourceNotFoundException("Instructor not found with id: " + request.getInstructorId())); + } + + Course course = Course.builder() + .title(request.getTitle()) + .slug(finalSlug) + .description(request.getDescription()) + .objectives(request.getObjectives()) + .prerequisites(request.getPrerequisites()) + .instructor(instructor) + .thumbnailUrl(request.getThumbnailUrl()) + .previewVideoUrl(request.getPreviewVideoUrl()) + .level(request.getLevel()) + .status(request.getStatus() != null ? request.getStatus() : Course.CourseStatus.DRAFT) + .durationHours(request.getDurationHours()) + .price(request.getPrice()) + .memberPrice(request.getMemberPrice()) + .regularPrice(request.getRegularPrice()) + .maxStudents(request.getMaxStudents()) + .enrolledCount(0) + .startDate(request.getStartDate()) + .endDate(request.getEndDate()) + .modules(new ArrayList<>()) + .build(); + + Course savedCourse = courseRepository.save(course); + + // Add modules if provided + if (request.getModules() != null && !request.getModules().isEmpty()) { + List modules = createModulesFromRequest(request.getModules(), savedCourse); + savedCourse.getModules().addAll(modules); + courseRepository.save(savedCourse); + } + + log.info("Course created successfully with id: {}", savedCourse.getId()); + return CourseResponse.fromEntity(savedCourse); + } + + @Transactional + public CourseResponse updateCourse(Long id, CourseRequest request) { + log.info("Updating course: {}", id); + Course course = courseRepository.findById(id) + .orElseThrow(() -> { + log.warn("Course not found for update: {}", id); + return new ResourceNotFoundException("Course not found with id: " + id); + }); + + // Update basic fields + course.setTitle(request.getTitle()); + if (request.getSlug() != null && !request.getSlug().isEmpty()) { + String newSlug = request.getSlug(); + if (!newSlug.equals(course.getSlug()) && courseRepository.existsBySlug(newSlug)) { + throw new EventRegistrationException("Slug already exists: " + newSlug); + } + course.setSlug(newSlug); + } + course.setDescription(request.getDescription()); + course.setObjectives(request.getObjectives()); + course.setPrerequisites(request.getPrerequisites()); + course.setThumbnailUrl(request.getThumbnailUrl()); + course.setPreviewVideoUrl(request.getPreviewVideoUrl()); + if (request.getLevel() != null) { + course.setLevel(request.getLevel()); + } + if (request.getStatus() != null) { + course.setStatus(request.getStatus()); + } + course.setDurationHours(request.getDurationHours()); + course.setPrice(request.getPrice()); + course.setMemberPrice(request.getMemberPrice()); + course.setRegularPrice(request.getRegularPrice()); + course.setMaxStudents(request.getMaxStudents()); + course.setStartDate(request.getStartDate()); + course.setEndDate(request.getEndDate()); + + // Update instructor + if (request.getInstructorId() != null) { + User instructor = userRepository.findById(request.getInstructorId()) + .orElseThrow(() -> new ResourceNotFoundException("Instructor not found with id: " + request.getInstructorId())); + course.setInstructor(instructor); + } + + // Update modules if provided + if (request.getModules() != null) { + // Remove existing modules + course.getModules().clear(); + List modules = createModulesFromRequest(request.getModules(), course); + course.getModules().addAll(modules); + } + + Course updatedCourse = courseRepository.save(course); + log.info("Course updated successfully: {}", id); + return CourseResponse.fromEntity(updatedCourse); + } + + @Transactional + public void deleteCourse(Long id) { + log.info("Deleting course: {}", id); + if (!courseRepository.existsById(id)) { + log.warn("Course not found for deletion: {}", id); + throw new ResourceNotFoundException("Course not found with id: " + id); + } + courseRepository.deleteById(id); + log.info("Course deleted successfully: {}", id); + } + + @Transactional(readOnly = true) + public Page getCoursesByLevel(Course.CourseLevel level, Pageable pageable) { + log.debug("Fetching courses by level: {}", level); + return courseRepository.findByLevelAndStatus(level, Course.CourseStatus.PUBLISHED, pageable) + .map(CourseResponse::fromEntity); + } + + @Transactional(readOnly = true) + public Page searchCourses(String title, Pageable pageable) { + log.debug("Searching courses by title: {}", title); + return courseRepository.searchByTitle(title, Course.CourseStatus.PUBLISHED, pageable) + .map(CourseResponse::fromEntity); + } + + private List createModulesFromRequest(List moduleRequests, Course course) { + return moduleRequests.stream() + .map(moduleRequest -> { + CourseModule module = CourseModule.builder() + .course(course) + .title(moduleRequest.getTitle()) + .description(moduleRequest.getDescription()) + .order(moduleRequest.getOrder() != null ? moduleRequest.getOrder() : 0) + .durationMinutes(moduleRequest.getDurationMinutes()) + .lessons(new ArrayList<>()) + .build(); + + // Add lessons if provided + if (moduleRequest.getLessons() != null && !moduleRequest.getLessons().isEmpty()) { + List lessons = moduleRequest.getLessons().stream() + .map(lessonRequest -> Lesson.builder() + .module(module) + .title(lessonRequest.getTitle()) + .content(lessonRequest.getContent()) + .type(lessonRequest.getType()) + .videoUrl(lessonRequest.getVideoUrl()) + .videoDuration(lessonRequest.getVideoDuration()) + .resourceUrls(lessonRequest.getResourceUrls()) + .order(lessonRequest.getOrder() != null ? lessonRequest.getOrder() : 0) + .isFree(lessonRequest.getIsFree() != null ? lessonRequest.getIsFree() : false) + .build()) + .toList(); + module.getLessons().addAll(lessons); + } + + return module; + }) + .toList(); + } + + private String generateSlug(String input) { + String normalized = Normalizer.normalize(input, Normalizer.Form.NFD); + String slug = WHITESPACE.matcher(normalized).replaceAll("-"); + slug = NONLATIN.matcher(slug).replaceAll(""); + return slug.toLowerCase(Locale.ENGLISH); + } +} + diff --git a/src/main/java/uy/supap/service/EnrollmentService.java b/src/main/java/uy/supap/service/EnrollmentService.java new file mode 100644 index 0000000..1d07b94 --- /dev/null +++ b/src/main/java/uy/supap/service/EnrollmentService.java @@ -0,0 +1,179 @@ +package uy.supap.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import uy.supap.exception.EventRegistrationException; +import uy.supap.exception.ResourceNotFoundException; +import uy.supap.model.dto.request.EnrollmentRequest; +import uy.supap.model.dto.response.EnrollmentResponse; +import uy.supap.model.entity.Course; +import uy.supap.model.entity.Enrollment; +import uy.supap.model.entity.User; +import uy.supap.repository.CourseRepository; +import uy.supap.repository.EnrollmentRepository; +import uy.supap.repository.UserRepository; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Service for managing course enrollments. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class EnrollmentService { + + private final EnrollmentRepository enrollmentRepository; + private final CourseRepository courseRepository; + private final UserRepository userRepository; + + @Transactional + public EnrollmentResponse enrollInCourse(EnrollmentRequest request) { + log.info("Enrollment request for course: {}", request.getCourseId()); + + // Get current user + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Find course + Course course = courseRepository.findById(request.getCourseId()) + .orElseThrow(() -> { + log.warn("Course not found for enrollment: {}", request.getCourseId()); + return new ResourceNotFoundException("Course not found with id: " + request.getCourseId()); + }); + + // Check if course is published + if (course.getStatus() != Course.CourseStatus.PUBLISHED) { + log.warn("Attempt to enroll in non-published course: {}", request.getCourseId()); + throw new EventRegistrationException("Course is not available for enrollment"); + } + + // Check if already enrolled + if (enrollmentRepository.existsByUserIdAndCourseId(user.getId(), request.getCourseId())) { + log.warn("User {} already enrolled in course {}", user.getId(), request.getCourseId()); + throw new EventRegistrationException("You are already enrolled in this course"); + } + + // Check capacity + if (!course.hasCapacity()) { + log.warn("Course {} is at full capacity", request.getCourseId()); + throw new EventRegistrationException("Course is at full capacity"); + } + + // Calculate price based on user type + BigDecimal price = calculatePrice(course, user); + + // Create enrollment + Enrollment enrollment = Enrollment.builder() + .user(user) + .course(course) + .status(price.compareTo(BigDecimal.ZERO) == 0 + ? Enrollment.EnrollmentStatus.ACTIVE + : Enrollment.EnrollmentStatus.PENDING_PAYMENT) + .progressPercentage(0) + .build(); + + Enrollment savedEnrollment = enrollmentRepository.save(enrollment); + + // Update course enrolled count + course.incrementEnrolledCount(); + courseRepository.save(course); + + log.info("Enrollment created successfully with id: {}", savedEnrollment.getId()); + return EnrollmentResponse.fromEntity(savedEnrollment); + } + + @Transactional(readOnly = true) + public Page getMyEnrollments(Pageable pageable) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + return enrollmentRepository.findByUserId(user.getId(), pageable) + .map(EnrollmentResponse::fromEntity); + } + + @Transactional(readOnly = true) + public EnrollmentResponse getEnrollmentById(Long id) { + log.debug("Fetching enrollment by id: {}", id); + Enrollment enrollment = enrollmentRepository.findById(id) + .orElseThrow(() -> { + log.warn("Enrollment not found: {}", id); + return new ResourceNotFoundException("Enrollment not found with id: " + id); + }); + + // Check if user owns this enrollment + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + if (!enrollment.getUser().getId().equals(user.getId())) { + throw new EventRegistrationException("You are not authorized to access this enrollment"); + } + + return EnrollmentResponse.fromEntity(enrollment); + } + + @Transactional(readOnly = true) + public Page getCourseEnrollments(Long courseId, Pageable pageable) { + return enrollmentRepository.findByCourseId(courseId, pageable) + .map(EnrollmentResponse::fromEntity); + } + + @Transactional + public EnrollmentResponse activateEnrollment(Long enrollmentId) { + log.info("Activating enrollment: {}", enrollmentId); + Enrollment enrollment = enrollmentRepository.findById(enrollmentId) + .orElseThrow(() -> new ResourceNotFoundException("Enrollment not found")); + + enrollment.setStatus(Enrollment.EnrollmentStatus.ACTIVE); + Enrollment updated = enrollmentRepository.save(enrollment); + return EnrollmentResponse.fromEntity(updated); + } + + @Transactional + public EnrollmentResponse completeEnrollment(Long enrollmentId) { + log.info("Completing enrollment: {}", enrollmentId); + Enrollment enrollment = enrollmentRepository.findById(enrollmentId) + .orElseThrow(() -> new ResourceNotFoundException("Enrollment not found")); + + enrollment.setStatus(Enrollment.EnrollmentStatus.COMPLETED); + enrollment.setCompletedAt(LocalDateTime.now()); + enrollment.setProgressPercentage(100); + Enrollment updated = enrollmentRepository.save(enrollment); + return EnrollmentResponse.fromEntity(updated); + } + + private BigDecimal calculatePrice(Course course, User user) { + // Check if user is a member + boolean isMember = user.getRoles().stream() + .anyMatch(role -> role.getName().name().equals("ROLE_MEMBER")); + + if (isMember && course.getMemberPrice() != null) { + return course.getMemberPrice(); + } + + if (course.getRegularPrice() != null) { + return course.getRegularPrice(); + } + + if (course.getPrice() != null) { + return course.getPrice(); + } + + return BigDecimal.ZERO; + } +} + diff --git a/src/main/java/uy/supap/service/StudentProgressService.java b/src/main/java/uy/supap/service/StudentProgressService.java new file mode 100644 index 0000000..b37d669 --- /dev/null +++ b/src/main/java/uy/supap/service/StudentProgressService.java @@ -0,0 +1,158 @@ +package uy.supap.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import uy.supap.exception.EventRegistrationException; +import uy.supap.exception.ResourceNotFoundException; +import uy.supap.model.dto.request.StudentProgressRequest; +import uy.supap.model.dto.response.StudentProgressResponse; +import uy.supap.model.entity.Enrollment; +import uy.supap.model.entity.Lesson; +import uy.supap.model.entity.StudentProgress; +import uy.supap.model.entity.User; +import uy.supap.repository.EnrollmentRepository; +import uy.supap.repository.LessonRepository; +import uy.supap.repository.StudentProgressRepository; +import uy.supap.repository.UserRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Service for managing student progress. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class StudentProgressService { + + private final StudentProgressRepository progressRepository; + private final EnrollmentRepository enrollmentRepository; + private final LessonRepository lessonRepository; + private final UserRepository userRepository; + + @Transactional + public StudentProgressResponse updateProgress(Long enrollmentId, StudentProgressRequest request) { + log.info("Updating progress for enrollment: {}, lesson: {}", enrollmentId, request.getLessonId()); + + // Get current user + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Get enrollment + Enrollment enrollment = enrollmentRepository.findById(enrollmentId) + .orElseThrow(() -> new ResourceNotFoundException("Enrollment not found")); + + // Verify user owns the enrollment + if (!enrollment.getUser().getId().equals(user.getId())) { + throw new EventRegistrationException("You are not authorized to update this progress"); + } + + // Check enrollment is active + if (enrollment.getStatus() != Enrollment.EnrollmentStatus.ACTIVE) { + throw new EventRegistrationException("Enrollment is not active"); + } + + // Get lesson + Lesson lesson = lessonRepository.findById(request.getLessonId()) + .orElseThrow(() -> new ResourceNotFoundException("Lesson not found")); + + // Verify lesson belongs to the course + if (!lesson.getModule().getCourse().getId().equals(enrollment.getCourse().getId())) { + throw new EventRegistrationException("Lesson does not belong to this course"); + } + + // Find or create progress + StudentProgress progress = progressRepository + .findByEnrollmentIdAndLessonId(enrollmentId, request.getLessonId()) + .orElse(StudentProgress.builder() + .enrollment(enrollment) + .lesson(lesson) + .completed(false) + .build()); + + // Update progress + if (request.getCompleted() != null) { + progress.setCompleted(request.getCompleted()); + if (request.getCompleted() && progress.getCompletedAt() == null) { + progress.setCompletedAt(LocalDateTime.now()); + } + } + + if (request.getVideoProgress() != null) { + progress.setVideoProgress(request.getVideoProgress()); + } + + StudentProgress savedProgress = progressRepository.save(progress); + + // Update enrollment progress percentage + updateEnrollmentProgress(enrollment); + + return StudentProgressResponse.fromEntity(savedProgress); + } + + @Transactional(readOnly = true) + public List getProgressByEnrollment(Long enrollmentId) { + log.debug("Fetching progress for enrollment: {}", enrollmentId); + + // Get current user + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + // Get enrollment + Enrollment enrollment = enrollmentRepository.findById(enrollmentId) + .orElseThrow(() -> new ResourceNotFoundException("Enrollment not found")); + + // Verify user owns the enrollment + if (!enrollment.getUser().getId().equals(user.getId())) { + throw new EventRegistrationException("You are not authorized to access this progress"); + } + + return progressRepository.findByEnrollmentId(enrollmentId).stream() + .map(StudentProgressResponse::fromEntity) + .collect(Collectors.toList()); + } + + @Transactional + public StudentProgressResponse markLessonComplete(Long enrollmentId, Long lessonId) { + log.info("Marking lesson as complete: enrollment {}, lesson {}", enrollmentId, lessonId); + StudentProgressRequest request = StudentProgressRequest.builder() + .lessonId(lessonId) + .completed(true) + .build(); + return updateProgress(enrollmentId, request); + } + + private void updateEnrollmentProgress(Enrollment enrollment) { + long totalLessons = enrollment.getCourse().getModules().stream() + .flatMap(module -> module.getLessons().stream()) + .count(); + + if (totalLessons == 0) { + return; + } + + long completedLessons = progressRepository.countCompletedLessonsByEnrollmentId(enrollment.getId()); + int progressPercentage = (int) ((completedLessons * 100) / totalLessons); + + enrollment.setProgressPercentage(progressPercentage); + + // Auto-complete enrollment if all lessons are completed + if (progressPercentage == 100 && enrollment.getStatus() == Enrollment.EnrollmentStatus.ACTIVE) { + enrollment.setStatus(Enrollment.EnrollmentStatus.COMPLETED); + enrollment.setCompletedAt(LocalDateTime.now()); + } + + enrollmentRepository.save(enrollment); + } +} + diff --git a/src/main/resources/db/migration/V5__create_lms_tables.sql b/src/main/resources/db/migration/V5__create_lms_tables.sql new file mode 100644 index 0000000..69e4a06 --- /dev/null +++ b/src/main/resources/db/migration/V5__create_lms_tables.sql @@ -0,0 +1,117 @@ +-- SUPAP Backend - Aula Virtual (LMS Module) +-- Phase 4: LMS Module - Create courses, modules, lessons, enrollments, and progress tables + +-- Courses table +CREATE TABLE courses ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) UNIQUE, + description TEXT, + objectives TEXT, + prerequisites TEXT, + instructor_id BIGINT REFERENCES users(id) ON DELETE SET NULL, + thumbnail_url VARCHAR(500), + preview_video_url VARCHAR(500), + level VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + duration_hours INTEGER, + price DECIMAL(10, 2), + member_price DECIMAL(10, 2), + regular_price DECIMAL(10, 2), + max_students INTEGER, + enrolled_count INTEGER DEFAULT 0, + start_date TIMESTAMP, + end_date TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Course modules table +CREATE TABLE course_modules ( + id BIGSERIAL PRIMARY KEY, + course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + display_order INTEGER DEFAULT 0, + duration_minutes INTEGER +); + +-- Lessons table +CREATE TABLE lessons ( + id BIGSERIAL PRIMARY KEY, + module_id BIGINT NOT NULL REFERENCES course_modules(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + content TEXT, + type VARCHAR(20) NOT NULL, + video_url VARCHAR(500), + video_duration INTEGER, + resource_urls TEXT, + display_order INTEGER DEFAULT 0, + is_free BOOLEAN NOT NULL DEFAULT FALSE +); + +-- Payments table +CREATE TABLE payments ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(20) NOT NULL, + reference_id BIGINT, + reference_type VARCHAR(50), + amount DECIMAL(10, 2) NOT NULL, + currency VARCHAR(3) NOT NULL DEFAULT 'UYU', + payment_method VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + transaction_id VARCHAR(100), + receipt_url VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP +); + +-- Enrollments table +CREATE TABLE enrollments ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING_PAYMENT', + enrolled_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP, + expires_at TIMESTAMP, + progress_percentage INTEGER DEFAULT 0, + payment_id BIGINT REFERENCES payments(id) ON DELETE SET NULL +); + +-- Student progress table +CREATE TABLE student_progress ( + id BIGSERIAL PRIMARY KEY, + enrollment_id BIGINT NOT NULL REFERENCES enrollments(id) ON DELETE CASCADE, + lesson_id BIGINT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + completed BOOLEAN NOT NULL DEFAULT FALSE, + video_progress INTEGER, + started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP, + last_accessed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for performance +CREATE INDEX idx_courses_slug ON courses(slug); +CREATE INDEX idx_courses_status ON courses(status); +CREATE INDEX idx_courses_level ON courses(level); +CREATE INDEX idx_courses_instructor ON courses(instructor_id); +CREATE INDEX idx_modules_course ON course_modules(course_id); +CREATE INDEX idx_modules_order ON course_modules(display_order); +CREATE INDEX idx_lessons_module ON lessons(module_id); +CREATE INDEX idx_lessons_order ON lessons(display_order); +CREATE INDEX idx_lessons_type ON lessons(type); +CREATE INDEX idx_payments_user ON payments(user_id); +CREATE INDEX idx_payments_status ON payments(status); +CREATE INDEX idx_payments_type ON payments(type); +CREATE INDEX idx_enrollments_user ON enrollments(user_id); +CREATE INDEX idx_enrollments_course ON enrollments(course_id); +CREATE INDEX idx_enrollments_status ON enrollments(status); +CREATE INDEX idx_progress_enrollment ON student_progress(enrollment_id); +CREATE INDEX idx_progress_lesson ON student_progress(lesson_id); +CREATE INDEX idx_progress_completed ON student_progress(completed); + +-- Unique constraint for enrollment (user can only enroll once per course) +CREATE UNIQUE INDEX idx_enrollments_user_course ON enrollments(user_id, course_id); +