From 941bf322e5c56f2d4f5eeee36735e2043d36a9f4 Mon Sep 17 00:00:00 2001 From: Hai Nguyen <2nguyen.work@gmail.com> Date: Fri, 21 Aug 2026 22:17:02 +0700 Subject: [PATCH 1/8] feat: update course entity & refactor package --- .../exception/GlobalExceptionHandler.java | 40 -------------- .../lms/api/controllers/CourseController.java | 48 ----------------- .../lms/application/dtos/CourseReqDto.java | 6 --- .../lms/application/dtos/CourseResDto.java | 17 ------ .../lms/application/mappers/CourseMapper.java | 20 ------- .../application/services/CourseService.java | 52 ------------------- .../backend/lms/domain/entities/Course.java | 24 --------- .../domain/repositories/CourseRepository.java | 19 ------- .../infrastructure/config/OpenApiConfig.java | 28 ---------- .../infrastructure/config/SecurityConfig.java | 25 --------- .../repositories/CourseJpaRepository.java | 9 ---- .../repositories/CourseRepositoryImpl.java | 35 ------------- src/main/resources/application.yaml | 6 +-- .../{V1__init.sql => V1__course_module.sql} | 0 14 files changed, 3 insertions(+), 326 deletions(-) delete mode 100644 src/main/java/aplp/backend/lms/api/common/exception/GlobalExceptionHandler.java delete mode 100644 src/main/java/aplp/backend/lms/api/controllers/CourseController.java delete mode 100644 src/main/java/aplp/backend/lms/application/dtos/CourseReqDto.java delete mode 100644 src/main/java/aplp/backend/lms/application/dtos/CourseResDto.java delete mode 100644 src/main/java/aplp/backend/lms/application/mappers/CourseMapper.java delete mode 100644 src/main/java/aplp/backend/lms/application/services/CourseService.java delete mode 100644 src/main/java/aplp/backend/lms/domain/entities/Course.java delete mode 100644 src/main/java/aplp/backend/lms/domain/repositories/CourseRepository.java delete mode 100644 src/main/java/aplp/backend/lms/infrastructure/config/OpenApiConfig.java delete mode 100644 src/main/java/aplp/backend/lms/infrastructure/config/SecurityConfig.java delete mode 100644 src/main/java/aplp/backend/lms/infrastructure/persistence/repositories/CourseJpaRepository.java delete mode 100644 src/main/java/aplp/backend/lms/infrastructure/persistence/repositories/CourseRepositoryImpl.java rename src/main/resources/db/migration/{V1__init.sql => V1__course_module.sql} (100%) diff --git a/src/main/java/aplp/backend/lms/api/common/exception/GlobalExceptionHandler.java b/src/main/java/aplp/backend/lms/api/common/exception/GlobalExceptionHandler.java deleted file mode 100644 index 90aa0cd..0000000 --- a/src/main/java/aplp/backend/lms/api/common/exception/GlobalExceptionHandler.java +++ /dev/null @@ -1,40 +0,0 @@ -package aplp.backend.lms.api.common.exception; - -import aplp.backend.core.common.exception.ResourceNotFoundException; -import aplp.backend.core.common.response.ErrorResponse; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -import java.time.LocalDateTime; - -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(ResourceNotFoundException.class) - public ResponseEntity handleNotFound( - ResourceNotFoundException ex - ) { - return ResponseEntity - .status(HttpStatus.NOT_FOUND) - .body(new ErrorResponse( - HttpStatus.NOT_FOUND.value(), - ex.getMessage(), - LocalDateTime.now() - )); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity handleException( - Exception ex - ) { - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse( - HttpStatus.INTERNAL_SERVER_ERROR.value(), - "Internal server error", - LocalDateTime.now() - )); - } -} diff --git a/src/main/java/aplp/backend/lms/api/controllers/CourseController.java b/src/main/java/aplp/backend/lms/api/controllers/CourseController.java deleted file mode 100644 index fcce627..0000000 --- a/src/main/java/aplp/backend/lms/api/controllers/CourseController.java +++ /dev/null @@ -1,48 +0,0 @@ -package aplp.backend.lms.api.controllers; - -import aplp.backend.lms.application.dtos.CourseReqDto; -import aplp.backend.lms.application.dtos.CourseResDto; -import aplp.backend.lms.application.services.CourseService; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping("/api/courses") -@RequiredArgsConstructor -public class CourseController { - - private final CourseService courseService; - - @GetMapping - public List getAll() { - return courseService.getAll(); - } - - @GetMapping("/{id}") - public CourseResDto getById(@PathVariable Long id) { - return courseService.getById(id); - } - - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - public CourseResDto create(@Valid @RequestBody CourseReqDto request) { - return courseService.create(request); - } - - @PutMapping("/{id}") - public CourseResDto update( - @PathVariable Long id, - @RequestBody CourseReqDto request) { - return courseService.update(id, request); - } - - @DeleteMapping("/{id}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void delete(@PathVariable Long id) { - courseService.delete(id); - } -} \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/application/dtos/CourseReqDto.java b/src/main/java/aplp/backend/lms/application/dtos/CourseReqDto.java deleted file mode 100644 index d966352..0000000 --- a/src/main/java/aplp/backend/lms/application/dtos/CourseReqDto.java +++ /dev/null @@ -1,6 +0,0 @@ -package aplp.backend.lms.application.dtos; - -public record CourseReqDto( - String title, - String description -) {} diff --git a/src/main/java/aplp/backend/lms/application/dtos/CourseResDto.java b/src/main/java/aplp/backend/lms/application/dtos/CourseResDto.java deleted file mode 100644 index 6841bbc..0000000 --- a/src/main/java/aplp/backend/lms/application/dtos/CourseResDto.java +++ /dev/null @@ -1,17 +0,0 @@ -package aplp.backend.lms.application.dtos; - -import aplp.backend.lms.domain.entities.Course; - -public record CourseResDto( - Long id, - String title, - String description -) { - public static CourseResDto from(Course course) { - return new CourseResDto( - course.getId(), - course.getTitle(), - course.getDescription() - ); - } -} \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/application/mappers/CourseMapper.java b/src/main/java/aplp/backend/lms/application/mappers/CourseMapper.java deleted file mode 100644 index 6bb6150..0000000 --- a/src/main/java/aplp/backend/lms/application/mappers/CourseMapper.java +++ /dev/null @@ -1,20 +0,0 @@ -package aplp.backend.lms.application.mappers; - -import aplp.backend.lms.application.dtos.CourseReqDto; -import aplp.backend.lms.application.dtos.CourseResDto; -import aplp.backend.lms.domain.entities.Course; -import org.mapstruct.Mapper; -import org.mapstruct.MappingTarget; - -@Mapper(componentModel = "spring") -public interface CourseMapper { - - CourseResDto toResponse(Course course); - - Course toEntity(CourseReqDto request); - - void updateEntity( - CourseReqDto request, - @MappingTarget Course course - ); -} diff --git a/src/main/java/aplp/backend/lms/application/services/CourseService.java b/src/main/java/aplp/backend/lms/application/services/CourseService.java deleted file mode 100644 index a84dbb2..0000000 --- a/src/main/java/aplp/backend/lms/application/services/CourseService.java +++ /dev/null @@ -1,52 +0,0 @@ -package aplp.backend.lms.application.services; - -import aplp.backend.core.common.exception.ResourceNotFoundException; -import aplp.backend.lms.application.dtos.CourseReqDto; -import aplp.backend.lms.application.dtos.CourseResDto; -import aplp.backend.lms.application.mappers.CourseMapper; -import aplp.backend.lms.domain.entities.Course; -import aplp.backend.lms.domain.repositories.CourseRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class CourseService { - - private final CourseRepository courseRepository; - private final CourseMapper courseMapper; - - public List getAll() { - return courseRepository.findAll() - .stream() - .map(CourseResDto::from) - .toList(); - } - - public CourseResDto getById(Long id) { - Course course = courseRepository.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Course not found")); - - return CourseResDto.from(course); - } - - public CourseResDto create(CourseReqDto request) { - Course course = courseMapper.toEntity(request); - return courseMapper.toResponse(courseRepository.save(course)); - } - - public CourseResDto update(Long id, CourseReqDto request) { - Course course = courseRepository.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Course not found")); - - courseMapper.updateEntity(request, course); - - return courseMapper.toResponse(courseRepository.save(course)); - } - - public void delete(Long id) { - courseRepository.deleteById(id); - } -} diff --git a/src/main/java/aplp/backend/lms/domain/entities/Course.java b/src/main/java/aplp/backend/lms/domain/entities/Course.java deleted file mode 100644 index d1eaf40..0000000 --- a/src/main/java/aplp/backend/lms/domain/entities/Course.java +++ /dev/null @@ -1,24 +0,0 @@ -package aplp.backend.lms.domain.entities; - -import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -@Entity -@Table(name = "courses") -@Getter -@Setter -@NoArgsConstructor -public class Course { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, length = 200) - private String title; - - @Column(length = 1000) - private String description; -} \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/domain/repositories/CourseRepository.java b/src/main/java/aplp/backend/lms/domain/repositories/CourseRepository.java deleted file mode 100644 index a854dad..0000000 --- a/src/main/java/aplp/backend/lms/domain/repositories/CourseRepository.java +++ /dev/null @@ -1,19 +0,0 @@ -package aplp.backend.lms.domain.repositories; - -import aplp.backend.lms.domain.entities.Course; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Optional; - -@Repository -public interface CourseRepository { - - Optional findById(Long id); - - List findAll(); - - Course save(Course course); - - void deleteById(Long id); -} \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/infrastructure/config/OpenApiConfig.java b/src/main/java/aplp/backend/lms/infrastructure/config/OpenApiConfig.java deleted file mode 100644 index 5165148..0000000 --- a/src/main/java/aplp/backend/lms/infrastructure/config/OpenApiConfig.java +++ /dev/null @@ -1,28 +0,0 @@ -package aplp.backend.lms.infrastructure.config; - -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; -import io.swagger.v3.oas.annotations.info.Info; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.security.SecurityScheme; -import org.springframework.context.annotation.Configuration; - -@Configuration -@OpenAPIDefinition( - info = @Info( - title = "APLP LMS API", - version = "v1", - description = "APLP Learning Management System API" - ), - security = { - @SecurityRequirement(name = "bearerAuth") - } -) -@SecurityScheme( - name = "bearerAuth", - type = SecuritySchemeType.HTTP, - bearerFormat = "JWT", - scheme = "bearer" -) -public class OpenApiConfig { -} diff --git a/src/main/java/aplp/backend/lms/infrastructure/config/SecurityConfig.java b/src/main/java/aplp/backend/lms/infrastructure/config/SecurityConfig.java deleted file mode 100644 index e1d72b7..0000000 --- a/src/main/java/aplp/backend/lms/infrastructure/config/SecurityConfig.java +++ /dev/null @@ -1,25 +0,0 @@ -package aplp.backend.lms.infrastructure.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -public class SecurityConfig { - - @Bean - public SecurityFilterChain securityFilterChain( - HttpSecurity http - ) throws Exception { - - http - .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() - ); - - return http.build(); - } -} diff --git a/src/main/java/aplp/backend/lms/infrastructure/persistence/repositories/CourseJpaRepository.java b/src/main/java/aplp/backend/lms/infrastructure/persistence/repositories/CourseJpaRepository.java deleted file mode 100644 index 05bef6c..0000000 --- a/src/main/java/aplp/backend/lms/infrastructure/persistence/repositories/CourseJpaRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package aplp.backend.lms.infrastructure.persistence.repositories; - -import aplp.backend.lms.domain.entities.Course; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface CourseJpaRepository extends JpaRepository { -} \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/infrastructure/persistence/repositories/CourseRepositoryImpl.java b/src/main/java/aplp/backend/lms/infrastructure/persistence/repositories/CourseRepositoryImpl.java deleted file mode 100644 index 605699a..0000000 --- a/src/main/java/aplp/backend/lms/infrastructure/persistence/repositories/CourseRepositoryImpl.java +++ /dev/null @@ -1,35 +0,0 @@ -package aplp.backend.lms.infrastructure.persistence.repositories; - -import aplp.backend.lms.domain.entities.Course; -import aplp.backend.lms.domain.repositories.CourseRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Optional; - -@Repository -@RequiredArgsConstructor -public class CourseRepositoryImpl implements CourseRepository { - private final CourseJpaRepository repository; - - @Override - public Optional findById(Long id) { - return repository.findById(id); - } - - @Override - public List findAll() { - return repository.findAll(); - } - - @Override - public Course save(Course course) { - return repository.save(course); - } - - @Override - public void deleteById(Long id) { - repository.deleteById(id); - } -} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 1a72aa8..a1ac880 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -3,13 +3,13 @@ spring: name: lms datasource: - url: jdbc:postgresql://localhost:5432/aplp + url: jdbc:postgresql://192.168.2.100:5432/aplp username: postgres password: Pa55w0rd jpa: hibernate: - ddl-auto: validate + ddl-auto: update properties: hibernate: default_schema: lms @@ -17,7 +17,7 @@ spring: show-sql: true flyway: - enabled: true + enabled: false default-schema: lms schemas: lms locations: classpath:db/migration diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__course_module.sql similarity index 100% rename from src/main/resources/db/migration/V1__init.sql rename to src/main/resources/db/migration/V1__course_module.sql From 6d4964782fab1bb6c36c522e551a2231cdfa6209 Mon Sep 17 00:00:00 2001 From: Hai Nguyen <2nguyen.work@gmail.com> Date: Tue, 25 Aug 2026 18:45:03 +0700 Subject: [PATCH 2/8] feat: update codes --- .../common/api/GlobalExceptionHandler.java | 40 ++++++++++++++ .../lms/common/config/JpaAuditingConfig.java | 30 +++++++++++ .../lms/common/config/OpenApiConfig.java | 28 ++++++++++ .../lms/common/security/SecurityConfig.java | 25 +++++++++ .../api/controller/CourseController.java | 48 +++++++++++++++++ .../course/application/dtos/CourseReqDto.java | 6 +++ .../course/application/dtos/CourseResDto.java | 17 ++++++ .../application/mappers/CourseMapper.java | 20 +++++++ .../application/services/CourseService.java | 52 +++++++++++++++++++ .../lms/course/domain/entities/Course.java | 48 +++++++++++++++++ .../domain/repositories/CourseRepository.java | 19 +++++++ .../persistence/CourseJpaRepository.java | 9 ++++ .../persistence/CourseRepositoryImpl.java | 35 +++++++++++++ .../db/migration/V2__course_audit_columns.sql | 7 +++ 14 files changed, 384 insertions(+) create mode 100644 src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java create mode 100644 src/main/java/aplp/backend/lms/common/config/JpaAuditingConfig.java create mode 100644 src/main/java/aplp/backend/lms/common/config/OpenApiConfig.java create mode 100644 src/main/java/aplp/backend/lms/common/security/SecurityConfig.java create mode 100644 src/main/java/aplp/backend/lms/course/api/controller/CourseController.java create mode 100644 src/main/java/aplp/backend/lms/course/application/dtos/CourseReqDto.java create mode 100644 src/main/java/aplp/backend/lms/course/application/dtos/CourseResDto.java create mode 100644 src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java create mode 100644 src/main/java/aplp/backend/lms/course/application/services/CourseService.java create mode 100644 src/main/java/aplp/backend/lms/course/domain/entities/Course.java create mode 100644 src/main/java/aplp/backend/lms/course/domain/repositories/CourseRepository.java create mode 100644 src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseJpaRepository.java create mode 100644 src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseRepositoryImpl.java create mode 100644 src/main/resources/db/migration/V2__course_audit_columns.sql diff --git a/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java b/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java new file mode 100644 index 0000000..20126cc --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java @@ -0,0 +1,40 @@ +package aplp.backend.lms.common.api; + +import aplp.backend.core.common.exception.ResourceNotFoundException; +import aplp.backend.core.common.response.ErrorResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity handleNotFound( + ResourceNotFoundException ex + ) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse( + HttpStatus.NOT_FOUND.value(), + ex.getMessage(), + LocalDateTime.now() + )); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException( + Exception ex + ) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + "Internal server error", + LocalDateTime.now() + )); + } +} diff --git a/src/main/java/aplp/backend/lms/common/config/JpaAuditingConfig.java b/src/main/java/aplp/backend/lms/common/config/JpaAuditingConfig.java new file mode 100644 index 0000000..7b01762 --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/config/JpaAuditingConfig.java @@ -0,0 +1,30 @@ +package aplp.backend.lms.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.AuditorAware; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +@Configuration +@EnableJpaAuditing(auditorAwareRef = "auditorProvider") +public class JpaAuditingConfig { + + @Bean + public AuditorAware auditorProvider() { + return () -> { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null + || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + return java.util.Optional.empty(); + } + if (authentication.getPrincipal() instanceof Long userId) { + return java.util.Optional.of(userId); + } + return java.util.Optional.empty(); + }; + } +} diff --git a/src/main/java/aplp/backend/lms/common/config/OpenApiConfig.java b/src/main/java/aplp/backend/lms/common/config/OpenApiConfig.java new file mode 100644 index 0000000..0c331cc --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/config/OpenApiConfig.java @@ -0,0 +1,28 @@ +package aplp.backend.lms.common.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = @Info( + title = "APLP LMS API", + version = "v1", + description = "APLP Learning Management System API" + ), + security = { + @SecurityRequirement(name = "bearerAuth") + } +) +@SecurityScheme( + name = "bearerAuth", + type = SecuritySchemeType.HTTP, + bearerFormat = "JWT", + scheme = "bearer" +) +public class OpenApiConfig { +} diff --git a/src/main/java/aplp/backend/lms/common/security/SecurityConfig.java b/src/main/java/aplp/backend/lms/common/security/SecurityConfig.java new file mode 100644 index 0000000..f21dcba --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/security/SecurityConfig.java @@ -0,0 +1,25 @@ +package aplp.backend.lms.common.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http + ) throws Exception { + + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ); + + return http.build(); + } +} diff --git a/src/main/java/aplp/backend/lms/course/api/controller/CourseController.java b/src/main/java/aplp/backend/lms/course/api/controller/CourseController.java new file mode 100644 index 0000000..fafb6e1 --- /dev/null +++ b/src/main/java/aplp/backend/lms/course/api/controller/CourseController.java @@ -0,0 +1,48 @@ +package aplp.backend.lms.course.api.controller; + +import aplp.backend.lms.course.application.dtos.CourseReqDto; +import aplp.backend.lms.course.application.dtos.CourseResDto; +import aplp.backend.lms.course.application.services.CourseService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/courses") +@RequiredArgsConstructor +public class CourseController { + + private final CourseService courseService; + + @GetMapping + public List getAll() { + return courseService.getAll(); + } + + @GetMapping("/{id}") + public CourseResDto getById(@PathVariable Long id) { + return courseService.getById(id); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public CourseResDto create(@Valid @RequestBody CourseReqDto request) { + return courseService.create(request); + } + + @PutMapping("/{id}") + public CourseResDto update( + @PathVariable Long id, + @RequestBody CourseReqDto request) { + return courseService.update(id, request); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Long id) { + courseService.delete(id); + } +} \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/course/application/dtos/CourseReqDto.java b/src/main/java/aplp/backend/lms/course/application/dtos/CourseReqDto.java new file mode 100644 index 0000000..e6c842c --- /dev/null +++ b/src/main/java/aplp/backend/lms/course/application/dtos/CourseReqDto.java @@ -0,0 +1,6 @@ +package aplp.backend.lms.course.application.dtos; + +public record CourseReqDto( + String title, + String description +) {} diff --git a/src/main/java/aplp/backend/lms/course/application/dtos/CourseResDto.java b/src/main/java/aplp/backend/lms/course/application/dtos/CourseResDto.java new file mode 100644 index 0000000..89ab149 --- /dev/null +++ b/src/main/java/aplp/backend/lms/course/application/dtos/CourseResDto.java @@ -0,0 +1,17 @@ +package aplp.backend.lms.course.application.dtos; + +import aplp.backend.lms.course.domain.entities.Course; + +public record CourseResDto( + Long id, + String title, + String description +) { + public static CourseResDto from(Course course) { + return new CourseResDto( + course.getId(), + course.getTitle(), + course.getDescription() + ); + } +} \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java b/src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java new file mode 100644 index 0000000..0a4dcc2 --- /dev/null +++ b/src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java @@ -0,0 +1,20 @@ +package aplp.backend.lms.course.application.mappers; + +import aplp.backend.lms.course.application.dtos.CourseReqDto; +import aplp.backend.lms.course.application.dtos.CourseResDto; +import aplp.backend.lms.course.domain.entities.Course; +import org.mapstruct.Mapper; +import org.mapstruct.MappingTarget; + +@Mapper(componentModel = "spring") +public interface CourseMapper { + + CourseResDto toResponse(Course course); + + Course toEntity(CourseReqDto request); + + void updateEntity( + CourseReqDto request, + @MappingTarget Course course + ); +} diff --git a/src/main/java/aplp/backend/lms/course/application/services/CourseService.java b/src/main/java/aplp/backend/lms/course/application/services/CourseService.java new file mode 100644 index 0000000..0b7d846 --- /dev/null +++ b/src/main/java/aplp/backend/lms/course/application/services/CourseService.java @@ -0,0 +1,52 @@ +package aplp.backend.lms.course.application.services; + +import aplp.backend.core.common.exception.ResourceNotFoundException; +import aplp.backend.lms.course.application.dtos.CourseReqDto; +import aplp.backend.lms.course.application.dtos.CourseResDto; +import aplp.backend.lms.course.application.mappers.CourseMapper; +import aplp.backend.lms.course.domain.entities.Course; +import aplp.backend.lms.course.domain.repositories.CourseRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CourseService { + + private final CourseRepository courseRepository; + private final CourseMapper courseMapper; + + public List getAll() { + return courseRepository.findAll() + .stream() + .map(CourseResDto::from) + .toList(); + } + + public CourseResDto getById(Long id) { + Course course = courseRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Course not found")); + + return CourseResDto.from(course); + } + + public CourseResDto create(CourseReqDto request) { + Course course = courseMapper.toEntity(request); + return courseMapper.toResponse(courseRepository.save(course)); + } + + public CourseResDto update(Long id, CourseReqDto request) { + Course course = courseRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Course not found")); + + courseMapper.updateEntity(request, course); + + return courseMapper.toResponse(courseRepository.save(course)); + } + + public void delete(Long id) { + courseRepository.deleteById(id); + } +} diff --git a/src/main/java/aplp/backend/lms/course/domain/entities/Course.java b/src/main/java/aplp/backend/lms/course/domain/entities/Course.java new file mode 100644 index 0000000..08d5ff4 --- /dev/null +++ b/src/main/java/aplp/backend/lms/course/domain/entities/Course.java @@ -0,0 +1,48 @@ +package aplp.backend.lms.course.domain.entities; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; + +@Entity +@Table(name = "courses") +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +@NoArgsConstructor +public class Course { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 200) + private String title; + + @Column(length = 1000) + private String description; + + @CreatedBy + @Column(name = "created_by", updatable = false) + private Long createdBy; + + @LastModifiedBy + @Column(name = "updated_by") + private Long updatedBy; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/course/domain/repositories/CourseRepository.java b/src/main/java/aplp/backend/lms/course/domain/repositories/CourseRepository.java new file mode 100644 index 0000000..dc140e3 --- /dev/null +++ b/src/main/java/aplp/backend/lms/course/domain/repositories/CourseRepository.java @@ -0,0 +1,19 @@ +package aplp.backend.lms.course.domain.repositories; + +import aplp.backend.lms.course.domain.entities.Course; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface CourseRepository { + + Optional findById(Long id); + + List findAll(); + + Course save(Course course); + + void deleteById(Long id); +} \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseJpaRepository.java b/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseJpaRepository.java new file mode 100644 index 0000000..001c4ad --- /dev/null +++ b/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseJpaRepository.java @@ -0,0 +1,9 @@ +package aplp.backend.lms.course.infrastructure.persistence; + +import aplp.backend.lms.course.domain.entities.Course; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CourseJpaRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseRepositoryImpl.java b/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseRepositoryImpl.java new file mode 100644 index 0000000..8e12132 --- /dev/null +++ b/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseRepositoryImpl.java @@ -0,0 +1,35 @@ +package aplp.backend.lms.course.infrastructure.persistence; + +import aplp.backend.lms.course.domain.entities.Course; +import aplp.backend.lms.course.domain.repositories.CourseRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class CourseRepositoryImpl implements CourseRepository { + private final CourseJpaRepository repository; + + @Override + public Optional findById(Long id) { + return repository.findById(id); + } + + @Override + public List findAll() { + return repository.findAll(); + } + + @Override + public Course save(Course course) { + return repository.save(course); + } + + @Override + public void deleteById(Long id) { + repository.deleteById(id); + } +} diff --git a/src/main/resources/db/migration/V2__course_audit_columns.sql b/src/main/resources/db/migration/V2__course_audit_columns.sql new file mode 100644 index 0000000..27f1181 --- /dev/null +++ b/src/main/resources/db/migration/V2__course_audit_columns.sql @@ -0,0 +1,7 @@ +-- Flyway Migration V2: Add audit columns to courses + +ALTER TABLE courses + ADD COLUMN IF NOT EXISTS created_by BIGINT, + ADD COLUMN IF NOT EXISTS updated_by BIGINT, + ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); From a48d6deb06bac3018394728a2dcff24594750f33 Mon Sep 17 00:00:00 2001 From: namdh861 Date: Thu, 3 Sep 2026 21:50:43 +0700 Subject: [PATCH 3/8] feat: add lesson --- .../lms/course/domain/entities/Course.java | 23 +------- .../api/controller/LessonController.java | 48 +++++++++++++++++ .../lesson/application/dtos/LessonReqDto.java | 17 ++++++ .../lesson/application/dtos/LessonResDto.java | 25 +++++++++ .../application/mappers/LessonMapper.java | 20 +++++++ .../application/services/LessonService.java | 52 +++++++++++++++++++ .../lms/lesson/domain/entities/Lesson.java | 26 ++++++++++ .../domain/repositories/LessonRepository.java | 19 +++++++ .../persistence/LessonJpaRepository.java | 9 ++++ .../persistence/LessonRepositoryImpl.java | 35 +++++++++++++ .../db/migration/V2__course_audit_columns.sql | 7 --- .../db/migration/V2__lesson_module.sql | 14 +++++ 12 files changed, 267 insertions(+), 28 deletions(-) create mode 100644 src/main/java/aplp/backend/lms/lesson/api/controller/LessonController.java create mode 100644 src/main/java/aplp/backend/lms/lesson/application/dtos/LessonReqDto.java create mode 100644 src/main/java/aplp/backend/lms/lesson/application/dtos/LessonResDto.java create mode 100644 src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java create mode 100644 src/main/java/aplp/backend/lms/lesson/application/services/LessonService.java create mode 100644 src/main/java/aplp/backend/lms/lesson/domain/entities/Lesson.java create mode 100644 src/main/java/aplp/backend/lms/lesson/domain/repositories/LessonRepository.java create mode 100644 src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonJpaRepository.java create mode 100644 src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java delete mode 100644 src/main/resources/db/migration/V2__course_audit_columns.sql create mode 100644 src/main/resources/db/migration/V2__lesson_module.sql diff --git a/src/main/java/aplp/backend/lms/course/domain/entities/Course.java b/src/main/java/aplp/backend/lms/course/domain/entities/Course.java index 08d5ff4..5414639 100644 --- a/src/main/java/aplp/backend/lms/course/domain/entities/Course.java +++ b/src/main/java/aplp/backend/lms/course/domain/entities/Course.java @@ -1,5 +1,6 @@ package aplp.backend.lms.course.domain.entities; +import aplp.backend.core.common.entity.BaseEntity; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; @@ -18,31 +19,11 @@ import java.time.Instant; @Getter @Setter @NoArgsConstructor -public class Course { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; +public class Course extends BaseEntity { @Column(nullable = false, length = 200) private String title; @Column(length = 1000) private String description; - - @CreatedBy - @Column(name = "created_by", updatable = false) - private Long createdBy; - - @LastModifiedBy - @Column(name = "updated_by") - private Long updatedBy; - - @CreatedDate - @Column(name = "created_at", nullable = false, updatable = false) - private Instant createdAt; - - @LastModifiedDate - @Column(name = "updated_at", nullable = false) - private Instant updatedAt; } \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/lesson/api/controller/LessonController.java b/src/main/java/aplp/backend/lms/lesson/api/controller/LessonController.java new file mode 100644 index 0000000..17af4e7 --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/api/controller/LessonController.java @@ -0,0 +1,48 @@ +package aplp.backend.lms.lesson.api.controller; + +import aplp.backend.lms.lesson.application.dtos.LessonReqDto; +import aplp.backend.lms.lesson.application.dtos.LessonResDto; +import aplp.backend.lms.lesson.application.services.LessonService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/lessons") +@RequiredArgsConstructor +public class LessonController { + + private final LessonService lessonService; + + @GetMapping + public List getAll() { + return lessonService.getAll(); + } + + @GetMapping("/{id}") + public LessonResDto getById(@PathVariable Long id) { + return lessonService.getById(id); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public LessonResDto create(@Valid @RequestBody LessonReqDto request) { + return lessonService.create(request); + } + + @PutMapping("/{id}") + public LessonResDto update( + @PathVariable Long id, + @Valid @RequestBody LessonReqDto request) { + return lessonService.update(id, request); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Long id) { + lessonService.delete(id); + } +} diff --git a/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonReqDto.java b/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonReqDto.java new file mode 100644 index 0000000..8c4af4e --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonReqDto.java @@ -0,0 +1,17 @@ +package aplp.backend.lms.lesson.application.dtos; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record LessonReqDto( + @NotBlank(message = "Lesson code is required") + @Size(max = 50, message = "Lesson code must not exceed 50 characters") + String lessonCode, + + @NotBlank(message = "Lesson name is required") + @Size(max = 200, message = "Lesson name must not exceed 200 characters") + String lessonName, + + @Size(max = 1000, message = "Description must not exceed 1000 characters") + String description +) {} diff --git a/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonResDto.java b/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonResDto.java new file mode 100644 index 0000000..fc7774b --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonResDto.java @@ -0,0 +1,25 @@ +package aplp.backend.lms.lesson.application.dtos; + +import aplp.backend.lms.lesson.domain.entities.Lesson; + +import java.time.LocalDateTime; + +public record LessonResDto( + Long id, + String lessonCode, + String lessonName, + String description, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static LessonResDto from(Lesson lesson) { + return new LessonResDto( + lesson.getId(), + lesson.getLessonCode(), + lesson.getLessonName(), + lesson.getDescription(), + lesson.getCreatedAt(), + lesson.getUpdatedAt() + ); + } +} diff --git a/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java b/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java new file mode 100644 index 0000000..a655a76 --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java @@ -0,0 +1,20 @@ +package aplp.backend.lms.lesson.application.mappers; + +import aplp.backend.lms.lesson.application.dtos.LessonReqDto; +import aplp.backend.lms.lesson.application.dtos.LessonResDto; +import aplp.backend.lms.lesson.domain.entities.Lesson; +import org.mapstruct.Mapper; +import org.mapstruct.MappingTarget; + +@Mapper(componentModel = "spring") +public interface LessonMapper { + + LessonResDto toResponse(Lesson lesson); + + Lesson toEntity(LessonReqDto request); + + void updateEntity( + LessonReqDto request, + @MappingTarget Lesson lesson + ); +} diff --git a/src/main/java/aplp/backend/lms/lesson/application/services/LessonService.java b/src/main/java/aplp/backend/lms/lesson/application/services/LessonService.java new file mode 100644 index 0000000..babdc95 --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/application/services/LessonService.java @@ -0,0 +1,52 @@ +package aplp.backend.lms.lesson.application.services; + +import aplp.backend.core.common.exception.ResourceNotFoundException; +import aplp.backend.lms.lesson.application.dtos.LessonReqDto; +import aplp.backend.lms.lesson.application.dtos.LessonResDto; +import aplp.backend.lms.lesson.application.mappers.LessonMapper; +import aplp.backend.lms.lesson.domain.entities.Lesson; +import aplp.backend.lms.lesson.domain.repositories.LessonRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class LessonService { + + private final LessonRepository lessonRepository; + private final LessonMapper lessonMapper; + + public List getAll() { + return lessonRepository.findAll() + .stream() + .map(LessonResDto::from) + .toList(); + } + + public LessonResDto getById(Long id) { + Lesson lesson = lessonRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Lesson not found")); + + return LessonResDto.from(lesson); + } + + public LessonResDto create(LessonReqDto request) { + Lesson lesson = lessonMapper.toEntity(request); + return lessonMapper.toResponse(lessonRepository.save(lesson)); + } + + public LessonResDto update(Long id, LessonReqDto request) { + Lesson lesson = lessonRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Lesson not found")); + + lessonMapper.updateEntity(request, lesson); + + return lessonMapper.toResponse(lessonRepository.save(lesson)); + } + + public void delete(Long id) { + lessonRepository.deleteById(id); + } +} diff --git a/src/main/java/aplp/backend/lms/lesson/domain/entities/Lesson.java b/src/main/java/aplp/backend/lms/lesson/domain/entities/Lesson.java new file mode 100644 index 0000000..dd69bf3 --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/domain/entities/Lesson.java @@ -0,0 +1,26 @@ +package aplp.backend.lms.lesson.domain.entities; + +import aplp.backend.core.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "lessons") +@Getter +@Setter +@NoArgsConstructor +public class Lesson extends BaseEntity { + + @Column(name = "lesson_code", nullable = false, length = 50, unique = true) + private String lessonCode; + + @Column(name = "lesson_name", nullable = false, length = 200) + private String lessonName; + + @Column(length = 1000) + private String description; +} diff --git a/src/main/java/aplp/backend/lms/lesson/domain/repositories/LessonRepository.java b/src/main/java/aplp/backend/lms/lesson/domain/repositories/LessonRepository.java new file mode 100644 index 0000000..808dcd6 --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/domain/repositories/LessonRepository.java @@ -0,0 +1,19 @@ +package aplp.backend.lms.lesson.domain.repositories; + +import aplp.backend.lms.lesson.domain.entities.Lesson; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface LessonRepository { + + Optional findById(Long id); + + List findAll(); + + Lesson save(Lesson lesson); + + void deleteById(Long id); +} diff --git a/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonJpaRepository.java b/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonJpaRepository.java new file mode 100644 index 0000000..e0ae1ae --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonJpaRepository.java @@ -0,0 +1,9 @@ +package aplp.backend.lms.lesson.infrastructure.persistence; + +import aplp.backend.lms.lesson.domain.entities.Lesson; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface LessonJpaRepository extends JpaRepository { +} diff --git a/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java b/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java new file mode 100644 index 0000000..87efb8b --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java @@ -0,0 +1,35 @@ +package aplp.backend.lms.lesson.infrastructure.persistence; + +import aplp.backend.lms.lesson.domain.entities.Lesson; +import aplp.backend.lms.lesson.domain.repositories.LessonRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class LessonRepositoryImpl implements LessonRepository { + private final LessonJpaRepository repository; + + @Override + public Optional findById(Long id) { + return repository.findById(id); + } + + @Override + public List findAll() { + return repository.findAll(); + } + + @Override + public Lesson save(Lesson lesson) { + return repository.save(lesson); + } + + @Override + public void deleteById(Long id) { + repository.deleteById(id); + } +} diff --git a/src/main/resources/db/migration/V2__course_audit_columns.sql b/src/main/resources/db/migration/V2__course_audit_columns.sql deleted file mode 100644 index 27f1181..0000000 --- a/src/main/resources/db/migration/V2__course_audit_columns.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Flyway Migration V2: Add audit columns to courses - -ALTER TABLE courses - ADD COLUMN IF NOT EXISTS created_by BIGINT, - ADD COLUMN IF NOT EXISTS updated_by BIGINT, - ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); diff --git a/src/main/resources/db/migration/V2__lesson_module.sql b/src/main/resources/db/migration/V2__lesson_module.sql new file mode 100644 index 0000000..bebe101 --- /dev/null +++ b/src/main/resources/db/migration/V2__lesson_module.sql @@ -0,0 +1,14 @@ +-- Flyway Migration V3: Create lessons table + +CREATE TABLE IF NOT EXISTS lessons ( + id BIGSERIAL PRIMARY KEY, + lesson_code VARCHAR(50) NOT NULL UNIQUE, + lesson_name VARCHAR(200) NOT NULL, + description VARCHAR(1000), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by VARCHAR(255), + updated_by VARCHAR(255), + deleted_at TIMESTAMPTZ, + deleted_by VARCHAR(255) +); From bba3ed33657a3f94c8d26f2ab0c15657b9d7b0c6 Mon Sep 17 00:00:00 2001 From: Hai Nguyen <2nguyen.work@gmail.com> Date: Wed, 9 Sep 2026 22:17:56 +0700 Subject: [PATCH 4/8] feat: update codes --- .../lms/common/security/SecurityConfig.java | 22 +++++++++++++++++ .../course/application/dtos/CourseReqDto.java | 21 +++++++++++++++- .../course/application/dtos/CourseResDto.java | 18 ++++++++++++-- .../lms/course/domain/entities/Course.java | 18 +++++++++----- .../lesson/application/dtos/LessonReqDto.java | 24 +++++++++++++------ .../lesson/application/dtos/LessonResDto.java | 14 +++++++---- .../application/mappers/LessonMapper.java | 11 +++++++++ .../lms/lesson/domain/entities/Lesson.java | 23 ++++++++++++------ .../domain/repositories/LessonRepository.java | 2 ++ .../persistence/LessonJpaRepository.java | 3 +++ .../persistence/LessonRepositoryImpl.java | 5 ++++ src/main/resources/application.yaml | 2 +- 12 files changed, 135 insertions(+), 28 deletions(-) diff --git a/src/main/java/aplp/backend/lms/common/security/SecurityConfig.java b/src/main/java/aplp/backend/lms/common/security/SecurityConfig.java index f21dcba..1f903eb 100644 --- a/src/main/java/aplp/backend/lms/common/security/SecurityConfig.java +++ b/src/main/java/aplp/backend/lms/common/security/SecurityConfig.java @@ -5,16 +5,38 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration public class SecurityConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of( + "http://localhost:5173", + "http://localhost:3000" + )); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + @Bean public SecurityFilterChain securityFilterChain( HttpSecurity http ) throws Exception { http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth .anyRequest().permitAll() diff --git a/src/main/java/aplp/backend/lms/course/application/dtos/CourseReqDto.java b/src/main/java/aplp/backend/lms/course/application/dtos/CourseReqDto.java index e6c842c..00867f1 100644 --- a/src/main/java/aplp/backend/lms/course/application/dtos/CourseReqDto.java +++ b/src/main/java/aplp/backend/lms/course/application/dtos/CourseReqDto.java @@ -1,6 +1,25 @@ package aplp.backend.lms.course.application.dtos; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + public record CourseReqDto( + @NotBlank(message = "Course code is required") + @Size(max = 50, message = "Course code must not exceed 50 characters") + String courseCode, + + @NotBlank(message = "Title is required") + @Size(max = 200, message = "Title must not exceed 200 characters") String title, - String description + + @Size(max = 255, message = "Slug must not exceed 255 characters") + String slug, + + @Size(max = 1000, message = "Description must not exceed 1000 characters") + String description, + + @Size(max = 500, message = "Image URL must not exceed 500 characters") + String image, + + Boolean isPublished ) {} diff --git a/src/main/java/aplp/backend/lms/course/application/dtos/CourseResDto.java b/src/main/java/aplp/backend/lms/course/application/dtos/CourseResDto.java index 89ab149..67da8f3 100644 --- a/src/main/java/aplp/backend/lms/course/application/dtos/CourseResDto.java +++ b/src/main/java/aplp/backend/lms/course/application/dtos/CourseResDto.java @@ -2,16 +2,30 @@ package aplp.backend.lms.course.application.dtos; import aplp.backend.lms.course.domain.entities.Course; +import java.time.LocalDateTime; + public record CourseResDto( Long id, + String courseCode, String title, - String description + String slug, + String description, + String image, + boolean isPublished, + LocalDateTime createdAt, + LocalDateTime updatedAt ) { public static CourseResDto from(Course course) { return new CourseResDto( course.getId(), + course.getCourseCode(), course.getTitle(), - course.getDescription() + course.getSlug(), + course.getDescription(), + course.getImage(), + course.isPublished(), + course.getCreatedAt(), + course.getUpdatedAt() ); } } \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/course/domain/entities/Course.java b/src/main/java/aplp/backend/lms/course/domain/entities/Course.java index 5414639..5e62fef 100644 --- a/src/main/java/aplp/backend/lms/course/domain/entities/Course.java +++ b/src/main/java/aplp/backend/lms/course/domain/entities/Course.java @@ -5,14 +5,8 @@ import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import org.springframework.data.annotation.CreatedBy; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedBy; -import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import java.time.Instant; - @Entity @Table(name = "courses") @EntityListeners(AuditingEntityListener.class) @@ -21,9 +15,21 @@ import java.time.Instant; @NoArgsConstructor public class Course extends BaseEntity { + @Column(name = "course_code", nullable = false, length = 50, unique = true) + private String courseCode; + @Column(nullable = false, length = 200) private String title; + @Column(nullable = false, unique = true, length = 255) + private String slug; + @Column(length = 1000) private String description; + + @Column(length = 500) + private String image; + + @Column(name = "is_published", nullable = false) + private boolean isPublished; } \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonReqDto.java b/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonReqDto.java index 8c4af4e..bdd5a27 100644 --- a/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonReqDto.java +++ b/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonReqDto.java @@ -1,17 +1,27 @@ package aplp.backend.lms.lesson.application.dtos; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; public record LessonReqDto( - @NotBlank(message = "Lesson code is required") - @Size(max = 50, message = "Lesson code must not exceed 50 characters") - String lessonCode, + @NotNull(message = "Course ID is required") + Long courseId, - @NotBlank(message = "Lesson name is required") - @Size(max = 200, message = "Lesson name must not exceed 200 characters") - String lessonName, + @NotBlank(message = "Title is required") + @Size(max = 200, message = "Title must not exceed 200 characters") + String title, + + @Size(max = 255, message = "Slug must not exceed 255 characters") + String slug, @Size(max = 1000, message = "Description must not exceed 1000 characters") - String description + String description, + + @NotNull(message = "Position is required") + @Min(value = 1, message = "Position must be at least 1") + Integer position, + + Boolean isPublished ) {} diff --git a/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonResDto.java b/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonResDto.java index fc7774b..e12e039 100644 --- a/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonResDto.java +++ b/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonResDto.java @@ -6,18 +6,24 @@ import java.time.LocalDateTime; public record LessonResDto( Long id, - String lessonCode, - String lessonName, + Long courseId, + String title, + String slug, String description, + int position, + boolean isPublished, LocalDateTime createdAt, LocalDateTime updatedAt ) { public static LessonResDto from(Lesson lesson) { return new LessonResDto( lesson.getId(), - lesson.getLessonCode(), - lesson.getLessonName(), + lesson.getCourse().getId(), + lesson.getTitle(), + lesson.getSlug(), lesson.getDescription(), + lesson.getPosition(), + lesson.isPublished(), lesson.getCreatedAt(), lesson.getUpdatedAt() ); diff --git a/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java b/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java index a655a76..288d809 100644 --- a/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java +++ b/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java @@ -1,20 +1,31 @@ package aplp.backend.lms.lesson.application.mappers; +import aplp.backend.lms.course.domain.entities.Course; import aplp.backend.lms.lesson.application.dtos.LessonReqDto; import aplp.backend.lms.lesson.application.dtos.LessonResDto; import aplp.backend.lms.lesson.domain.entities.Lesson; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; @Mapper(componentModel = "spring") public interface LessonMapper { + @Mapping(source = "course.id", target = "courseId") LessonResDto toResponse(Lesson lesson); + @Mapping(source = "courseId", target = "course") Lesson toEntity(LessonReqDto request); void updateEntity( LessonReqDto request, @MappingTarget Lesson lesson ); + + default Course mapCourse(Long courseId) { + if (courseId == null) return null; + Course course = new Course(); + course.setId(courseId); + return course; + } } diff --git a/src/main/java/aplp/backend/lms/lesson/domain/entities/Lesson.java b/src/main/java/aplp/backend/lms/lesson/domain/entities/Lesson.java index dd69bf3..9408016 100644 --- a/src/main/java/aplp/backend/lms/lesson/domain/entities/Lesson.java +++ b/src/main/java/aplp/backend/lms/lesson/domain/entities/Lesson.java @@ -1,9 +1,8 @@ package aplp.backend.lms.lesson.domain.entities; import aplp.backend.core.common.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; +import aplp.backend.lms.course.domain.entities.Course; +import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -15,12 +14,22 @@ import lombok.Setter; @NoArgsConstructor public class Lesson extends BaseEntity { - @Column(name = "lesson_code", nullable = false, length = 50, unique = true) - private String lessonCode; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "course_id", nullable = false) + private Course course; - @Column(name = "lesson_name", nullable = false, length = 200) - private String lessonName; + @Column(nullable = false, length = 200) + private String title; + + @Column(nullable = false, unique = true, length = 255) + private String slug; @Column(length = 1000) private String description; + + @Column(nullable = false) + private int position; + + @Column(name = "is_published", nullable = false) + private boolean isPublished; } diff --git a/src/main/java/aplp/backend/lms/lesson/domain/repositories/LessonRepository.java b/src/main/java/aplp/backend/lms/lesson/domain/repositories/LessonRepository.java index 808dcd6..5fb1675 100644 --- a/src/main/java/aplp/backend/lms/lesson/domain/repositories/LessonRepository.java +++ b/src/main/java/aplp/backend/lms/lesson/domain/repositories/LessonRepository.java @@ -13,6 +13,8 @@ public interface LessonRepository { List findAll(); + List findByCourseId(Long courseId); + Lesson save(Lesson lesson); void deleteById(Long id); diff --git a/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonJpaRepository.java b/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonJpaRepository.java index e0ae1ae..c2b5e50 100644 --- a/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonJpaRepository.java +++ b/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonJpaRepository.java @@ -4,6 +4,9 @@ import aplp.backend.lms.lesson.domain.entities.Lesson; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface LessonJpaRepository extends JpaRepository { + List findByCourseIdOrderByPositionAsc(Long courseId); } diff --git a/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java b/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java index 87efb8b..7571e9d 100644 --- a/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java +++ b/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java @@ -23,6 +23,11 @@ public class LessonRepositoryImpl implements LessonRepository { return repository.findAll(); } + @Override + public List findByCourseId(Long courseId) { + return repository.findByCourseIdOrderByPositionAsc(courseId); + } + @Override public Lesson save(Lesson lesson) { return repository.save(lesson); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index a1ac880..52b7bd4 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -3,7 +3,7 @@ spring: name: lms datasource: - url: jdbc:postgresql://192.168.2.100:5432/aplp + url: jdbc:postgresql://pgsql.koda.id.vn:5432/aplp username: postgres password: Pa55w0rd From dc43e34271e6c61264eed0e9156e0149ea11d61e Mon Sep 17 00:00:00 2001 From: Hai Nguyen <2nguyen.work@gmail.com> Date: Tue, 15 Sep 2026 22:34:28 +0700 Subject: [PATCH 5/8] feat: add 3 components: course, lesson, section --- .serena/.gitignore | 2 + .serena/project.yml | 171 ++++++++++++++++++ .../common/api/GlobalExceptionHandler.java | 53 ++++-- .../api/controller/CourseController.java | 29 +-- .../application/mappers/CourseMapper.java | 4 + .../application/services/CourseService.java | 9 +- .../persistence/CourseRepositoryImpl.java | 6 +- .../api/controller/LessonController.java | 29 +-- .../application/mappers/LessonMapper.java | 3 + .../application/services/LessonService.java | 9 +- .../persistence/LessonRepositoryImpl.java | 6 +- .../api/controller/SectionController.java | 56 ++++++ .../application/dtos/SectionReqDto.java | 29 +++ .../application/dtos/SectionResDto.java | 34 ++++ .../application/mappers/SectionMapper.java | 31 ++++ .../application/services/SectionService.java | 61 +++++++ .../lms/section/domain/entities/Section.java | 43 +++++ .../lms/section/domain/enums/SectionType.java | 21 +++ .../repositories/SectionRepository.java | 21 +++ .../persistence/SectionJpaRepository.java | 12 ++ .../persistence/SectionRepositoryImpl.java | 42 +++++ .../V3__update_course_add_fields.sql | 16 ++ ...update_lesson_add_course_fk_and_fields.sql | 26 +++ .../migration/V5__create_section_module.sql | 23 +++ 24 files changed, 690 insertions(+), 46 deletions(-) create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100644 src/main/java/aplp/backend/lms/section/api/controller/SectionController.java create mode 100644 src/main/java/aplp/backend/lms/section/application/dtos/SectionReqDto.java create mode 100644 src/main/java/aplp/backend/lms/section/application/dtos/SectionResDto.java create mode 100644 src/main/java/aplp/backend/lms/section/application/mappers/SectionMapper.java create mode 100644 src/main/java/aplp/backend/lms/section/application/services/SectionService.java create mode 100644 src/main/java/aplp/backend/lms/section/domain/entities/Section.java create mode 100644 src/main/java/aplp/backend/lms/section/domain/enums/SectionType.java create mode 100644 src/main/java/aplp/backend/lms/section/domain/repositories/SectionRepository.java create mode 100644 src/main/java/aplp/backend/lms/section/infrastructure/persistence/SectionJpaRepository.java create mode 100644 src/main/java/aplp/backend/lms/section/infrastructure/persistence/SectionRepositoryImpl.java create mode 100644 src/main/resources/db/migration/V3__update_course_add_fields.sql create mode 100644 src/main/resources/db/migration/V4__update_lesson_add_course_fk_and_fields.sql create mode 100644 src/main/resources/db/migration/V5__create_section_module.sql diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..c620a7b --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,171 @@ +# the name by which the project can be referenced within Serena/when chatting with the LLM. +project_name: "aplp.backend.lms" + +# list of language servers to start when using the LSP backend; choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart deno +# elixir elm erlang fortran fsharp +# gdscript gleam go groovy haskell +# haxe hlsl html java json +# julia julia_fatou kotlin latex lean4 +# lua luau markdown matlab msl +# nextflow nix ocaml pascal perl +# php php_phpactor php_phpantom powershell python +# python_basedpyright python_jedi python_pyrefly python_ty qml +# r rego ruby ruby_solargraph rust +# scala scss solidity svelte swift +# systemverilog terraform toml typescript typescript_vts +# vue wolfram yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of the LanguageServerId enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# - External Python adapters may add further registered IDs; install the adapter package first +# and then use its ID here, for example: example +# Special requirements: +# Some language servers require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple language servers, the first language server that supports a given file will be used for that file. +# The first language server is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +language_servers: +- java + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180.0 + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings +ls_specific_settings: {} + +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: +- "." + +# list of additional workspace folder paths for cross-package reference support. +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tools. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +ls_additional_workspace_folders: [] + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases. +# Example: +# ignored_paths: +# - "examples/**" +# - ".worktrees/**" +# - "**/bin/**" +# - "**/obj/**" +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] diff --git a/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java b/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java index 20126cc..0415d91 100644 --- a/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java +++ b/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java @@ -1,40 +1,69 @@ package aplp.backend.lms.common.api; +import aplp.backend.core.common.exception.ErrorCode; import aplp.backend.core.common.exception.ResourceNotFoundException; -import aplp.backend.core.common.response.ErrorResponse; +import aplp.backend.core.common.response.ApiResponse; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.time.LocalDateTime; +import java.util.stream.Collectors; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(ResourceNotFoundException.class) - public ResponseEntity handleNotFound( + public ResponseEntity> handleNotFound( ResourceNotFoundException ex ) { return ResponseEntity .status(HttpStatus.NOT_FOUND) - .body(new ErrorResponse( - HttpStatus.NOT_FOUND.value(), - ex.getMessage(), - LocalDateTime.now() + .body(ApiResponse.error( + HttpStatus.NOT_FOUND.name(), + ex.getMessage() )); } @ExceptionHandler(Exception.class) - public ResponseEntity handleException( + public ResponseEntity> handleException( Exception ex ) { return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse( - HttpStatus.INTERNAL_SERVER_ERROR.value(), - "Internal server error", - LocalDateTime.now() + .body(ApiResponse.error( + ErrorCode.INTERNAL_ERROR.name(), + "Internal server error" + )); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation( + MethodArgumentNotValidException ex + ) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(e -> e.getField() + ": " + e.getDefaultMessage()) + .sorted() + .collect(Collectors.joining("; ")); + return ResponseEntity + .badRequest() + .body(ApiResponse.error( + ErrorCode.VALIDATION_FAILED.name(), + message + )); + } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity> handleConflict( + DataIntegrityViolationException ex + ) { + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(ApiResponse.error( + HttpStatus.CONFLICT.name(), + "Duplicate or invalid data: " + ex.getMostSpecificCause().getMessage() )); } } diff --git a/src/main/java/aplp/backend/lms/course/api/controller/CourseController.java b/src/main/java/aplp/backend/lms/course/api/controller/CourseController.java index fafb6e1..66d7833 100644 --- a/src/main/java/aplp/backend/lms/course/api/controller/CourseController.java +++ b/src/main/java/aplp/backend/lms/course/api/controller/CourseController.java @@ -1,48 +1,51 @@ package aplp.backend.lms.course.api.controller; +import aplp.backend.core.common.response.ApiResponse; import aplp.backend.lms.course.application.dtos.CourseReqDto; import aplp.backend.lms.course.application.dtos.CourseResDto; import aplp.backend.lms.course.application.services.CourseService; import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/courses") -@RequiredArgsConstructor public class CourseController { private final CourseService courseService; + public CourseController(CourseService courseService) { + this.courseService = courseService; + } + @GetMapping - public List getAll() { - return courseService.getAll(); + public ResponseEntity>> getAll() { + return ResponseEntity.ok(ApiResponse.ok(courseService.getAll())); } @GetMapping("/{id}") - public CourseResDto getById(@PathVariable Long id) { - return courseService.getById(id); + public ResponseEntity> getById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.ok(courseService.getById(id))); } @PostMapping - @ResponseStatus(HttpStatus.CREATED) - public CourseResDto create(@Valid @RequestBody CourseReqDto request) { - return courseService.create(request); + public ResponseEntity> create(@Valid @RequestBody CourseReqDto request) { + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(courseService.create(request))); } @PutMapping("/{id}") - public CourseResDto update( + public ResponseEntity> update( @PathVariable Long id, @RequestBody CourseReqDto request) { - return courseService.update(id, request); + return ResponseEntity.ok(ApiResponse.ok(courseService.update(id, request))); } @DeleteMapping("/{id}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void delete(@PathVariable Long id) { + public ResponseEntity> delete(@PathVariable Long id) { courseService.delete(id); + return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null)); } } \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java b/src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java index 0a4dcc2..45742ab 100644 --- a/src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java +++ b/src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java @@ -4,15 +4,19 @@ import aplp.backend.lms.course.application.dtos.CourseReqDto; import aplp.backend.lms.course.application.dtos.CourseResDto; import aplp.backend.lms.course.domain.entities.Course; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; @Mapper(componentModel = "spring") public interface CourseMapper { + @Mapping(target = "isPublished", source = "published") CourseResDto toResponse(Course course); + @Mapping(target = "published", source = "isPublished") Course toEntity(CourseReqDto request); + @Mapping(target = "published", source = "isPublished") void updateEntity( CourseReqDto request, @MappingTarget Course course diff --git a/src/main/java/aplp/backend/lms/course/application/services/CourseService.java b/src/main/java/aplp/backend/lms/course/application/services/CourseService.java index 0b7d846..6d98ea0 100644 --- a/src/main/java/aplp/backend/lms/course/application/services/CourseService.java +++ b/src/main/java/aplp/backend/lms/course/application/services/CourseService.java @@ -6,18 +6,21 @@ import aplp.backend.lms.course.application.dtos.CourseResDto; import aplp.backend.lms.course.application.mappers.CourseMapper; import aplp.backend.lms.course.domain.entities.Course; import aplp.backend.lms.course.domain.repositories.CourseRepository; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.util.List; @Service -@RequiredArgsConstructor public class CourseService { private final CourseRepository courseRepository; private final CourseMapper courseMapper; + public CourseService(CourseRepository courseRepository, CourseMapper courseMapper) { + this.courseRepository = courseRepository; + this.courseMapper = courseMapper; + } + public List getAll() { return courseRepository.findAll() .stream() @@ -47,6 +50,8 @@ public class CourseService { } public void delete(Long id) { + courseRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Course not found")); courseRepository.deleteById(id); } } diff --git a/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseRepositoryImpl.java b/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseRepositoryImpl.java index 8e12132..32a3e78 100644 --- a/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseRepositoryImpl.java +++ b/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseRepositoryImpl.java @@ -2,17 +2,19 @@ package aplp.backend.lms.course.infrastructure.persistence; import aplp.backend.lms.course.domain.entities.Course; import aplp.backend.lms.course.domain.repositories.CourseRepository; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; @Repository -@RequiredArgsConstructor public class CourseRepositoryImpl implements CourseRepository { private final CourseJpaRepository repository; + public CourseRepositoryImpl(CourseJpaRepository repository) { + this.repository = repository; + } + @Override public Optional findById(Long id) { return repository.findById(id); diff --git a/src/main/java/aplp/backend/lms/lesson/api/controller/LessonController.java b/src/main/java/aplp/backend/lms/lesson/api/controller/LessonController.java index 17af4e7..12280d8 100644 --- a/src/main/java/aplp/backend/lms/lesson/api/controller/LessonController.java +++ b/src/main/java/aplp/backend/lms/lesson/api/controller/LessonController.java @@ -1,48 +1,51 @@ package aplp.backend.lms.lesson.api.controller; +import aplp.backend.core.common.response.ApiResponse; import aplp.backend.lms.lesson.application.dtos.LessonReqDto; import aplp.backend.lms.lesson.application.dtos.LessonResDto; import aplp.backend.lms.lesson.application.services.LessonService; import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/lessons") -@RequiredArgsConstructor public class LessonController { private final LessonService lessonService; + public LessonController(LessonService lessonService) { + this.lessonService = lessonService; + } + @GetMapping - public List getAll() { - return lessonService.getAll(); + public ResponseEntity>> getAll() { + return ResponseEntity.ok(ApiResponse.ok(lessonService.getAll())); } @GetMapping("/{id}") - public LessonResDto getById(@PathVariable Long id) { - return lessonService.getById(id); + public ResponseEntity> getById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.ok(lessonService.getById(id))); } @PostMapping - @ResponseStatus(HttpStatus.CREATED) - public LessonResDto create(@Valid @RequestBody LessonReqDto request) { - return lessonService.create(request); + public ResponseEntity> create(@Valid @RequestBody LessonReqDto request) { + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(lessonService.create(request))); } @PutMapping("/{id}") - public LessonResDto update( + public ResponseEntity> update( @PathVariable Long id, @Valid @RequestBody LessonReqDto request) { - return lessonService.update(id, request); + return ResponseEntity.ok(ApiResponse.ok(lessonService.update(id, request))); } @DeleteMapping("/{id}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void delete(@PathVariable Long id) { + public ResponseEntity> delete(@PathVariable Long id) { lessonService.delete(id); + return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null)); } } diff --git a/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java b/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java index 288d809..3527687 100644 --- a/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java +++ b/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java @@ -12,11 +12,14 @@ import org.mapstruct.MappingTarget; public interface LessonMapper { @Mapping(source = "course.id", target = "courseId") + @Mapping(target = "isPublished", source = "published") LessonResDto toResponse(Lesson lesson); @Mapping(source = "courseId", target = "course") + @Mapping(target = "published", source = "isPublished") Lesson toEntity(LessonReqDto request); + @Mapping(target = "published", source = "isPublished") void updateEntity( LessonReqDto request, @MappingTarget Lesson lesson diff --git a/src/main/java/aplp/backend/lms/lesson/application/services/LessonService.java b/src/main/java/aplp/backend/lms/lesson/application/services/LessonService.java index babdc95..0cbd6e0 100644 --- a/src/main/java/aplp/backend/lms/lesson/application/services/LessonService.java +++ b/src/main/java/aplp/backend/lms/lesson/application/services/LessonService.java @@ -6,18 +6,21 @@ import aplp.backend.lms.lesson.application.dtos.LessonResDto; import aplp.backend.lms.lesson.application.mappers.LessonMapper; import aplp.backend.lms.lesson.domain.entities.Lesson; import aplp.backend.lms.lesson.domain.repositories.LessonRepository; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.util.List; @Service -@RequiredArgsConstructor public class LessonService { private final LessonRepository lessonRepository; private final LessonMapper lessonMapper; + public LessonService(LessonRepository lessonRepository, LessonMapper lessonMapper) { + this.lessonRepository = lessonRepository; + this.lessonMapper = lessonMapper; + } + public List getAll() { return lessonRepository.findAll() .stream() @@ -47,6 +50,8 @@ public class LessonService { } public void delete(Long id) { + lessonRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Lesson not found")); lessonRepository.deleteById(id); } } diff --git a/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java b/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java index 7571e9d..917e9cc 100644 --- a/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java +++ b/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java @@ -2,17 +2,19 @@ package aplp.backend.lms.lesson.infrastructure.persistence; import aplp.backend.lms.lesson.domain.entities.Lesson; import aplp.backend.lms.lesson.domain.repositories.LessonRepository; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; @Repository -@RequiredArgsConstructor public class LessonRepositoryImpl implements LessonRepository { private final LessonJpaRepository repository; + public LessonRepositoryImpl(LessonJpaRepository repository) { + this.repository = repository; + } + @Override public Optional findById(Long id) { return repository.findById(id); diff --git a/src/main/java/aplp/backend/lms/section/api/controller/SectionController.java b/src/main/java/aplp/backend/lms/section/api/controller/SectionController.java new file mode 100644 index 0000000..fdf0627 --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/api/controller/SectionController.java @@ -0,0 +1,56 @@ +package aplp.backend.lms.section.api.controller; + +import aplp.backend.core.common.response.ApiResponse; +import aplp.backend.lms.section.application.dtos.SectionReqDto; +import aplp.backend.lms.section.application.dtos.SectionResDto; +import aplp.backend.lms.section.application.services.SectionService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/sections") +public class SectionController { + + private final SectionService sectionService; + + public SectionController(SectionService sectionService) { + this.sectionService = sectionService; + } + + @GetMapping + public ResponseEntity>> getAll() { + return ResponseEntity.ok(ApiResponse.ok(sectionService.getAll())); + } + + @GetMapping("/{id}") + public ResponseEntity> getById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.ok(sectionService.getById(id))); + } + + @GetMapping("/by-lesson/{lessonId}") + public ResponseEntity>> getByLessonId(@PathVariable Long lessonId) { + return ResponseEntity.ok(ApiResponse.ok(sectionService.getByLessonId(lessonId))); + } + + @PostMapping + public ResponseEntity> create(@Valid @RequestBody SectionReqDto request) { + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(sectionService.create(request))); + } + + @PutMapping("/{id}") + public ResponseEntity> update( + @PathVariable Long id, + @Valid @RequestBody SectionReqDto request) { + return ResponseEntity.ok(ApiResponse.ok(sectionService.update(id, request))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable Long id) { + sectionService.delete(id); + return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null)); + } +} diff --git a/src/main/java/aplp/backend/lms/section/application/dtos/SectionReqDto.java b/src/main/java/aplp/backend/lms/section/application/dtos/SectionReqDto.java new file mode 100644 index 0000000..ed8979b --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/application/dtos/SectionReqDto.java @@ -0,0 +1,29 @@ +package aplp.backend.lms.section.application.dtos; + +import aplp.backend.lms.section.domain.enums.SectionType; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record SectionReqDto( + @NotNull(message = "Lesson ID is required") + Long lessonId, + + @NotBlank(message = "Title is required") + @Size(max = 255, message = "Title must not exceed 255 characters") + String title, + + @NotNull(message = "Type is required") + SectionType type, + + @NotNull(message = "Position is required") + @Min(value = 1, message = "Position must be at least 1") + Integer position, + + String content, + + Integer durationSeconds, + + Boolean isFreePreview +) {} \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/section/application/dtos/SectionResDto.java b/src/main/java/aplp/backend/lms/section/application/dtos/SectionResDto.java new file mode 100644 index 0000000..23059c5 --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/application/dtos/SectionResDto.java @@ -0,0 +1,34 @@ +package aplp.backend.lms.section.application.dtos; + +import aplp.backend.lms.section.domain.entities.Section; +import aplp.backend.lms.section.domain.enums.SectionType; + +import java.time.LocalDateTime; + +public record SectionResDto( + Long id, + Long lessonId, + String title, + SectionType type, + int position, + String content, + int durationSeconds, + boolean isFreePreview, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static SectionResDto from(Section section) { + return new SectionResDto( + section.getId(), + section.getLesson().getId(), + section.getTitle(), + section.getType(), + section.getPosition(), + section.getContent(), + section.getDurationSeconds(), + section.isFreePreview(), + section.getCreatedAt(), + section.getUpdatedAt() + ); + } +} diff --git a/src/main/java/aplp/backend/lms/section/application/mappers/SectionMapper.java b/src/main/java/aplp/backend/lms/section/application/mappers/SectionMapper.java new file mode 100644 index 0000000..e026da7 --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/application/mappers/SectionMapper.java @@ -0,0 +1,31 @@ +package aplp.backend.lms.section.application.mappers; + +import aplp.backend.lms.lesson.domain.entities.Lesson; +import aplp.backend.lms.section.application.dtos.SectionReqDto; +import aplp.backend.lms.section.application.dtos.SectionResDto; +import aplp.backend.lms.section.domain.entities.Section; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; + +@Mapper(componentModel = "spring") +public interface SectionMapper { + + @Mapping(source = "lesson.id", target = "lessonId") + SectionResDto toResponse(Section section); + + @Mapping(source = "lessonId", target = "lesson") + Section toEntity(SectionReqDto request); + + void updateEntity( + SectionReqDto request, + @MappingTarget Section section + ); + + default Lesson mapLesson(Long lessonId) { + if (lessonId == null) return null; + Lesson lesson = new Lesson(); + lesson.setId(lessonId); + return lesson; + } +} diff --git a/src/main/java/aplp/backend/lms/section/application/services/SectionService.java b/src/main/java/aplp/backend/lms/section/application/services/SectionService.java new file mode 100644 index 0000000..d74eb6c --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/application/services/SectionService.java @@ -0,0 +1,61 @@ +package aplp.backend.lms.section.application.services; + +import aplp.backend.core.common.exception.ResourceNotFoundException; +import aplp.backend.lms.section.application.dtos.SectionReqDto; +import aplp.backend.lms.section.application.dtos.SectionResDto; +import aplp.backend.lms.section.application.mappers.SectionMapper; +import aplp.backend.lms.section.domain.entities.Section; +import aplp.backend.lms.section.domain.repositories.SectionRepository; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class SectionService { + + private final SectionRepository sectionRepository; + private final SectionMapper sectionMapper; + + public SectionService(SectionRepository sectionRepository, SectionMapper sectionMapper) { + this.sectionRepository = sectionRepository; + this.sectionMapper = sectionMapper; + } + + public List getAll() { + return sectionRepository.findAll() + .stream() + .map(SectionResDto::from) + .toList(); + } + + public SectionResDto getById(Long id) { + Section section = sectionRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Section not found")); + return SectionResDto.from(section); + } + + public List getByLessonId(Long lessonId) { + return sectionRepository.findByLessonId(lessonId) + .stream() + .map(SectionResDto::from) + .toList(); + } + + public SectionResDto create(SectionReqDto request) { + Section section = sectionMapper.toEntity(request); + return sectionMapper.toResponse(sectionRepository.save(section)); + } + + public SectionResDto update(Long id, SectionReqDto request) { + Section section = sectionRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Section not found")); + sectionMapper.updateEntity(request, section); + return sectionMapper.toResponse(sectionRepository.save(section)); + } + + public void delete(Long id) { + sectionRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Section not found")); + sectionRepository.deleteById(id); + } +} diff --git a/src/main/java/aplp/backend/lms/section/domain/entities/Section.java b/src/main/java/aplp/backend/lms/section/domain/entities/Section.java new file mode 100644 index 0000000..eaf1597 --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/domain/entities/Section.java @@ -0,0 +1,43 @@ +package aplp.backend.lms.section.domain.entities; + +import aplp.backend.core.common.entity.BaseEntity; +import aplp.backend.lms.lesson.domain.entities.Lesson; +import aplp.backend.lms.section.domain.enums.SectionType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Table(name = "sections") +@Getter +@Setter +@NoArgsConstructor +public class Section extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "lesson_id", nullable = false) + private Lesson lesson; + + @Column(nullable = false, length = 255) + private String title; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SectionType type; + + @Column(nullable = false) + private int position; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private String content; + + @Column(name = "duration_seconds") + private int durationSeconds; + + @Column(name = "is_free_preview", nullable = false) + private boolean isFreePreview; +} diff --git a/src/main/java/aplp/backend/lms/section/domain/enums/SectionType.java b/src/main/java/aplp/backend/lms/section/domain/enums/SectionType.java new file mode 100644 index 0000000..81f7ad7 --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/domain/enums/SectionType.java @@ -0,0 +1,21 @@ +package aplp.backend.lms.section.domain.enums; + +import com.fasterxml.jackson.annotation.JsonValue; + +public enum SectionType { + TEXT("text"), + VIDEO("video"), + QUIZ("quiz"), + EMBED("embed"); + + private final String value; + + SectionType(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } +} diff --git a/src/main/java/aplp/backend/lms/section/domain/repositories/SectionRepository.java b/src/main/java/aplp/backend/lms/section/domain/repositories/SectionRepository.java new file mode 100644 index 0000000..109b59e --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/domain/repositories/SectionRepository.java @@ -0,0 +1,21 @@ +package aplp.backend.lms.section.domain.repositories; + +import aplp.backend.lms.section.domain.entities.Section; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface SectionRepository { + + Optional
findById(Long id); + + List
findAll(); + + List
findByLessonId(Long lessonId); + + Section save(Section section); + + void deleteById(Long id); +} diff --git a/src/main/java/aplp/backend/lms/section/infrastructure/persistence/SectionJpaRepository.java b/src/main/java/aplp/backend/lms/section/infrastructure/persistence/SectionJpaRepository.java new file mode 100644 index 0000000..1f24462 --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/infrastructure/persistence/SectionJpaRepository.java @@ -0,0 +1,12 @@ +package aplp.backend.lms.section.infrastructure.persistence; + +import aplp.backend.lms.section.domain.entities.Section; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SectionJpaRepository extends JpaRepository { + List
findByLessonIdOrderByPositionAsc(Long lessonId); +} diff --git a/src/main/java/aplp/backend/lms/section/infrastructure/persistence/SectionRepositoryImpl.java b/src/main/java/aplp/backend/lms/section/infrastructure/persistence/SectionRepositoryImpl.java new file mode 100644 index 0000000..c0085d3 --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/infrastructure/persistence/SectionRepositoryImpl.java @@ -0,0 +1,42 @@ +package aplp.backend.lms.section.infrastructure.persistence; + +import aplp.backend.lms.section.domain.entities.Section; +import aplp.backend.lms.section.domain.repositories.SectionRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class SectionRepositoryImpl implements SectionRepository { + private final SectionJpaRepository repository; + + public SectionRepositoryImpl(SectionJpaRepository repository) { + this.repository = repository; + } + + @Override + public Optional
findById(Long id) { + return repository.findById(id); + } + + @Override + public List
findAll() { + return repository.findAll(); + } + + @Override + public List
findByLessonId(Long lessonId) { + return repository.findByLessonIdOrderByPositionAsc(lessonId); + } + + @Override + public Section save(Section section) { + return repository.save(section); + } + + @Override + public void deleteById(Long id) { + repository.deleteById(id); + } +} diff --git a/src/main/resources/db/migration/V3__update_course_add_fields.sql b/src/main/resources/db/migration/V3__update_course_add_fields.sql new file mode 100644 index 0000000..5465a5f --- /dev/null +++ b/src/main/resources/db/migration/V3__update_course_add_fields.sql @@ -0,0 +1,16 @@ +-- Flyway Migration V3: Update courses table +-- Add course_code, slug, image, is_published + +ALTER TABLE courses ADD COLUMN course_code VARCHAR(50); +ALTER TABLE courses ADD COLUMN slug VARCHAR(255); +ALTER TABLE courses ADD COLUMN image VARCHAR(500); +ALTER TABLE courses ADD COLUMN is_published BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE courses ADD CONSTRAINT uq_course_code UNIQUE (course_code); +ALTER TABLE courses ADD CONSTRAINT uq_course_slug UNIQUE (slug); + +UPDATE courses SET slug = LOWER(REPLACE(REPLACE(TRIM(title), ' ', '-'), '.', '')); +UPDATE courses SET course_code = 'CRS-' || id; + +ALTER TABLE courses ALTER COLUMN course_code SET NOT NULL; +ALTER TABLE courses ALTER COLUMN slug SET NOT NULL; diff --git a/src/main/resources/db/migration/V4__update_lesson_add_course_fk_and_fields.sql b/src/main/resources/db/migration/V4__update_lesson_add_course_fk_and_fields.sql new file mode 100644 index 0000000..f290129 --- /dev/null +++ b/src/main/resources/db/migration/V4__update_lesson_add_course_fk_and_fields.sql @@ -0,0 +1,26 @@ +-- Flyway Migration V4: Update lessons table +-- Add course_id FK, slug, position, is_published; remove lesson_code + +ALTER TABLE lessons ADD COLUMN course_id BIGINT; +ALTER TABLE lessons ADD COLUMN slug VARCHAR(255); +ALTER TABLE lessons ADD COLUMN position INT; +ALTER TABLE lessons ADD COLUMN is_published BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE lessons RENAME COLUMN lesson_name TO title; + +UPDATE lessons SET slug = LOWER(REPLACE(REPLACE(TRIM(title), ' ', '-'), '.', '')); + +ALTER TABLE lessons ALTER COLUMN course_id SET NOT NULL; +ALTER TABLE lessons ALTER COLUMN slug SET NOT NULL; +ALTER TABLE lessons ALTER COLUMN position SET NOT NULL; + +ALTER TABLE lessons ADD CONSTRAINT fk_lesson_course + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE; + +ALTER TABLE lessons ADD CONSTRAINT uq_course_lesson_position UNIQUE (course_id, position); +ALTER TABLE lessons ADD CONSTRAINT uq_course_lesson_slug UNIQUE (course_id, slug); + +ALTER TABLE lessons DROP CONSTRAINT IF EXISTS lessons_lesson_code_key; +ALTER TABLE lessons DROP COLUMN lesson_code; + +CREATE INDEX idx_lessons_course_id ON lessons(course_id); diff --git a/src/main/resources/db/migration/V5__create_section_module.sql b/src/main/resources/db/migration/V5__create_section_module.sql new file mode 100644 index 0000000..1b280ff --- /dev/null +++ b/src/main/resources/db/migration/V5__create_section_module.sql @@ -0,0 +1,23 @@ +-- Flyway Migration V5: Create sections table + +CREATE TYPE section_type AS ENUM ('text', 'video', 'quiz', 'embed'); + +CREATE TABLE sections ( + id BIGSERIAL PRIMARY KEY, + lesson_id BIGINT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + type section_type NOT NULL DEFAULT 'text', + position INT NOT NULL, + content JSONB, + duration_seconds INT DEFAULT 0, + is_free_preview BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by VARCHAR(255), + updated_by VARCHAR(255), + deleted_at TIMESTAMPTZ, + deleted_by VARCHAR(255), + CONSTRAINT uq_lesson_section_position UNIQUE (lesson_id, position) +); + +CREATE INDEX idx_sections_lesson_id ON sections(lesson_id); From 7f3fd7d1e671a32e0f0f0b5dc70a68b3fbf9aa9d Mon Sep 17 00:00:00 2001 From: namdh861 Date: Tue, 15 Sep 2026 23:08:38 +0700 Subject: [PATCH 6/8] feat: add Minio --- .serena/.gitignore | 2 - .serena/project.yml | 171 ------------------ AGENTS.md | 55 ++++++ pom.xml | 5 + .../common/api/GlobalExceptionHandler.java | 11 ++ .../lms/common/storage/FileController.java | 29 +++ .../lms/common/storage/FileResDto.java | 8 + .../common/storage/FileStorageService.java | 81 +++++++++ .../lms/common/storage/MinioConfig.java | 61 +++++++ .../lms/common/storage/StorageProperties.java | 12 ++ src/main/resources/application-prod.yaml | 11 ++ src/main/resources/application.yaml | 14 +- .../storage/FileStorageServiceTest.java | 28 +++ 13 files changed, 314 insertions(+), 174 deletions(-) delete mode 100644 .serena/.gitignore delete mode 100644 .serena/project.yml create mode 100644 AGENTS.md create mode 100644 src/main/java/aplp/backend/lms/common/storage/FileController.java create mode 100644 src/main/java/aplp/backend/lms/common/storage/FileResDto.java create mode 100644 src/main/java/aplp/backend/lms/common/storage/FileStorageService.java create mode 100644 src/main/java/aplp/backend/lms/common/storage/MinioConfig.java create mode 100644 src/main/java/aplp/backend/lms/common/storage/StorageProperties.java create mode 100644 src/main/resources/application-prod.yaml create mode 100644 src/test/java/aplp/backend/lms/common/storage/FileStorageServiceTest.java diff --git a/.serena/.gitignore b/.serena/.gitignore deleted file mode 100644 index 2e510af..0000000 --- a/.serena/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/cache -/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml deleted file mode 100644 index c620a7b..0000000 --- a/.serena/project.yml +++ /dev/null @@ -1,171 +0,0 @@ -# the name by which the project can be referenced within Serena/when chatting with the LLM. -project_name: "aplp.backend.lms" - -# list of language servers to start when using the LSP backend; choose from: -# ada al angular ansible bash -# bsl clojure cpp cpp_ccls crystal -# csharp csharp_omnisharp cue dart deno -# elixir elm erlang fortran fsharp -# gdscript gleam go groovy haskell -# haxe hlsl html java json -# julia julia_fatou kotlin latex lean4 -# lua luau markdown matlab msl -# nextflow nix ocaml pascal perl -# php php_phpactor php_phpantom powershell python -# python_basedpyright python_jedi python_pyrefly python_ty qml -# r rego ruby ruby_solargraph rust -# scala scss solidity svelte swift -# systemverilog terraform toml typescript typescript_vts -# vue wolfram yaml zig -# (This list may be outdated; generated with scripts/print_language_list.py; -# For the current list, see values of the LanguageServerId enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) -# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) -# Note: -# - For C, use cpp -# - For JavaScript, use typescript -# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) -# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) -# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH) -# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) -# - For Free Pascal/Lazarus, use pascal -# - External Python adapters may add further registered IDs; install the adapter package first -# and then use its ID here, for example: example -# Special requirements: -# Some language servers require additional setup/installations. -# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers -# When using multiple language servers, the first language server that supports a given file will be used for that file. -# The first language server is the default language and the respective language server will be used as a fallback. -# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. -language_servers: -- java - -# the encoding used by text files in the project -# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings -encoding: "utf-8" - -# optional shell command to run before the language backend (LSP or JetBrains) is initialised. -# the command runs in the project root directory and is only executed if the project is trusted -# (see trusted_project_path_patterns in the global configuration). -# serena waits for the command to exit: a non-zero exit code is logged as an error but does not -# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety -# backstop for non-terminating commands; on expiry the process is killed and activation continues. -# example: activation_command: "npx nx run-many -t build" -activation_command: - -# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). -# must be a positive number. -activation_command_timeout: 180.0 - -# line ending convention to use when writing source files. -# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) -# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. -line_ending: - -# The language backend to use for this project. -# If not set, the global setting from serena_config.yml is used. -# Valid values: LSP, JetBrains -# Note: the backend is fixed at startup. If a project with a different backend -# is activated post-init, an error will be returned. -language_backend: - -# whether to use project's .gitignore files to ignore files -ignore_all_files_in_gitignore: true - -# advanced configuration option allowing to configure language server-specific options. -# Maps the language key to the options. -# The settings are considered only if the project is trusted (see global configuration to define trusted projects). -# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings -ls_specific_settings: {} - -# list of workspace folder paths (LSP backend only). -# These folders will be used to build up Serena's symbol index. -# Paths must be within the project root and should thus be relative to the project root. -# Furthermore, the paths should not be filtered by ignore settings. -# Default setting: The entire project root folder (".") is considered. -# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. -# ls_workspace_folders: -# - "./subproject1" -# - "./subproject2" -ls_workspace_folders: -- "." - -# list of additional workspace folder paths for cross-package reference support. -# Paths can be absolute or relative to the project root. -# Each folder is registered as an LSP workspace folder, enabling language servers to discover -# symbols and references across package boundaries, but these folders are not indexed by Serena, -# i.e. the respective symbols will not be found using Serena's symbol search tools. -# Example: -# additional_workspace_folders: -# - ../sibling-package -# - ../shared-lib -ls_additional_workspace_folders: [] - -# list of additional paths to ignore in this project. -# Same syntax as gitignore, so you can use * and **. -# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases. -# Example: -# ignored_paths: -# - "examples/**" -# - ".worktrees/**" -# - "**/bin/**" -# - "**/obj/**" -# Note: global ignored_paths from serena_config.yml are also applied additively. -ignored_paths: [] - -# whether the project is in read-only mode -# If set to true, all editing tools will be disabled and attempts to use them will result in an error -# Added on 2025-04-18 -read_only: false - -# list of tool names to exclude. -# This extends the existing exclusions (e.g. from the global configuration) -# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html -excluded_tools: [] - -# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). -# This extends the existing inclusions (e.g. from the global configuration). -# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html -included_optional_tools: [] - -# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. -# This cannot be combined with non-empty excluded_tools or included_optional_tools. -# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html -fixed_tools: [] - -# list of mode names that are to be activated by default, overriding the setting in the global configuration. -# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. -# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. -# Otherwise, this overrides the setting from the global configuration (serena_config.yml). -# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply -# for this project. -# This setting can, in turn, be overridden by CLI parameters (--mode). -# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes -default_modes: - -# list of mode names to be activated additionally for this project, e.g. ["query-projects"] -# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. -# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes -added_modes: - -# initial prompt for the project. It will always be given to the LLM upon activating the project -# (contrary to the memories, which are loaded on demand). -initial_prompt: "" - -# time budget (seconds) per tool call for the retrieval of additional symbol information -# such as docstrings or parameter information. -# This overrides the corresponding setting in the global configuration; see the documentation there. -# If null or missing, use the setting from the global configuration. -symbol_info_budget: - -# list of regex patterns which, when matched, mark a memory entry as read‑only. -# Extends the list from the global configuration, merging the two lists. -read_only_memory_patterns: [] - -# list of regex patterns for memories to completely ignore. -# Matching memories will not appear in list_memories or activate_project output -# and cannot be accessed via read_memory or write_memory. -# To access ignored memory files, use the read_file tool on the raw file path. -# Extends the list from the global configuration, merging the two lists. -# Example: ["_archive/.*", "_episodes/.*"] -ignored_memory_patterns: [] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e02a1c1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# AGENTS.md + +Spring Boot **4.1.0** / Java 17 REST backend for an LMS (PostgreSQL). Root package `aplp.backend.lms`. + +## Commands + +- Build: `./mvnw -DskipTests package` +- Run: `./mvnw spring-boot:run` +- All tests: `./mvnw test` +- Single test: `./mvnw test -Dtest=LmsApplicationTests` +- Requires JDK 17. No linter, formatter, or checkstyle is configured — don't invent one. + +## Build gotcha: GitLab Maven registry + +`aplp.backend:core:1.0-SNAPSHOT` is not built here; it resolves from the GitLab package registry (repository id `gitlab-maven` in `pom.xml`). A fresh checkout needs a token for server id `gitlab-maven` in `~/.m2/settings.xml` (or the artifact cached in `~/.m2/repository`). Credentials are not in the repo. + +## `aplp.backend.core` shared types + +Not defined in this repo; import them instead of recreating: + +- `BaseEntity` — `@MappedSuperclass` for every entity: `id`, `createdAt/By`, `updatedAt/By`, `deletedAt/By`; timestamps set via `@PrePersist`/`@PreUpdate`. +- `ApiResponse` — `ok(data)`, `ok(message, data)`, `error(code, message)`. +- `PagedResponse` — `of(content, page, size, totalElements)`. +- `ErrorCode`, `DomainException`, `ResourceNotFoundException`, `TokenHasher.sha256Hex`. + +## Architecture + +Vertical slices per feature under `aplp.backend.lms.` (currently `course`, `lesson`, `section`); `common/` holds cross-cutting `api`, `config`, `security`. Layers per feature: + +- `api/controller` — `@RestController`, returns `ResponseEntity>` +- `application/services`, `application/dtos` (Java records), `application/mappers` (MapStruct) +- `domain/entities`, `domain/enums`, `domain/repositories` (plain interfaces) +- `infrastructure/persistence` — `*RepositoryImpl` implements the domain repo and delegates to a Spring Data `*JpaRepository` + +Conventions: + +- Entities extend `BaseEntity`, use Lombok `@Getter/@Setter/@NoArgsConstructor`, `@Table` snake_case. +- Controllers stay thin; services throw `ResourceNotFoundException`; `GlobalExceptionHandler` maps exceptions to `ApiResponse`. +- Mappers are interfaces annotated `@Mapper(componentModel = "spring")`; Lombok/MapStruct annotation processing is already wired in `pom.xml`. +- Add derived queries to `*JpaRepository` and expose them through the domain repo interface. + +## Database + +- PostgreSQL, Hibernate default schema `lms`; datasource hardcoded in `src/main/resources/application.yaml`. +- **`spring.flyway.enabled: false` + `spring.jpa.hibernate.ddl-auto: update`**: Hibernate maintains the schema at runtime. Scripts in `db/migration` (`V{n}__desc.sql`) are history only and are NOT applied on startup. A schema change needs both the entity field and a migration; don't assume Flyway runs. +- jsonb columns use `@JdbcTypeCode(SqlTypes.JSON)` on a `String` field. + +## Security + +- `SecurityConfig` disables CSRF and `permitAll`s every request — endpoints are currently unauthenticated despite OpenAPI declaring a `bearerAuth` JWT scheme (Swagger UI at `/swagger-ui`). +- `JpaAuditingConfig` reads the current user from `SecurityContextHolder` and expects `Authentication.getPrincipal()` to be a `Long` userId. + +## Testing + +Only `LmsApplicationTests` (`@SpringBootTest contextLoads`) exists. There is no `src/test/resources` or test profile, so tests use the real `application.yaml` datasource and require access to the remote DB — `./mvnw test` fails without it. diff --git a/pom.xml b/pom.xml index a02711a..3880ae1 100644 --- a/pom.xml +++ b/pom.xml @@ -108,6 +108,11 @@ springdoc-openapi-starter-webmvc-ui 3.1.0 + + io.minio + minio + 8.5.17 + aplp.backend diff --git a/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java b/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java index 0415d91..8cfecd3 100644 --- a/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java +++ b/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package aplp.backend.lms.common.api; +import aplp.backend.core.common.exception.DomainException; import aplp.backend.core.common.exception.ErrorCode; import aplp.backend.core.common.exception.ResourceNotFoundException; import aplp.backend.core.common.response.ApiResponse; @@ -27,6 +28,16 @@ public class GlobalExceptionHandler { )); } + @ExceptionHandler(DomainException.class) + public ResponseEntity> handleDomainException(DomainException ex) { + return ResponseEntity + .status(ex.code().status()) + .body(ApiResponse.error( + ex.code().name(), + ex.getMessage() + )); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleException( Exception ex diff --git a/src/main/java/aplp/backend/lms/common/storage/FileController.java b/src/main/java/aplp/backend/lms/common/storage/FileController.java new file mode 100644 index 0000000..0cbbddf --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/storage/FileController.java @@ -0,0 +1,29 @@ +package aplp.backend.lms.common.storage; + +import aplp.backend.core.common.response.ApiResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/files") +public class FileController { + + private final FileStorageService fileStorageService; + + public FileController(FileStorageService fileStorageService) { + this.fileStorageService = fileStorageService; + } + + @PostMapping + public ResponseEntity> upload( + @RequestParam("file") MultipartFile file, + @RequestParam(value = "folder", required = false, defaultValue = "general") String folder) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.ok(fileStorageService.upload(file, folder))); + } +} diff --git a/src/main/java/aplp/backend/lms/common/storage/FileResDto.java b/src/main/java/aplp/backend/lms/common/storage/FileResDto.java new file mode 100644 index 0000000..0868ba1 --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/storage/FileResDto.java @@ -0,0 +1,8 @@ +package aplp.backend.lms.common.storage; + +public record FileResDto( + String url, + String objectKey, + long size, + String contentType +) {} diff --git a/src/main/java/aplp/backend/lms/common/storage/FileStorageService.java b/src/main/java/aplp/backend/lms/common/storage/FileStorageService.java new file mode 100644 index 0000000..0f1f2d8 --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/storage/FileStorageService.java @@ -0,0 +1,81 @@ +package aplp.backend.lms.common.storage; + +import aplp.backend.core.common.exception.DomainException; +import aplp.backend.core.common.exception.ErrorCode; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Locale; +import java.util.UUID; + +@Service +public class FileStorageService { + + private final MinioClient minioClient; + private final StorageProperties properties; + + public FileStorageService(MinioClient minioClient, StorageProperties properties) { + this.minioClient = minioClient; + this.properties = properties; + } + + public FileResDto upload(MultipartFile file, String folder) { + if (file == null || file.isEmpty()) { + throw new DomainException(ErrorCode.INVALID_ARGUMENT, "File is required"); + } + + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new DomainException(ErrorCode.INVALID_ARGUMENT, "Only image files are allowed"); + } + + String objectKey = buildObjectKey(folder, contentType, file.getOriginalFilename()); + try { + minioClient.putObject(PutObjectArgs.builder() + .bucket(properties.bucket()) + .object(objectKey) + .stream(file.getInputStream(), file.getSize(), -1) + .contentType(contentType) + .build()); + } catch (Exception e) { + throw new DomainException(ErrorCode.INTERNAL_ERROR, "Failed to upload file: " + e.getMessage()); + } + + return new FileResDto( + properties.publicUrl() + "/" + properties.bucket() + "/" + objectKey, + objectKey, + file.getSize(), + contentType + ); + } + + static String buildObjectKey(String folder, String contentType, String originalFilename) { + return sanitizeFolder(folder) + "/" + UUID.randomUUID() + "." + extensionFor(contentType, originalFilename); + } + + static String sanitizeFolder(String folder) { + if (folder == null || folder.isBlank()) { + return "general"; + } + String clean = folder.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", ""); + return clean.isEmpty() ? "general" : clean; + } + + static String extensionFor(String contentType, String originalFilename) { + return switch (contentType) { + case "image/jpeg", "image/jpg" -> "jpg"; + case "image/png" -> "png"; + case "image/gif" -> "gif"; + case "image/webp" -> "webp"; + case "image/svg+xml" -> "svg"; + default -> { + String name = originalFilename == null ? "" : originalFilename; + int dot = name.lastIndexOf('.'); + String ext = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + yield ext.isEmpty() ? "img" : ext; + } + }; + } +} diff --git a/src/main/java/aplp/backend/lms/common/storage/MinioConfig.java b/src/main/java/aplp/backend/lms/common/storage/MinioConfig.java new file mode 100644 index 0000000..3b8ac94 --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/storage/MinioConfig.java @@ -0,0 +1,61 @@ +package aplp.backend.lms.common.storage; + +import io.minio.BucketExistsArgs; +import io.minio.MakeBucketArgs; +import io.minio.MinioClient; +import io.minio.SetBucketPolicyArgs; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(StorageProperties.class) +public class MinioConfig { + + private static final Logger log = LoggerFactory.getLogger(MinioConfig.class); + + private static final String PUBLIC_READ_POLICY = """ + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] + } + """; + + private final StorageProperties properties; + + public MinioConfig(StorageProperties properties) { + this.properties = properties; + } + + @Bean + public MinioClient minioClient() { + MinioClient client = MinioClient.builder() + .endpoint(properties.endpoint()) + .credentials(properties.accessKey(), properties.secretKey()) + .build(); + + try { + if (!client.bucketExists(BucketExistsArgs.builder().bucket(properties.bucket()).build())) { + client.makeBucket(MakeBucketArgs.builder().bucket(properties.bucket()).build()); + log.info("Created MinIO bucket '{}'", properties.bucket()); + } + client.setBucketPolicy(SetBucketPolicyArgs.builder() + .bucket(properties.bucket()) + .config(PUBLIC_READ_POLICY.formatted(properties.bucket())) + .build()); + } catch (Exception e) { + log.warn("MinIO bucket init skipped ({}): {}", properties.endpoint(), e.getMessage()); + } + + return client; + } +} diff --git a/src/main/java/aplp/backend/lms/common/storage/StorageProperties.java b/src/main/java/aplp/backend/lms/common/storage/StorageProperties.java new file mode 100644 index 0000000..74a822e --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/storage/StorageProperties.java @@ -0,0 +1,12 @@ +package aplp.backend.lms.common.storage; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "minio") +public record StorageProperties( + String endpoint, + String accessKey, + String secretKey, + String bucket, + String publicUrl +) {} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 0000000..0b2990f --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,11 @@ +spring: + config: + activate: + on-profile: prod + +minio: + endpoint: ${MINIO_ENDPOINT} + access-key: ${MINIO_ACCESS_KEY} + secret-key: ${MINIO_SECRET_KEY} + bucket: ${MINIO_BUCKET} + public-url: ${MINIO_PUBLIC_URL} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 52b7bd4..84f2341 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -5,7 +5,7 @@ spring: datasource: url: jdbc:postgresql://pgsql.koda.id.vn:5432/aplp username: postgres - password: Pa55w0rd + password: xxxx jpa: hibernate: @@ -22,6 +22,18 @@ spring: schemas: lms locations: classpath:db/migration + servlet: + multipart: + max-file-size: 5MB + max-request-size: 5MB + +minio: + endpoint: https://minio.koda.id.vn + access-key: CHANGE_ME + secret-key: CHANGE_ME + bucket: lms + public-url: https://minio.koda.id.vn + springdoc: swagger-ui: path: /swagger-ui \ No newline at end of file diff --git a/src/test/java/aplp/backend/lms/common/storage/FileStorageServiceTest.java b/src/test/java/aplp/backend/lms/common/storage/FileStorageServiceTest.java new file mode 100644 index 0000000..db20706 --- /dev/null +++ b/src/test/java/aplp/backend/lms/common/storage/FileStorageServiceTest.java @@ -0,0 +1,28 @@ +package aplp.backend.lms.common.storage; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FileStorageServiceTest { + + @Test + void sanitizesFolderAndUsesContentTypeExtension() { + String key = FileStorageService.buildObjectKey("Course Images!", "image/png", "whatever"); + assertTrue(key.startsWith("courseimages/"), key); + assertTrue(key.endsWith(".png"), key); + } + + @Test + void blankOrInvalidFolderFallsBackToGeneral() { + assertEquals("general", FileStorageService.sanitizeFolder(" ")); + assertEquals("general", FileStorageService.sanitizeFolder("!!!")); + } + + @Test + void unknownContentTypeFallsBackToFilenameExtension() { + assertEquals("jpg", FileStorageService.extensionFor("application/octet-stream", "photo.JPG")); + assertEquals("img", FileStorageService.extensionFor("application/octet-stream", null)); + } +} From 6eee3c09f77fe7ce49f147f6c22cc47d052a285b Mon Sep 17 00:00:00 2001 From: namdh861 Date: Tue, 15 Sep 2026 23:10:23 +0700 Subject: [PATCH 7/8] fix: .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9efba72..3284ed8 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ build/ .vscode/ .idea/ +.serena \ No newline at end of file From cd81d93f5d85ac2e1e2f25617a3d4d03409bcf39 Mon Sep 17 00:00:00 2001 From: namdh861 Date: Tue, 15 Sep 2026 23:33:33 +0700 Subject: [PATCH 8/8] feat: add upload file --- .../lms/common/storage/FileController.java | 7 +++ .../common/storage/FileStorageService.java | 43 ++++++++++++++++++- .../course/application/dtos/CourseResDto.java | 5 ++- .../application/mappers/CourseMapper.java | 4 -- .../application/services/CourseService.java | 39 ++++++++++++++--- src/main/resources/application.yaml | 2 +- 6 files changed, 86 insertions(+), 14 deletions(-) diff --git a/src/main/java/aplp/backend/lms/common/storage/FileController.java b/src/main/java/aplp/backend/lms/common/storage/FileController.java index 0cbbddf..3c20279 100644 --- a/src/main/java/aplp/backend/lms/common/storage/FileController.java +++ b/src/main/java/aplp/backend/lms/common/storage/FileController.java @@ -3,6 +3,7 @@ package aplp.backend.lms.common.storage; import aplp.backend.core.common.response.ApiResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -26,4 +27,10 @@ public class FileController { return ResponseEntity.status(HttpStatus.CREATED) .body(ApiResponse.ok(fileStorageService.upload(file, folder))); } + + @DeleteMapping + public ResponseEntity> delete(@RequestParam("objectKey") String objectKey) { + fileStorageService.delete(objectKey); + return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null)); + } } diff --git a/src/main/java/aplp/backend/lms/common/storage/FileStorageService.java b/src/main/java/aplp/backend/lms/common/storage/FileStorageService.java index 0f1f2d8..3857363 100644 --- a/src/main/java/aplp/backend/lms/common/storage/FileStorageService.java +++ b/src/main/java/aplp/backend/lms/common/storage/FileStorageService.java @@ -4,6 +4,7 @@ import aplp.backend.core.common.exception.DomainException; import aplp.backend.core.common.exception.ErrorCode; import io.minio.MinioClient; import io.minio.PutObjectArgs; +import io.minio.RemoveObjectArgs; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -44,13 +45,53 @@ public class FileStorageService { } return new FileResDto( - properties.publicUrl() + "/" + properties.bucket() + "/" + objectKey, + publicUrl(objectKey), objectKey, file.getSize(), contentType ); } + public String publicUrl(String objectKey) { + if (objectKey == null || objectKey.isBlank()) { + return null; + } + if (isAbsoluteUrl(objectKey)) { + return objectKey; + } + return properties.publicUrl() + "/" + properties.bucket() + "/" + objectKey; + } + + public void delete(String objectKey) { + String key = toObjectKey(objectKey); + if (key == null) { + throw new DomainException(ErrorCode.INVALID_ARGUMENT, "objectKey is required"); + } + try { + minioClient.removeObject(RemoveObjectArgs.builder() + .bucket(properties.bucket()) + .object(key) + .build()); + } catch (Exception e) { + throw new DomainException(ErrorCode.INTERNAL_ERROR, "Failed to delete file: " + e.getMessage()); + } + } + + private String toObjectKey(String value) { + if (value == null || value.isBlank()) { + return null; + } + String prefix = properties.publicUrl() + "/" + properties.bucket() + "/"; + if (value.startsWith(prefix)) { + return value.substring(prefix.length()); + } + return isAbsoluteUrl(value) ? null : value; + } + + private static boolean isAbsoluteUrl(String value) { + return value.startsWith("http://") || value.startsWith("https://"); + } + static String buildObjectKey(String folder, String contentType, String originalFilename) { return sanitizeFolder(folder) + "/" + UUID.randomUUID() + "." + extensionFor(contentType, originalFilename); } diff --git a/src/main/java/aplp/backend/lms/course/application/dtos/CourseResDto.java b/src/main/java/aplp/backend/lms/course/application/dtos/CourseResDto.java index 67da8f3..6b9aee9 100644 --- a/src/main/java/aplp/backend/lms/course/application/dtos/CourseResDto.java +++ b/src/main/java/aplp/backend/lms/course/application/dtos/CourseResDto.java @@ -1,5 +1,6 @@ package aplp.backend.lms.course.application.dtos; +import aplp.backend.lms.common.storage.FileStorageService; import aplp.backend.lms.course.domain.entities.Course; import java.time.LocalDateTime; @@ -11,11 +12,12 @@ public record CourseResDto( String slug, String description, String image, + String imageUrl, boolean isPublished, LocalDateTime createdAt, LocalDateTime updatedAt ) { - public static CourseResDto from(Course course) { + public static CourseResDto from(Course course, FileStorageService fileStorageService) { return new CourseResDto( course.getId(), course.getCourseCode(), @@ -23,6 +25,7 @@ public record CourseResDto( course.getSlug(), course.getDescription(), course.getImage(), + fileStorageService.publicUrl(course.getImage()), course.isPublished(), course.getCreatedAt(), course.getUpdatedAt() diff --git a/src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java b/src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java index 45742ab..725b8b5 100644 --- a/src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java +++ b/src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java @@ -1,7 +1,6 @@ package aplp.backend.lms.course.application.mappers; import aplp.backend.lms.course.application.dtos.CourseReqDto; -import aplp.backend.lms.course.application.dtos.CourseResDto; import aplp.backend.lms.course.domain.entities.Course; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @@ -10,9 +9,6 @@ import org.mapstruct.MappingTarget; @Mapper(componentModel = "spring") public interface CourseMapper { - @Mapping(target = "isPublished", source = "published") - CourseResDto toResponse(Course course); - @Mapping(target = "published", source = "isPublished") Course toEntity(CourseReqDto request); diff --git a/src/main/java/aplp/backend/lms/course/application/services/CourseService.java b/src/main/java/aplp/backend/lms/course/application/services/CourseService.java index 6d98ea0..d6191a8 100644 --- a/src/main/java/aplp/backend/lms/course/application/services/CourseService.java +++ b/src/main/java/aplp/backend/lms/course/application/services/CourseService.java @@ -1,11 +1,14 @@ package aplp.backend.lms.course.application.services; import aplp.backend.core.common.exception.ResourceNotFoundException; +import aplp.backend.lms.common.storage.FileStorageService; import aplp.backend.lms.course.application.dtos.CourseReqDto; import aplp.backend.lms.course.application.dtos.CourseResDto; import aplp.backend.lms.course.application.mappers.CourseMapper; import aplp.backend.lms.course.domain.entities.Course; import aplp.backend.lms.course.domain.repositories.CourseRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.util.List; @@ -13,18 +16,25 @@ import java.util.List; @Service public class CourseService { + private static final Logger log = LoggerFactory.getLogger(CourseService.class); + private final CourseRepository courseRepository; private final CourseMapper courseMapper; + private final FileStorageService fileStorageService; - public CourseService(CourseRepository courseRepository, CourseMapper courseMapper) { + public CourseService( + CourseRepository courseRepository, + CourseMapper courseMapper, + FileStorageService fileStorageService) { this.courseRepository = courseRepository; this.courseMapper = courseMapper; + this.fileStorageService = fileStorageService; } public List getAll() { return courseRepository.findAll() .stream() - .map(CourseResDto::from) + .map(course -> CourseResDto.from(course, fileStorageService)) .toList(); } @@ -32,26 +42,41 @@ public class CourseService { Course course = courseRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Course not found")); - return CourseResDto.from(course); + return CourseResDto.from(course, fileStorageService); } public CourseResDto create(CourseReqDto request) { - Course course = courseMapper.toEntity(request); - return courseMapper.toResponse(courseRepository.save(course)); + Course course = courseRepository.save(courseMapper.toEntity(request)); + return CourseResDto.from(course, fileStorageService); } public CourseResDto update(Long id, CourseReqDto request) { Course course = courseRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Course not found")); + String previousImage = course.getImage(); courseMapper.updateEntity(request, course); + Course saved = courseRepository.save(course); - return courseMapper.toResponse(courseRepository.save(course)); + if (previousImage != null && !previousImage.isBlank() && !previousImage.equals(saved.getImage())) { + deleteFileQuietly(previousImage); + } + + return CourseResDto.from(saved, fileStorageService); } public void delete(Long id) { - courseRepository.findById(id) + Course course = courseRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Course not found")); courseRepository.deleteById(id); + deleteFileQuietly(course.getImage()); + } + + private void deleteFileQuietly(String objectKey) { + try { + fileStorageService.delete(objectKey); + } catch (Exception e) { + log.warn("Failed to delete image '{}': {}", objectKey, e.getMessage()); + } } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 84f2341..1df6544 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -31,7 +31,7 @@ minio: endpoint: https://minio.koda.id.vn access-key: CHANGE_ME secret-key: CHANGE_ME - bucket: lms + bucket: aplp public-url: https://minio.koda.id.vn springdoc: