Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cf53b8384 | ||
|
|
c6f9ff0e3e | ||
|
|
f64425930f | ||
|
|
cd81d93f5d | ||
|
|
6eee3c09f7 | ||
|
|
7f3fd7d1e6 | ||
|
|
dc43e34271 | ||
|
|
bba3ed3365 | ||
|
|
a48d6deb06 | ||
|
|
6d4964782f | ||
|
|
941bf322e5 | ||
|
|
31f53a1d52 |
@@ -33,3 +33,4 @@ build/
|
||||
.vscode/
|
||||
|
||||
.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,15 +108,39 @@
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>3.1.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.minio</groupId>
|
||||
<artifactId>minio</artifactId>
|
||||
<version>8.5.17</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>aplp.backend</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>gitea</id>
|
||||
<url>https://git.koda.id.vn/api/packages/aplp/maven</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<distributionManagement>
|
||||
<repository>
|
||||
<id>gitea</id>
|
||||
<url>https://git.koda.id.vn/api/packages/aplp/maven</url>
|
||||
</repository>
|
||||
|
||||
<snapshotRepository>
|
||||
<id>gitea</id>
|
||||
<url>https://git.koda.id.vn/api/packages/aplp/maven</url>
|
||||
</snapshotRepository>
|
||||
</distributionManagement>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
|
||||
@@ -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.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 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.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.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<Course> findById(Long 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,9 +3,9 @@ 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:
|
||||
@@ -16,11 +16,23 @@ spring:
|
||||
format_sql: true
|
||||
show-sql: true
|
||||
|
||||
# flyway:
|
||||
# enabled: true
|
||||
# default-schema: lms
|
||||
# schemas: lms
|
||||
# locations: classpath:db/migration
|
||||
flyway:
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
-- Baseline schema: matches current JPA entities (Course, Lesson, Section + BaseEntity).
|
||||
-- History-only: flyway.enabled=false, runtime schema is maintained by Hibernate (ddl-auto: update).
|
||||
-- To rebuild tables in new shape, run this file manually against the lms schema (DESTROYS DATA),
|
||||
-- or drop tables and restart the app so Hibernate recreates them.
|
||||
|
||||
DROP TABLE IF EXISTS sections CASCADE;
|
||||
DROP TABLE IF EXISTS lessons CASCADE;
|
||||
DROP TABLE IF EXISTS courses CASCADE;
|
||||
DROP TYPE IF EXISTS section_type;
|
||||
|
||||
CREATE TABLE courses (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
course_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||
description VARCHAR(1000),
|
||||
image VARCHAR(500),
|
||||
is_published BOOLEAN NOT NULL,
|
||||
created_at TIMESTAMP(6) NOT NULL,
|
||||
created_by VARCHAR(50),
|
||||
updated_at TIMESTAMP(6),
|
||||
updated_by VARCHAR(50),
|
||||
deleted_at TIMESTAMP(6),
|
||||
deleted_by VARCHAR(50)
|
||||
);
|
||||
|
||||
CREATE TABLE lessons (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||
description VARCHAR(1000),
|
||||
position INTEGER NOT NULL,
|
||||
is_published BOOLEAN NOT NULL,
|
||||
created_at TIMESTAMP(6) NOT NULL,
|
||||
created_by VARCHAR(50),
|
||||
updated_at TIMESTAMP(6),
|
||||
updated_by VARCHAR(50),
|
||||
deleted_at TIMESTAMP(6),
|
||||
deleted_by VARCHAR(50)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_lessons_course_id ON lessons(course_id);
|
||||
|
||||
CREATE TABLE sections (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
lesson_id BIGINT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(255) NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
content JSONB,
|
||||
duration_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
is_free_preview BOOLEAN NOT NULL,
|
||||
created_at TIMESTAMP(6) NOT NULL,
|
||||
created_by VARCHAR(50),
|
||||
updated_at TIMESTAMP(6),
|
||||
updated_by VARCHAR(50),
|
||||
deleted_at TIMESTAMP(6),
|
||||
deleted_by VARCHAR(50)
|
||||
);
|
||||
|
||||
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