feat: add 3 components: course, lesson, section

This commit is contained in:
2026-09-15 22:34:28 +07:00
parent bba3ed3365
commit dc43e34271
24 changed files with 690 additions and 46 deletions
+2
View File
@@ -0,0 +1,2 @@
/cache
/project.local.yml
+171
View File
@@ -0,0 +1,171 @@
# the name by which the project can be referenced within Serena/when chatting with the LLM.
project_name: "aplp.backend.lms"
# list of language servers to start when using the LSP backend; choose from:
# ada al angular ansible bash
# bsl clojure cpp cpp_ccls crystal
# csharp csharp_omnisharp cue dart deno
# elixir elm erlang fortran fsharp
# gdscript gleam go groovy haskell
# haxe hlsl html java json
# julia julia_fatou kotlin latex lean4
# lua luau markdown matlab msl
# nextflow nix ocaml pascal perl
# php php_phpactor php_phpantom powershell python
# python_basedpyright python_jedi python_pyrefly python_ty qml
# r rego ruby ruby_solargraph rust
# scala scss solidity svelte swift
# systemverilog terraform toml typescript typescript_vts
# vue wolfram yaml zig
# (This list may be outdated; generated with scripts/print_language_list.py;
# For the current list, see values of the LanguageServerId enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# - External Python adapters may add further registered IDs; install the adapter package first
# and then use its ID here, for example: example
# Special requirements:
# Some language servers require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple language servers, the first language server that supports a given file will be used for that file.
# The first language server is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
language_servers:
- java
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
# the command runs in the project root directory and is only executed if the project is trusted
# (see trusted_project_path_patterns in the global configuration).
# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
# backstop for non-terminating commands; on expiry the process is killed and activation continues.
# example: activation_command: "npx nx run-many -t build"
activation_command:
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
# must be a positive number.
activation_command_timeout: 180.0
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
ls_specific_settings: {}
# list of workspace folder paths (LSP backend only).
# These folders will be used to build up Serena's symbol index.
# Paths must be within the project root and should thus be relative to the project root.
# Furthermore, the paths should not be filtered by ignore settings.
# Default setting: The entire project root folder (".") is considered.
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
# ls_workspace_folders:
# - "./subproject1"
# - "./subproject2"
ls_workspace_folders:
- "."
# list of additional workspace folder paths for cross-package reference support.
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries, but these folders are not indexed by Serena,
# i.e. the respective symbols will not be found using Serena's symbol search tools.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
ls_additional_workspace_folders: []
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
# Example:
# ignored_paths:
# - "examples/**"
# - ".worktrees/**"
# - "**/bin/**"
# - "**/obj/**"
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
@@ -1,40 +1,69 @@
package aplp.backend.lms.common.api;
import aplp.backend.core.common.exception.ErrorCode;
import aplp.backend.core.common.exception.ResourceNotFoundException;
import aplp.backend.core.common.response.ErrorResponse;
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.time.LocalDateTime;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
public ResponseEntity<ApiResponse<?>> handleNotFound(
ResourceNotFoundException ex
) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
LocalDateTime.now()
.body(ApiResponse.error(
HttpStatus.NOT_FOUND.name(),
ex.getMessage()
));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(
public ResponseEntity<ApiResponse<?>> handleException(
Exception ex
) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"Internal server error",
LocalDateTime.now()
.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()
));
}
}
@@ -1,48 +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 lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/courses")
@RequiredArgsConstructor
public class CourseController {
private final CourseService courseService;
public CourseController(CourseService courseService) {
this.courseService = courseService;
}
@GetMapping
public List<CourseResDto> getAll() {
return courseService.getAll();
public ResponseEntity<ApiResponse<List<CourseResDto>>> getAll() {
return ResponseEntity.ok(ApiResponse.ok(courseService.getAll()));
}
@GetMapping("/{id}")
public CourseResDto getById(@PathVariable Long id) {
return courseService.getById(id);
public ResponseEntity<ApiResponse<CourseResDto>> getById(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.ok(courseService.getById(id)));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public CourseResDto create(@Valid @RequestBody CourseReqDto request) {
return courseService.create(request);
public ResponseEntity<ApiResponse<CourseResDto>> create(@Valid @RequestBody CourseReqDto request) {
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(courseService.create(request)));
}
@PutMapping("/{id}")
public CourseResDto update(
public ResponseEntity<ApiResponse<CourseResDto>> update(
@PathVariable Long id,
@RequestBody CourseReqDto request) {
return courseService.update(id, request);
return ResponseEntity.ok(ApiResponse.ok(courseService.update(id, request)));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
courseService.delete(id);
return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null));
}
}
@@ -4,15 +4,19 @@ import aplp.backend.lms.course.application.dtos.CourseReqDto;
import aplp.backend.lms.course.application.dtos.CourseResDto;
import aplp.backend.lms.course.domain.entities.Course;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
@Mapper(componentModel = "spring")
public interface CourseMapper {
@Mapping(target = "isPublished", source = "published")
CourseResDto toResponse(Course course);
@Mapping(target = "published", source = "isPublished")
Course toEntity(CourseReqDto request);
@Mapping(target = "published", source = "isPublished")
void updateEntity(
CourseReqDto request,
@MappingTarget Course course
@@ -6,18 +6,21 @@ import aplp.backend.lms.course.application.dtos.CourseResDto;
import aplp.backend.lms.course.application.mappers.CourseMapper;
import aplp.backend.lms.course.domain.entities.Course;
import aplp.backend.lms.course.domain.repositories.CourseRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class CourseService {
private final CourseRepository courseRepository;
private final CourseMapper courseMapper;
public CourseService(CourseRepository courseRepository, CourseMapper courseMapper) {
this.courseRepository = courseRepository;
this.courseMapper = courseMapper;
}
public List<CourseResDto> getAll() {
return courseRepository.findAll()
.stream()
@@ -47,6 +50,8 @@ public class CourseService {
}
public void delete(Long id) {
courseRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Course not found"));
courseRepository.deleteById(id);
}
}
@@ -2,17 +2,19 @@ package aplp.backend.lms.course.infrastructure.persistence;
import aplp.backend.lms.course.domain.entities.Course;
import aplp.backend.lms.course.domain.repositories.CourseRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
@RequiredArgsConstructor
public class CourseRepositoryImpl implements CourseRepository {
private final CourseJpaRepository repository;
public CourseRepositoryImpl(CourseJpaRepository repository) {
this.repository = repository;
}
@Override
public Optional<Course> findById(Long id) {
return repository.findById(id);
@@ -1,48 +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 lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/lessons")
@RequiredArgsConstructor
public class LessonController {
private final LessonService lessonService;
public LessonController(LessonService lessonService) {
this.lessonService = lessonService;
}
@GetMapping
public List<LessonResDto> getAll() {
return lessonService.getAll();
public ResponseEntity<ApiResponse<List<LessonResDto>>> getAll() {
return ResponseEntity.ok(ApiResponse.ok(lessonService.getAll()));
}
@GetMapping("/{id}")
public LessonResDto getById(@PathVariable Long id) {
return lessonService.getById(id);
public ResponseEntity<ApiResponse<LessonResDto>> getById(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.ok(lessonService.getById(id)));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public LessonResDto create(@Valid @RequestBody LessonReqDto request) {
return lessonService.create(request);
public ResponseEntity<ApiResponse<LessonResDto>> create(@Valid @RequestBody LessonReqDto request) {
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(lessonService.create(request)));
}
@PutMapping("/{id}")
public LessonResDto update(
public ResponseEntity<ApiResponse<LessonResDto>> update(
@PathVariable Long id,
@Valid @RequestBody LessonReqDto request) {
return lessonService.update(id, request);
return ResponseEntity.ok(ApiResponse.ok(lessonService.update(id, request)));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
lessonService.delete(id);
return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null));
}
}
@@ -12,11 +12,14 @@ import org.mapstruct.MappingTarget;
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
@@ -6,18 +6,21 @@ 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 lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
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()
@@ -47,6 +50,8 @@ public class LessonService {
}
public void delete(Long id) {
lessonRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found"));
lessonRepository.deleteById(id);
}
}
@@ -2,17 +2,19 @@ package aplp.backend.lms.lesson.infrastructure.persistence;
import aplp.backend.lms.lesson.domain.entities.Lesson;
import aplp.backend.lms.lesson.domain.repositories.LessonRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
@RequiredArgsConstructor
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);
@@ -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);
}
}
@@ -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);