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();