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 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/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/common/api/GlobalExceptionHandler.java b/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java new file mode 100644 index 0000000..8cfecd3 --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java @@ -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> handleNotFound( + ResourceNotFoundException ex + ) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error( + HttpStatus.NOT_FOUND.name(), + ex.getMessage() + )); + } + + @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 + ) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .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/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/infrastructure/config/OpenApiConfig.java b/src/main/java/aplp/backend/lms/common/config/OpenApiConfig.java similarity index 94% rename from src/main/java/aplp/backend/lms/infrastructure/config/OpenApiConfig.java rename to src/main/java/aplp/backend/lms/common/config/OpenApiConfig.java index 5165148..0c331cc 100644 --- a/src/main/java/aplp/backend/lms/infrastructure/config/OpenApiConfig.java +++ b/src/main/java/aplp/backend/lms/common/config/OpenApiConfig.java @@ -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.enums.SecuritySchemeType; 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..1f903eb --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/security/SecurityConfig.java @@ -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(); + } +} 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..3c20279 --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/storage/FileController.java @@ -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> 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> 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/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..3857363 --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/storage/FileStorageService.java @@ -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; + } + }; + } +} 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/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..66d7833 --- /dev/null +++ b/src/main/java/aplp/backend/lms/course/api/controller/CourseController.java @@ -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>> getAll() { + return ResponseEntity.ok(ApiResponse.ok(courseService.getAll())); + } + + @GetMapping("/{id}") + public ResponseEntity> getById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.ok(courseService.getById(id))); + } + + @PostMapping + public ResponseEntity> create(@Valid @RequestBody CourseReqDto request) { + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(courseService.create(request))); + } + + @PutMapping("/{id}") + public ResponseEntity> update( + @PathVariable Long id, + @RequestBody CourseReqDto request) { + return ResponseEntity.ok(ApiResponse.ok(courseService.update(id, request))); + } + + @DeleteMapping("/{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/dtos/CourseReqDto.java b/src/main/java/aplp/backend/lms/course/application/dtos/CourseReqDto.java new file mode 100644 index 0000000..00867f1 --- /dev/null +++ b/src/main/java/aplp/backend/lms/course/application/dtos/CourseReqDto.java @@ -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 +) {} 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..6b9aee9 --- /dev/null +++ b/src/main/java/aplp/backend/lms/course/application/dtos/CourseResDto.java @@ -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() + ); + } +} \ 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..725b8b5 --- /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.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 + ); +} 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..d6191a8 --- /dev/null +++ b/src/main/java/aplp/backend/lms/course/application/services/CourseService.java @@ -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 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()); + } + } +} 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..5e62fef --- /dev/null +++ b/src/main/java/aplp/backend/lms/course/domain/entities/Course.java @@ -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; +} \ 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/course/domain/repositories/CourseRepository.java similarity index 72% rename from src/main/java/aplp/backend/lms/domain/repositories/CourseRepository.java rename to src/main/java/aplp/backend/lms/course/domain/repositories/CourseRepository.java index a854dad..dc140e3 100644 --- a/src/main/java/aplp/backend/lms/domain/repositories/CourseRepository.java +++ b/src/main/java/aplp/backend/lms/course/domain/repositories/CourseRepository.java @@ -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 java.util.List; diff --git a/src/main/java/aplp/backend/lms/infrastructure/persistence/repositories/CourseJpaRepository.java b/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseJpaRepository.java similarity index 63% rename from src/main/java/aplp/backend/lms/infrastructure/persistence/repositories/CourseJpaRepository.java rename to src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseJpaRepository.java index 05bef6c..001c4ad 100644 --- a/src/main/java/aplp/backend/lms/infrastructure/persistence/repositories/CourseJpaRepository.java +++ b/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseJpaRepository.java @@ -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.stereotype.Repository; diff --git a/src/main/java/aplp/backend/lms/infrastructure/persistence/repositories/CourseRepositoryImpl.java b/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseRepositoryImpl.java similarity index 68% rename from src/main/java/aplp/backend/lms/infrastructure/persistence/repositories/CourseRepositoryImpl.java rename to src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseRepositoryImpl.java index 605699a..32a3e78 100644 --- a/src/main/java/aplp/backend/lms/infrastructure/persistence/repositories/CourseRepositoryImpl.java +++ b/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseRepositoryImpl.java @@ -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.domain.repositories.CourseRepository; -import lombok.RequiredArgsConstructor; +import aplp.backend.lms.course.domain.entities.Course; +import aplp.backend.lms.course.domain.repositories.CourseRepository; 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/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/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/lesson/api/controller/LessonController.java b/src/main/java/aplp/backend/lms/lesson/api/controller/LessonController.java new file mode 100644 index 0000000..12280d8 --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/api/controller/LessonController.java @@ -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>> getAll() { + return ResponseEntity.ok(ApiResponse.ok(lessonService.getAll())); + } + + @GetMapping("/{id}") + public ResponseEntity> getById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.ok(lessonService.getById(id))); + } + + @PostMapping + public ResponseEntity> create(@Valid @RequestBody LessonReqDto request) { + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(lessonService.create(request))); + } + + @PutMapping("/{id}") + public ResponseEntity> update( + @PathVariable Long id, + @Valid @RequestBody LessonReqDto request) { + return ResponseEntity.ok(ApiResponse.ok(lessonService.update(id, request))); + } + + @DeleteMapping("/{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/dtos/LessonReqDto.java b/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonReqDto.java new file mode 100644 index 0000000..bdd5a27 --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonReqDto.java @@ -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 +) {} 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..e12e039 --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/application/dtos/LessonResDto.java @@ -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() + ); + } +} 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..3527687 --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java @@ -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; + } +} 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..0cbd6e0 --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/application/services/LessonService.java @@ -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 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); + } +} 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..9408016 --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/domain/entities/Lesson.java @@ -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; +} 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..5fb1675 --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/domain/repositories/LessonRepository.java @@ -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 findById(Long id); + + 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 new file mode 100644 index 0000000..c2b5e50 --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonJpaRepository.java @@ -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 { + 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 new file mode 100644 index 0000000..917e9cc --- /dev/null +++ b/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java @@ -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 findById(Long id) { + return repository.findById(id); + } + + @Override + public List findAll() { + return repository.findAll(); + } + + @Override + public List 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); + } +} 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/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 1a72aa8..1df6544 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://pgsql.koda.id.vn:5432/aplp username: postgres - password: Pa55w0rd + password: xxxx jpa: hibernate: - ddl-auto: validate + ddl-auto: update properties: hibernate: default_schema: lms @@ -17,11 +17,23 @@ spring: show-sql: true flyway: - enabled: true + enabled: false default-schema: lms 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: aplp + public-url: https://minio.koda.id.vn + springdoc: swagger-ui: path: /swagger-ui \ No newline at end of file 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 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) +); 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); 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)); + } +}