Author SHA1 Message Date
namdh c6f9ff0e3e fix: core package 2026-09-22 21:02:23 +07:00
namdh f64425930f Merge branch 'feature/course' into develop 2026-09-15 23:35:01 +07:00
namdh cd81d93f5d feat: add upload file 2026-09-15 23:33:33 +07:00
namdh 6eee3c09f7 fix: .gitignore 2026-09-15 23:10:23 +07:00
namdh 7f3fd7d1e6 feat: add Minio 2026-09-15 23:08:38 +07:00
hainm dc43e34271 feat: add 3 components: course, lesson, section 2026-09-15 22:34:28 +07:00
hainm bba3ed3365 feat: update codes 2026-09-09 22:17:56 +07:00
namdh a48d6deb06 feat: add lesson 2026-09-03 21:50:43 +07:00
hainm 6d4964782f feat: update codes 2026-08-25 18:45:03 +07:00
hainm 941bf322e5 feat: update course entity & refactor package 2026-08-21 22:17:02 +07:00
namdh 31f53a1d52 feat: add dependency 2026-08-13 21:09:59 +07:00
56 changed files with 1541 additions and 250 deletions
+1
View File
@@ -33,3 +33,4 @@ build/
.vscode/ .vscode/
.idea/ .idea/
.serena
+55
View File
@@ -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.
+25 -1
View File
@@ -108,15 +108,39 @@
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>3.1.0</version> <version>3.1.0</version>
</dependency> </dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.17</version>
</dependency>
<dependency> <dependency>
<groupId>aplp.backend</groupId> <groupId>aplp.backend</groupId>
<artifactId>core</artifactId> <artifactId>core</artifactId>
<version>1.0-SNAPSHOT</version> <version>1.0.0-SNAPSHOT</version>
</dependency> </dependency>
</dependencies> </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> <build>
<plugins> <plugins>
<plugin> <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,4 +1,4 @@
package aplp.backend.lms.infrastructure.config; package aplp.backend.lms.common.config;
import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
@@ -0,0 +1,47 @@
package aplp.backend.lms.common.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"http://localhost:5173",
"http://localhost:3000"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http
) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
);
return http.build();
}
}
@@ -0,0 +1,36 @@
package aplp.backend.lms.common.storage;
import aplp.backend.core.common.response.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/files")
public class FileController {
private final FileStorageService fileStorageService;
public FileController(FileStorageService fileStorageService) {
this.fileStorageService = fileStorageService;
}
@PostMapping
public ResponseEntity<ApiResponse<FileResDto>> upload(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "folder", required = false, defaultValue = "general") String folder) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.ok(fileStorageService.upload(file, folder)));
}
@DeleteMapping
public ResponseEntity<ApiResponse<Void>> delete(@RequestParam("objectKey") String objectKey) {
fileStorageService.delete(objectKey);
return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null));
}
}
@@ -0,0 +1,8 @@
package aplp.backend.lms.common.storage;
public record FileResDto(
String url,
String objectKey,
long size,
String contentType
) {}
@@ -0,0 +1,122 @@
package aplp.backend.lms.common.storage;
import aplp.backend.core.common.exception.DomainException;
import aplp.backend.core.common.exception.ErrorCode;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.Locale;
import java.util.UUID;
@Service
public class FileStorageService {
private final MinioClient minioClient;
private final StorageProperties properties;
public FileStorageService(MinioClient minioClient, StorageProperties properties) {
this.minioClient = minioClient;
this.properties = properties;
}
public FileResDto upload(MultipartFile file, String folder) {
if (file == null || file.isEmpty()) {
throw new DomainException(ErrorCode.INVALID_ARGUMENT, "File is required");
}
String contentType = file.getContentType();
if (contentType == null || !contentType.startsWith("image/")) {
throw new DomainException(ErrorCode.INVALID_ARGUMENT, "Only image files are allowed");
}
String objectKey = buildObjectKey(folder, contentType, file.getOriginalFilename());
try {
minioClient.putObject(PutObjectArgs.builder()
.bucket(properties.bucket())
.object(objectKey)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(contentType)
.build());
} catch (Exception e) {
throw new DomainException(ErrorCode.INTERNAL_ERROR, "Failed to upload file: " + e.getMessage());
}
return new FileResDto(
publicUrl(objectKey),
objectKey,
file.getSize(),
contentType
);
}
public String publicUrl(String objectKey) {
if (objectKey == null || objectKey.isBlank()) {
return null;
}
if (isAbsoluteUrl(objectKey)) {
return objectKey;
}
return properties.publicUrl() + "/" + properties.bucket() + "/" + objectKey;
}
public void delete(String objectKey) {
String key = toObjectKey(objectKey);
if (key == null) {
throw new DomainException(ErrorCode.INVALID_ARGUMENT, "objectKey is required");
}
try {
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(properties.bucket())
.object(key)
.build());
} catch (Exception e) {
throw new DomainException(ErrorCode.INTERNAL_ERROR, "Failed to delete file: " + e.getMessage());
}
}
private String toObjectKey(String value) {
if (value == null || value.isBlank()) {
return null;
}
String prefix = properties.publicUrl() + "/" + properties.bucket() + "/";
if (value.startsWith(prefix)) {
return value.substring(prefix.length());
}
return isAbsoluteUrl(value) ? null : value;
}
private static boolean isAbsoluteUrl(String value) {
return value.startsWith("http://") || value.startsWith("https://");
}
static String buildObjectKey(String folder, String contentType, String originalFilename) {
return sanitizeFolder(folder) + "/" + UUID.randomUUID() + "." + extensionFor(contentType, originalFilename);
}
static String sanitizeFolder(String folder) {
if (folder == null || folder.isBlank()) {
return "general";
}
String clean = folder.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "");
return clean.isEmpty() ? "general" : clean;
}
static String extensionFor(String contentType, String originalFilename) {
return switch (contentType) {
case "image/jpeg", "image/jpg" -> "jpg";
case "image/png" -> "png";
case "image/gif" -> "gif";
case "image/webp" -> "webp";
case "image/svg+xml" -> "svg";
default -> {
String name = originalFilename == null ? "" : originalFilename;
int dot = name.lastIndexOf('.');
String ext = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
yield ext.isEmpty() ? "img" : ext;
}
};
}
}
@@ -0,0 +1,61 @@
package aplp.backend.lms.common.storage;
import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.SetBucketPolicyArgs;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(StorageProperties.class)
public class MinioConfig {
private static final Logger log = LoggerFactory.getLogger(MinioConfig.class);
private static final String PUBLIC_READ_POLICY = """
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"AWS": ["*"]},
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::%s/*"]
}
]
}
""";
private final StorageProperties properties;
public MinioConfig(StorageProperties properties) {
this.properties = properties;
}
@Bean
public MinioClient minioClient() {
MinioClient client = MinioClient.builder()
.endpoint(properties.endpoint())
.credentials(properties.accessKey(), properties.secretKey())
.build();
try {
if (!client.bucketExists(BucketExistsArgs.builder().bucket(properties.bucket()).build())) {
client.makeBucket(MakeBucketArgs.builder().bucket(properties.bucket()).build());
log.info("Created MinIO bucket '{}'", properties.bucket());
}
client.setBucketPolicy(SetBucketPolicyArgs.builder()
.bucket(properties.bucket())
.config(PUBLIC_READ_POLICY.formatted(properties.bucket()))
.build());
} catch (Exception e) {
log.warn("MinIO bucket init skipped ({}): {}", properties.endpoint(), e.getMessage());
}
return client;
}
}
@@ -0,0 +1,12 @@
package aplp.backend.lms.common.storage;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "minio")
public record StorageProperties(
String endpoint,
String accessKey,
String secretKey,
String bucket,
String publicUrl
) {}
@@ -0,0 +1,51 @@
package aplp.backend.lms.course.api.controller;
import aplp.backend.core.common.response.ApiResponse;
import aplp.backend.lms.course.application.dtos.CourseReqDto;
import aplp.backend.lms.course.application.dtos.CourseResDto;
import aplp.backend.lms.course.application.services.CourseService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/courses")
public class CourseController {
private final CourseService courseService;
public CourseController(CourseService courseService) {
this.courseService = courseService;
}
@GetMapping
public ResponseEntity<ApiResponse<List<CourseResDto>>> getAll() {
return ResponseEntity.ok(ApiResponse.ok(courseService.getAll()));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<CourseResDto>> getById(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.ok(courseService.getById(id)));
}
@PostMapping
public ResponseEntity<ApiResponse<CourseResDto>> create(@Valid @RequestBody CourseReqDto request) {
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(courseService.create(request)));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<CourseResDto>> update(
@PathVariable Long id,
@RequestBody CourseReqDto request) {
return ResponseEntity.ok(ApiResponse.ok(courseService.update(id, request)));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
courseService.delete(id);
return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null));
}
}
@@ -0,0 +1,25 @@
package aplp.backend.lms.course.application.dtos;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CourseReqDto(
@NotBlank(message = "Course code is required")
@Size(max = 50, message = "Course code must not exceed 50 characters")
String courseCode,
@NotBlank(message = "Title is required")
@Size(max = 200, message = "Title must not exceed 200 characters")
String title,
@Size(max = 255, message = "Slug must not exceed 255 characters")
String slug,
@Size(max = 1000, message = "Description must not exceed 1000 characters")
String description,
@Size(max = 500, message = "Image URL must not exceed 500 characters")
String image,
Boolean isPublished
) {}
@@ -0,0 +1,34 @@
package aplp.backend.lms.course.application.dtos;
import aplp.backend.lms.common.storage.FileStorageService;
import aplp.backend.lms.course.domain.entities.Course;
import java.time.LocalDateTime;
public record CourseResDto(
Long id,
String courseCode,
String title,
String slug,
String description,
String image,
String imageUrl,
boolean isPublished,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public static CourseResDto from(Course course, FileStorageService fileStorageService) {
return new CourseResDto(
course.getId(),
course.getCourseCode(),
course.getTitle(),
course.getSlug(),
course.getDescription(),
course.getImage(),
fileStorageService.publicUrl(course.getImage()),
course.isPublished(),
course.getCreatedAt(),
course.getUpdatedAt()
);
}
}
@@ -0,0 +1,20 @@
package aplp.backend.lms.course.application.mappers;
import aplp.backend.lms.course.application.dtos.CourseReqDto;
import aplp.backend.lms.course.domain.entities.Course;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
@Mapper(componentModel = "spring")
public interface CourseMapper {
@Mapping(target = "published", source = "isPublished")
Course toEntity(CourseReqDto request);
@Mapping(target = "published", source = "isPublished")
void updateEntity(
CourseReqDto request,
@MappingTarget Course course
);
}
@@ -0,0 +1,82 @@
package aplp.backend.lms.course.application.services;
import aplp.backend.core.common.exception.ResourceNotFoundException;
import aplp.backend.lms.common.storage.FileStorageService;
import aplp.backend.lms.course.application.dtos.CourseReqDto;
import aplp.backend.lms.course.application.dtos.CourseResDto;
import aplp.backend.lms.course.application.mappers.CourseMapper;
import aplp.backend.lms.course.domain.entities.Course;
import aplp.backend.lms.course.domain.repositories.CourseRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CourseService {
private static final Logger log = LoggerFactory.getLogger(CourseService.class);
private final CourseRepository courseRepository;
private final CourseMapper courseMapper;
private final FileStorageService fileStorageService;
public CourseService(
CourseRepository courseRepository,
CourseMapper courseMapper,
FileStorageService fileStorageService) {
this.courseRepository = courseRepository;
this.courseMapper = courseMapper;
this.fileStorageService = fileStorageService;
}
public List<CourseResDto> getAll() {
return courseRepository.findAll()
.stream()
.map(course -> CourseResDto.from(course, fileStorageService))
.toList();
}
public CourseResDto getById(Long id) {
Course course = courseRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Course not found"));
return CourseResDto.from(course, fileStorageService);
}
public CourseResDto create(CourseReqDto request) {
Course course = courseRepository.save(courseMapper.toEntity(request));
return CourseResDto.from(course, fileStorageService);
}
public CourseResDto update(Long id, CourseReqDto request) {
Course course = courseRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Course not found"));
String previousImage = course.getImage();
courseMapper.updateEntity(request, course);
Course saved = courseRepository.save(course);
if (previousImage != null && !previousImage.isBlank() && !previousImage.equals(saved.getImage())) {
deleteFileQuietly(previousImage);
}
return CourseResDto.from(saved, fileStorageService);
}
public void delete(Long id) {
Course course = courseRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Course not found"));
courseRepository.deleteById(id);
deleteFileQuietly(course.getImage());
}
private void deleteFileQuietly(String objectKey) {
try {
fileStorageService.delete(objectKey);
} catch (Exception e) {
log.warn("Failed to delete image '{}': {}", objectKey, e.getMessage());
}
}
}
@@ -0,0 +1,35 @@
package aplp.backend.lms.course.domain.entities;
import aplp.backend.core.common.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Entity
@Table(name = "courses")
@EntityListeners(AuditingEntityListener.class)
@Getter
@Setter
@NoArgsConstructor
public class Course extends BaseEntity {
@Column(name = "course_code", nullable = false, length = 50, unique = true)
private String courseCode;
@Column(nullable = false, length = 200)
private String title;
@Column(nullable = false, unique = true, length = 255)
private String slug;
@Column(length = 1000)
private String description;
@Column(length = 500)
private String image;
@Column(name = "is_published", nullable = false)
private boolean isPublished;
}
@@ -1,6 +1,6 @@
package aplp.backend.lms.domain.repositories; package aplp.backend.lms.course.domain.repositories;
import aplp.backend.lms.domain.entities.Course; import aplp.backend.lms.course.domain.entities.Course;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
@@ -1,6 +1,6 @@
package aplp.backend.lms.infrastructure.persistence.repositories; package aplp.backend.lms.course.infrastructure.persistence;
import aplp.backend.lms.domain.entities.Course; import aplp.backend.lms.course.domain.entities.Course;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -1,18 +1,20 @@
package aplp.backend.lms.infrastructure.persistence.repositories; package aplp.backend.lms.course.infrastructure.persistence;
import aplp.backend.lms.domain.entities.Course; import aplp.backend.lms.course.domain.entities.Course;
import aplp.backend.lms.domain.repositories.CourseRepository; import aplp.backend.lms.course.domain.repositories.CourseRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
@RequiredArgsConstructor
public class CourseRepositoryImpl implements CourseRepository { public class CourseRepositoryImpl implements CourseRepository {
private final CourseJpaRepository repository; private final CourseJpaRepository repository;
public CourseRepositoryImpl(CourseJpaRepository repository) {
this.repository = repository;
}
@Override @Override
public Optional<Course> findById(Long id) { public Optional<Course> findById(Long id) {
return repository.findById(id); return repository.findById(id);
@@ -1,24 +0,0 @@
package aplp.backend.lms.domain.entities;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "courses")
@Getter
@Setter
@NoArgsConstructor
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String title;
@Column(length = 1000)
private String description;
}
@@ -1,25 +0,0 @@
package aplp.backend.lms.infrastructure.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http
) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
);
return http.build();
}
}
@@ -0,0 +1,51 @@
package aplp.backend.lms.lesson.api.controller;
import aplp.backend.core.common.response.ApiResponse;
import aplp.backend.lms.lesson.application.dtos.LessonReqDto;
import aplp.backend.lms.lesson.application.dtos.LessonResDto;
import aplp.backend.lms.lesson.application.services.LessonService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/lessons")
public class LessonController {
private final LessonService lessonService;
public LessonController(LessonService lessonService) {
this.lessonService = lessonService;
}
@GetMapping
public ResponseEntity<ApiResponse<List<LessonResDto>>> getAll() {
return ResponseEntity.ok(ApiResponse.ok(lessonService.getAll()));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<LessonResDto>> getById(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.ok(lessonService.getById(id)));
}
@PostMapping
public ResponseEntity<ApiResponse<LessonResDto>> create(@Valid @RequestBody LessonReqDto request) {
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(lessonService.create(request)));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<LessonResDto>> update(
@PathVariable Long id,
@Valid @RequestBody LessonReqDto request) {
return ResponseEntity.ok(ApiResponse.ok(lessonService.update(id, request)));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
lessonService.delete(id);
return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null));
}
}
@@ -0,0 +1,27 @@
package aplp.backend.lms.lesson.application.dtos;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record LessonReqDto(
@NotNull(message = "Course ID is required")
Long courseId,
@NotBlank(message = "Title is required")
@Size(max = 200, message = "Title must not exceed 200 characters")
String title,
@Size(max = 255, message = "Slug must not exceed 255 characters")
String slug,
@Size(max = 1000, message = "Description must not exceed 1000 characters")
String description,
@NotNull(message = "Position is required")
@Min(value = 1, message = "Position must be at least 1")
Integer position,
Boolean isPublished
) {}
@@ -0,0 +1,31 @@
package aplp.backend.lms.lesson.application.dtos;
import aplp.backend.lms.lesson.domain.entities.Lesson;
import java.time.LocalDateTime;
public record LessonResDto(
Long id,
Long courseId,
String title,
String slug,
String description,
int position,
boolean isPublished,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public static LessonResDto from(Lesson lesson) {
return new LessonResDto(
lesson.getId(),
lesson.getCourse().getId(),
lesson.getTitle(),
lesson.getSlug(),
lesson.getDescription(),
lesson.getPosition(),
lesson.isPublished(),
lesson.getCreatedAt(),
lesson.getUpdatedAt()
);
}
}
@@ -0,0 +1,34 @@
package aplp.backend.lms.lesson.application.mappers;
import aplp.backend.lms.course.domain.entities.Course;
import aplp.backend.lms.lesson.application.dtos.LessonReqDto;
import aplp.backend.lms.lesson.application.dtos.LessonResDto;
import aplp.backend.lms.lesson.domain.entities.Lesson;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
@Mapper(componentModel = "spring")
public interface LessonMapper {
@Mapping(source = "course.id", target = "courseId")
@Mapping(target = "isPublished", source = "published")
LessonResDto toResponse(Lesson lesson);
@Mapping(source = "courseId", target = "course")
@Mapping(target = "published", source = "isPublished")
Lesson toEntity(LessonReqDto request);
@Mapping(target = "published", source = "isPublished")
void updateEntity(
LessonReqDto request,
@MappingTarget Lesson lesson
);
default Course mapCourse(Long courseId) {
if (courseId == null) return null;
Course course = new Course();
course.setId(courseId);
return course;
}
}
@@ -0,0 +1,57 @@
package aplp.backend.lms.lesson.application.services;
import aplp.backend.core.common.exception.ResourceNotFoundException;
import aplp.backend.lms.lesson.application.dtos.LessonReqDto;
import aplp.backend.lms.lesson.application.dtos.LessonResDto;
import aplp.backend.lms.lesson.application.mappers.LessonMapper;
import aplp.backend.lms.lesson.domain.entities.Lesson;
import aplp.backend.lms.lesson.domain.repositories.LessonRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class LessonService {
private final LessonRepository lessonRepository;
private final LessonMapper lessonMapper;
public LessonService(LessonRepository lessonRepository, LessonMapper lessonMapper) {
this.lessonRepository = lessonRepository;
this.lessonMapper = lessonMapper;
}
public List<LessonResDto> getAll() {
return lessonRepository.findAll()
.stream()
.map(LessonResDto::from)
.toList();
}
public LessonResDto getById(Long id) {
Lesson lesson = lessonRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found"));
return LessonResDto.from(lesson);
}
public LessonResDto create(LessonReqDto request) {
Lesson lesson = lessonMapper.toEntity(request);
return lessonMapper.toResponse(lessonRepository.save(lesson));
}
public LessonResDto update(Long id, LessonReqDto request) {
Lesson lesson = lessonRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found"));
lessonMapper.updateEntity(request, lesson);
return lessonMapper.toResponse(lessonRepository.save(lesson));
}
public void delete(Long id) {
lessonRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found"));
lessonRepository.deleteById(id);
}
}
@@ -0,0 +1,35 @@
package aplp.backend.lms.lesson.domain.entities;
import aplp.backend.core.common.entity.BaseEntity;
import aplp.backend.lms.course.domain.entities.Course;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "lessons")
@Getter
@Setter
@NoArgsConstructor
public class Lesson extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id", nullable = false)
private Course course;
@Column(nullable = false, length = 200)
private String title;
@Column(nullable = false, unique = true, length = 255)
private String slug;
@Column(length = 1000)
private String description;
@Column(nullable = false)
private int position;
@Column(name = "is_published", nullable = false)
private boolean isPublished;
}
@@ -0,0 +1,21 @@
package aplp.backend.lms.lesson.domain.repositories;
import aplp.backend.lms.lesson.domain.entities.Lesson;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface LessonRepository {
Optional<Lesson> findById(Long id);
List<Lesson> findAll();
List<Lesson> findByCourseId(Long courseId);
Lesson save(Lesson lesson);
void deleteById(Long id);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
}
+11
View File
@@ -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}
+19 -7
View File
@@ -3,9 +3,9 @@ spring:
name: lms name: lms
datasource: datasource:
url: jdbc:postgresql://localhost:5432/aplp url: jdbc:postgresql://pgsql.koda.id.vn:5432/aplp
username: postgres username: postgres
password: Pa55w0rd password: xxxx
jpa: jpa:
hibernate: hibernate:
@@ -16,11 +16,23 @@ spring:
format_sql: true format_sql: true
show-sql: true show-sql: true
# flyway: flyway:
# enabled: true enabled: false
# default-schema: lms default-schema: lms
# schemas: lms schemas: lms
# locations: classpath:db/migration locations: classpath:db/migration
servlet:
multipart:
max-file-size: 5MB
max-request-size: 5MB
minio:
endpoint: https://minio.koda.id.vn
access-key: CHANGE_ME
secret-key: CHANGE_ME
bucket: aplp
public-url: https://minio.koda.id.vn
springdoc: springdoc:
swagger-ui: swagger-ui:
@@ -0,0 +1,8 @@
-- Flyway Migration V1: Initial schema
-- Creates courses table
CREATE TABLE IF NOT EXISTS courses (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
description VARCHAR(1000)
);
@@ -0,0 +1,14 @@
-- Flyway Migration V3: Create lessons table
CREATE TABLE IF NOT EXISTS lessons (
id BIGSERIAL PRIMARY KEY,
lesson_code VARCHAR(50) NOT NULL UNIQUE,
lesson_name VARCHAR(200) NOT NULL,
description VARCHAR(1000),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by VARCHAR(255),
updated_by VARCHAR(255),
deleted_at TIMESTAMPTZ,
deleted_by VARCHAR(255)
);
@@ -0,0 +1,16 @@
-- Flyway Migration V3: Update courses table
-- Add course_code, slug, image, is_published
ALTER TABLE courses ADD COLUMN course_code VARCHAR(50);
ALTER TABLE courses ADD COLUMN slug VARCHAR(255);
ALTER TABLE courses ADD COLUMN image VARCHAR(500);
ALTER TABLE courses ADD COLUMN is_published BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE courses ADD CONSTRAINT uq_course_code UNIQUE (course_code);
ALTER TABLE courses ADD CONSTRAINT uq_course_slug UNIQUE (slug);
UPDATE courses SET slug = LOWER(REPLACE(REPLACE(TRIM(title), ' ', '-'), '.', ''));
UPDATE courses SET course_code = 'CRS-' || id;
ALTER TABLE courses ALTER COLUMN course_code SET NOT NULL;
ALTER TABLE courses ALTER COLUMN slug SET NOT NULL;
@@ -0,0 +1,26 @@
-- Flyway Migration V4: Update lessons table
-- Add course_id FK, slug, position, is_published; remove lesson_code
ALTER TABLE lessons ADD COLUMN course_id BIGINT;
ALTER TABLE lessons ADD COLUMN slug VARCHAR(255);
ALTER TABLE lessons ADD COLUMN position INT;
ALTER TABLE lessons ADD COLUMN is_published BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE lessons RENAME COLUMN lesson_name TO title;
UPDATE lessons SET slug = LOWER(REPLACE(REPLACE(TRIM(title), ' ', '-'), '.', ''));
ALTER TABLE lessons ALTER COLUMN course_id SET NOT NULL;
ALTER TABLE lessons ALTER COLUMN slug SET NOT NULL;
ALTER TABLE lessons ALTER COLUMN position SET NOT NULL;
ALTER TABLE lessons ADD CONSTRAINT fk_lesson_course
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE;
ALTER TABLE lessons ADD CONSTRAINT uq_course_lesson_position UNIQUE (course_id, position);
ALTER TABLE lessons ADD CONSTRAINT uq_course_lesson_slug UNIQUE (course_id, slug);
ALTER TABLE lessons DROP CONSTRAINT IF EXISTS lessons_lesson_code_key;
ALTER TABLE lessons DROP COLUMN lesson_code;
CREATE INDEX idx_lessons_course_id ON lessons(course_id);
@@ -0,0 +1,23 @@
-- Flyway Migration V5: Create sections table
CREATE TYPE section_type AS ENUM ('text', 'video', 'quiz', 'embed');
CREATE TABLE sections (
id BIGSERIAL PRIMARY KEY,
lesson_id BIGINT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
type section_type NOT NULL DEFAULT 'text',
position INT NOT NULL,
content JSONB,
duration_seconds INT DEFAULT 0,
is_free_preview BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by VARCHAR(255),
updated_by VARCHAR(255),
deleted_at TIMESTAMPTZ,
deleted_by VARCHAR(255),
CONSTRAINT uq_lesson_section_position UNIQUE (lesson_id, position)
);
CREATE INDEX idx_sections_lesson_id ON sections(lesson_id);
@@ -0,0 +1,28 @@
package aplp.backend.lms.common.storage;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FileStorageServiceTest {
@Test
void sanitizesFolderAndUsesContentTypeExtension() {
String key = FileStorageService.buildObjectKey("Course Images!", "image/png", "whatever");
assertTrue(key.startsWith("courseimages/"), key);
assertTrue(key.endsWith(".png"), key);
}
@Test
void blankOrInvalidFolderFallsBackToGeneral() {
assertEquals("general", FileStorageService.sanitizeFolder(" "));
assertEquals("general", FileStorageService.sanitizeFolder("!!!"));
}
@Test
void unknownContentTypeFallsBackToFilenameExtension() {
assertEquals("jpg", FileStorageService.extensionFor("application/octet-stream", "photo.JPG"));
assertEquals("img", FileStorageService.extensionFor("application/octet-stream", null));
}
}