Merge branch 'feature/course' into develop
This commit is contained in:
@@ -33,3 +33,4 @@ build/
|
|||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
|
.serena
|
||||||
@@ -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<T>` — `ok(data)`, `ok(message, data)`, `error(code, message)`.
|
||||||
|
- `PagedResponse<T>` — `of(content, page, size, totalElements)`.
|
||||||
|
- `ErrorCode`, `DomainException`, `ResourceNotFoundException`, `TokenHasher.sha256Hex`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Vertical slices per feature under `aplp.backend.lms.<feature>` (currently `course`, `lesson`, `section`); `common/` holds cross-cutting `api`, `config`, `security`. Layers per feature:
|
||||||
|
|
||||||
|
- `api/controller` — `@RestController`, returns `ResponseEntity<ApiResponse<...>>`
|
||||||
|
- `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.
|
||||||
@@ -108,6 +108,11 @@
|
|||||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
<version>3.1.0</version>
|
<version>3.1.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.minio</groupId>
|
||||||
|
<artifactId>minio</artifactId>
|
||||||
|
<version>8.5.17</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>aplp.backend</groupId>
|
<groupId>aplp.backend</groupId>
|
||||||
|
|||||||
@@ -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<ErrorResponse> 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<ErrorResponse> handleException(
|
|
||||||
Exception ex
|
|
||||||
) {
|
|
||||||
return ResponseEntity
|
|
||||||
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
||||||
.body(new ErrorResponse(
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR.value(),
|
|
||||||
"Internal server error",
|
|
||||||
LocalDateTime.now()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<CourseResDto> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package aplp.backend.lms.application.dtos;
|
|
||||||
|
|
||||||
public record CourseReqDto(
|
|
||||||
String title,
|
|
||||||
String description
|
|
||||||
) {}
|
|
||||||
@@ -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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<CourseResDto> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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;
|
||||||
|
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.util.stream.Collectors;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(ResourceNotFoundException.class)
|
||||||
|
public ResponseEntity<ApiResponse<?>> handleNotFound(
|
||||||
|
ResourceNotFoundException ex
|
||||||
|
) {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(ApiResponse.error(
|
||||||
|
HttpStatus.NOT_FOUND.name(),
|
||||||
|
ex.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(DomainException.class)
|
||||||
|
public ResponseEntity<ApiResponse<?>> handleDomainException(DomainException ex) {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(ex.code().status())
|
||||||
|
.body(ApiResponse.error(
|
||||||
|
ex.code().name(),
|
||||||
|
ex.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<ApiResponse<?>> handleException(
|
||||||
|
Exception ex
|
||||||
|
) {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(ApiResponse.error(
|
||||||
|
ErrorCode.INTERNAL_ERROR.name(),
|
||||||
|
"Internal server error"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<ApiResponse<?>> 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<ApiResponse<?>> handleConflict(
|
||||||
|
DataIntegrityViolationException ex
|
||||||
|
) {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.CONFLICT)
|
||||||
|
.body(ApiResponse.error(
|
||||||
|
HttpStatus.CONFLICT.name(),
|
||||||
|
"Duplicate or invalid data: " + ex.getMostSpecificCause().getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Long> 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package aplp.backend.lms.infrastructure.config;
|
package aplp.backend.lms.common.config;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||||
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
|
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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;
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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;
|
||||||
|
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<ApiResponse<FileResDto>> 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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping
|
||||||
|
public ResponseEntity<ApiResponse<Void>> delete(@RequestParam("objectKey") String objectKey) {
|
||||||
|
fileStorageService.delete(objectKey);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package aplp.backend.lms.common.storage;
|
||||||
|
|
||||||
|
public record FileResDto(
|
||||||
|
String url,
|
||||||
|
String objectKey,
|
||||||
|
long size,
|
||||||
|
String contentType
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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 io.minio.RemoveObjectArgs;
|
||||||
|
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(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -0,0 +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 org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/courses")
|
||||||
|
public class CourseController {
|
||||||
|
|
||||||
|
private final CourseService courseService;
|
||||||
|
|
||||||
|
public CourseController(CourseService courseService) {
|
||||||
|
this.courseService = courseService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<List<CourseResDto>>> getAll() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(courseService.getAll()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<CourseResDto>> getById(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(courseService.getById(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<CourseResDto>> create(@Valid @RequestBody CourseReqDto request) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(courseService.create(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<CourseResDto>> update(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody CourseReqDto request) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(courseService.update(id, request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
|
||||||
|
courseService.delete(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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,
|
||||||
|
|
||||||
|
@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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public record CourseResDto(
|
||||||
|
Long id,
|
||||||
|
String courseCode,
|
||||||
|
String title,
|
||||||
|
String slug,
|
||||||
|
String description,
|
||||||
|
String image,
|
||||||
|
String imageUrl,
|
||||||
|
boolean isPublished,
|
||||||
|
LocalDateTime createdAt,
|
||||||
|
LocalDateTime updatedAt
|
||||||
|
) {
|
||||||
|
public static CourseResDto from(Course course, FileStorageService fileStorageService) {
|
||||||
|
return new CourseResDto(
|
||||||
|
course.getId(),
|
||||||
|
course.getCourseCode(),
|
||||||
|
course.getTitle(),
|
||||||
|
course.getSlug(),
|
||||||
|
course.getDescription(),
|
||||||
|
course.getImage(),
|
||||||
|
fileStorageService.publicUrl(course.getImage()),
|
||||||
|
course.isPublished(),
|
||||||
|
course.getCreatedAt(),
|
||||||
|
course.getUpdatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package aplp.backend.lms.course.application.mappers;
|
||||||
|
|
||||||
|
import aplp.backend.lms.course.application.dtos.CourseReqDto;
|
||||||
|
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 = "published", source = "isPublished")
|
||||||
|
Course toEntity(CourseReqDto request);
|
||||||
|
|
||||||
|
@Mapping(target = "published", source = "isPublished")
|
||||||
|
void updateEntity(
|
||||||
|
CourseReqDto request,
|
||||||
|
@MappingTarget Course course
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
@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,
|
||||||
|
FileStorageService fileStorageService) {
|
||||||
|
this.courseRepository = courseRepository;
|
||||||
|
this.courseMapper = courseMapper;
|
||||||
|
this.fileStorageService = fileStorageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CourseResDto> getAll() {
|
||||||
|
return courseRepository.findAll()
|
||||||
|
.stream()
|
||||||
|
.map(course -> CourseResDto.from(course, fileStorageService))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public CourseResDto getById(Long id) {
|
||||||
|
Course course = courseRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Course not found"));
|
||||||
|
|
||||||
|
return CourseResDto.from(course, fileStorageService);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CourseResDto create(CourseReqDto request) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (previousImage != null && !previousImage.isBlank() && !previousImage.equals(saved.getImage())) {
|
||||||
|
deleteFileQuietly(previousImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CourseResDto.from(saved, fileStorageService);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(Long 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package aplp.backend.lms.course.domain.entities;
|
||||||
|
|
||||||
|
import aplp.backend.core.common.entity.BaseEntity;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "courses")
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@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;
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
package aplp.backend.lms.domain.repositories;
|
package aplp.backend.lms.course.domain.repositories;
|
||||||
|
|
||||||
import aplp.backend.lms.domain.entities.Course;
|
import aplp.backend.lms.course.domain.entities.Course;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
package aplp.backend.lms.infrastructure.persistence.repositories;
|
package aplp.backend.lms.course.infrastructure.persistence;
|
||||||
|
|
||||||
import aplp.backend.lms.domain.entities.Course;
|
import aplp.backend.lms.course.domain.entities.Course;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
+7
-5
@@ -1,18 +1,20 @@
|
|||||||
package aplp.backend.lms.infrastructure.persistence.repositories;
|
package aplp.backend.lms.course.infrastructure.persistence;
|
||||||
|
|
||||||
import aplp.backend.lms.domain.entities.Course;
|
import aplp.backend.lms.course.domain.entities.Course;
|
||||||
import aplp.backend.lms.domain.repositories.CourseRepository;
|
import aplp.backend.lms.course.domain.repositories.CourseRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class CourseRepositoryImpl implements CourseRepository {
|
public class CourseRepositoryImpl implements CourseRepository {
|
||||||
private final CourseJpaRepository repository;
|
private final CourseJpaRepository repository;
|
||||||
|
|
||||||
|
public CourseRepositoryImpl(CourseJpaRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<Course> findById(Long id) {
|
public Optional<Course> findById(Long id) {
|
||||||
return repository.findById(id);
|
return repository.findById(id);
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +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 org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/lessons")
|
||||||
|
public class LessonController {
|
||||||
|
|
||||||
|
private final LessonService lessonService;
|
||||||
|
|
||||||
|
public LessonController(LessonService lessonService) {
|
||||||
|
this.lessonService = lessonService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<List<LessonResDto>>> getAll() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(lessonService.getAll()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<LessonResDto>> getById(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(lessonService.getById(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<LessonResDto>> create(@Valid @RequestBody LessonReqDto request) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(lessonService.create(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<LessonResDto>> update(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@Valid @RequestBody LessonReqDto request) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(lessonService.update(id, request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
|
||||||
|
lessonService.delete(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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(
|
||||||
|
@NotNull(message = "Course ID is required")
|
||||||
|
Long courseId,
|
||||||
|
|
||||||
|
@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,
|
||||||
|
|
||||||
|
@NotNull(message = "Position is required")
|
||||||
|
@Min(value = 1, message = "Position must be at least 1")
|
||||||
|
Integer position,
|
||||||
|
|
||||||
|
Boolean isPublished
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package aplp.backend.lms.lesson.application.dtos;
|
||||||
|
|
||||||
|
import aplp.backend.lms.lesson.domain.entities.Lesson;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public record LessonResDto(
|
||||||
|
Long id,
|
||||||
|
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.getCourse().getId(),
|
||||||
|
lesson.getTitle(),
|
||||||
|
lesson.getSlug(),
|
||||||
|
lesson.getDescription(),
|
||||||
|
lesson.getPosition(),
|
||||||
|
lesson.isPublished(),
|
||||||
|
lesson.getCreatedAt(),
|
||||||
|
lesson.getUpdatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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")
|
||||||
|
@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
|
||||||
|
);
|
||||||
|
|
||||||
|
default Course mapCourse(Long courseId) {
|
||||||
|
if (courseId == null) return null;
|
||||||
|
Course course = new Course();
|
||||||
|
course.setId(courseId);
|
||||||
|
return course;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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 org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
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<LessonResDto> 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.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found"));
|
||||||
|
lessonRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package aplp.backend.lms.lesson.domain.entities;
|
||||||
|
|
||||||
|
import aplp.backend.core.common.entity.BaseEntity;
|
||||||
|
import aplp.backend.lms.course.domain.entities.Course;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "lessons")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class Lesson extends BaseEntity {
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "course_id", nullable = false)
|
||||||
|
private Course course;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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<Lesson> findById(Long id);
|
||||||
|
|
||||||
|
List<Lesson> findAll();
|
||||||
|
|
||||||
|
List<Lesson> findByCourseId(Long courseId);
|
||||||
|
|
||||||
|
Lesson save(Lesson lesson);
|
||||||
|
|
||||||
|
void deleteById(Long id);
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface LessonJpaRepository extends JpaRepository<Lesson, Long> {
|
||||||
|
List<Lesson> findByCourseIdOrderByPositionAsc(Long courseId);
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package aplp.backend.lms.lesson.infrastructure.persistence;
|
||||||
|
|
||||||
|
import aplp.backend.lms.lesson.domain.entities.Lesson;
|
||||||
|
import aplp.backend.lms.lesson.domain.repositories.LessonRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class LessonRepositoryImpl implements LessonRepository {
|
||||||
|
private final LessonJpaRepository repository;
|
||||||
|
|
||||||
|
public LessonRepositoryImpl(LessonJpaRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Lesson> findById(Long id) {
|
||||||
|
return repository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Lesson> findAll() {
|
||||||
|
return repository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Lesson> findByCourseId(Long courseId) {
|
||||||
|
return repository.findByCourseIdOrderByPositionAsc(courseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Lesson save(Lesson lesson) {
|
||||||
|
return repository.save(lesson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(Long id) {
|
||||||
|
repository.deleteById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ApiResponse<List<SectionResDto>>> getAll() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(sectionService.getAll()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<SectionResDto>> getById(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(sectionService.getById(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/by-lesson/{lessonId}")
|
||||||
|
public ResponseEntity<ApiResponse<List<SectionResDto>>> getByLessonId(@PathVariable Long lessonId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(sectionService.getByLessonId(lessonId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<SectionResDto>> create(@Valid @RequestBody SectionReqDto request) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(sectionService.create(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<SectionResDto>> update(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@Valid @RequestBody SectionReqDto request) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(sectionService.update(id, request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
|
||||||
|
sectionService.delete(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SectionResDto> 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<SectionResDto> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Section> findById(Long id);
|
||||||
|
|
||||||
|
List<Section> findAll();
|
||||||
|
|
||||||
|
List<Section> findByLessonId(Long lessonId);
|
||||||
|
|
||||||
|
Section save(Section section);
|
||||||
|
|
||||||
|
void deleteById(Long id);
|
||||||
|
}
|
||||||
+12
@@ -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<Section, Long> {
|
||||||
|
List<Section> findByLessonIdOrderByPositionAsc(Long lessonId);
|
||||||
|
}
|
||||||
+42
@@ -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<Section> findById(Long id) {
|
||||||
|
return repository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Section> findAll() {
|
||||||
|
return repository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Section> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -3,13 +3,13 @@ spring:
|
|||||||
name: lms
|
name: lms
|
||||||
|
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://localhost:5432/aplp
|
url: jdbc:postgresql://pgsql.koda.id.vn:5432/aplp
|
||||||
username: postgres
|
username: postgres
|
||||||
password: Pa55w0rd
|
password: xxxx
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: validate
|
ddl-auto: update
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
default_schema: lms
|
default_schema: lms
|
||||||
@@ -17,11 +17,23 @@ spring:
|
|||||||
show-sql: true
|
show-sql: true
|
||||||
|
|
||||||
flyway:
|
flyway:
|
||||||
enabled: true
|
enabled: false
|
||||||
default-schema: lms
|
default-schema: lms
|
||||||
schemas: lms
|
schemas: lms
|
||||||
locations: classpath:db/migration
|
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: aplp
|
||||||
|
public-url: https://minio.koda.id.vn
|
||||||
|
|
||||||
springdoc:
|
springdoc:
|
||||||
swagger-ui:
|
swagger-ui:
|
||||||
path: /swagger-ui
|
path: /swagger-ui
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user