diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..c620a7b --- /dev/null +++ b/.serena/project.yml @@ -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 read‑only. +# 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: [] diff --git a/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java b/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java index 20126cc..0415d91 100644 --- a/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java +++ b/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java @@ -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 handleNotFound( + public ResponseEntity> 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 handleException( + public ResponseEntity> handleException( Exception ex ) { return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse( - HttpStatus.INTERNAL_SERVER_ERROR.value(), - "Internal server error", - LocalDateTime.now() + .body(ApiResponse.error( + ErrorCode.INTERNAL_ERROR.name(), + "Internal server error" + )); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation( + MethodArgumentNotValidException ex + ) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(e -> e.getField() + ": " + e.getDefaultMessage()) + .sorted() + .collect(Collectors.joining("; ")); + return ResponseEntity + .badRequest() + .body(ApiResponse.error( + ErrorCode.VALIDATION_FAILED.name(), + message + )); + } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity> handleConflict( + DataIntegrityViolationException ex + ) { + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(ApiResponse.error( + HttpStatus.CONFLICT.name(), + "Duplicate or invalid data: " + ex.getMostSpecificCause().getMessage() )); } } diff --git a/src/main/java/aplp/backend/lms/course/api/controller/CourseController.java b/src/main/java/aplp/backend/lms/course/api/controller/CourseController.java index fafb6e1..66d7833 100644 --- a/src/main/java/aplp/backend/lms/course/api/controller/CourseController.java +++ b/src/main/java/aplp/backend/lms/course/api/controller/CourseController.java @@ -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 getAll() { - return courseService.getAll(); + public ResponseEntity>> getAll() { + return ResponseEntity.ok(ApiResponse.ok(courseService.getAll())); } @GetMapping("/{id}") - public CourseResDto getById(@PathVariable Long id) { - return courseService.getById(id); + public ResponseEntity> 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> create(@Valid @RequestBody CourseReqDto request) { + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(courseService.create(request))); } @PutMapping("/{id}") - public CourseResDto update( + public ResponseEntity> 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> delete(@PathVariable Long id) { courseService.delete(id); + return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null)); } } \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java b/src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java index 0a4dcc2..45742ab 100644 --- a/src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java +++ b/src/main/java/aplp/backend/lms/course/application/mappers/CourseMapper.java @@ -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 diff --git a/src/main/java/aplp/backend/lms/course/application/services/CourseService.java b/src/main/java/aplp/backend/lms/course/application/services/CourseService.java index 0b7d846..6d98ea0 100644 --- a/src/main/java/aplp/backend/lms/course/application/services/CourseService.java +++ b/src/main/java/aplp/backend/lms/course/application/services/CourseService.java @@ -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 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); } } diff --git a/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseRepositoryImpl.java b/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseRepositoryImpl.java index 8e12132..32a3e78 100644 --- a/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseRepositoryImpl.java +++ b/src/main/java/aplp/backend/lms/course/infrastructure/persistence/CourseRepositoryImpl.java @@ -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 findById(Long id) { return repository.findById(id); diff --git a/src/main/java/aplp/backend/lms/lesson/api/controller/LessonController.java b/src/main/java/aplp/backend/lms/lesson/api/controller/LessonController.java index 17af4e7..12280d8 100644 --- a/src/main/java/aplp/backend/lms/lesson/api/controller/LessonController.java +++ b/src/main/java/aplp/backend/lms/lesson/api/controller/LessonController.java @@ -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 getAll() { - return lessonService.getAll(); + public ResponseEntity>> getAll() { + return ResponseEntity.ok(ApiResponse.ok(lessonService.getAll())); } @GetMapping("/{id}") - public LessonResDto getById(@PathVariable Long id) { - return lessonService.getById(id); + public ResponseEntity> 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> create(@Valid @RequestBody LessonReqDto request) { + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(lessonService.create(request))); } @PutMapping("/{id}") - public LessonResDto update( + public ResponseEntity> 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> delete(@PathVariable Long id) { lessonService.delete(id); + return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null)); } } diff --git a/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java b/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java index 288d809..3527687 100644 --- a/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java +++ b/src/main/java/aplp/backend/lms/lesson/application/mappers/LessonMapper.java @@ -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 diff --git a/src/main/java/aplp/backend/lms/lesson/application/services/LessonService.java b/src/main/java/aplp/backend/lms/lesson/application/services/LessonService.java index babdc95..0cbd6e0 100644 --- a/src/main/java/aplp/backend/lms/lesson/application/services/LessonService.java +++ b/src/main/java/aplp/backend/lms/lesson/application/services/LessonService.java @@ -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 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); } } diff --git a/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java b/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java index 7571e9d..917e9cc 100644 --- a/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java +++ b/src/main/java/aplp/backend/lms/lesson/infrastructure/persistence/LessonRepositoryImpl.java @@ -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 findById(Long id) { return repository.findById(id); diff --git a/src/main/java/aplp/backend/lms/section/api/controller/SectionController.java b/src/main/java/aplp/backend/lms/section/api/controller/SectionController.java new file mode 100644 index 0000000..fdf0627 --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/api/controller/SectionController.java @@ -0,0 +1,56 @@ +package aplp.backend.lms.section.api.controller; + +import aplp.backend.core.common.response.ApiResponse; +import aplp.backend.lms.section.application.dtos.SectionReqDto; +import aplp.backend.lms.section.application.dtos.SectionResDto; +import aplp.backend.lms.section.application.services.SectionService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/sections") +public class SectionController { + + private final SectionService sectionService; + + public SectionController(SectionService sectionService) { + this.sectionService = sectionService; + } + + @GetMapping + public ResponseEntity>> getAll() { + return ResponseEntity.ok(ApiResponse.ok(sectionService.getAll())); + } + + @GetMapping("/{id}") + public ResponseEntity> getById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.ok(sectionService.getById(id))); + } + + @GetMapping("/by-lesson/{lessonId}") + public ResponseEntity>> getByLessonId(@PathVariable Long lessonId) { + return ResponseEntity.ok(ApiResponse.ok(sectionService.getByLessonId(lessonId))); + } + + @PostMapping + public ResponseEntity> create(@Valid @RequestBody SectionReqDto request) { + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(sectionService.create(request))); + } + + @PutMapping("/{id}") + public ResponseEntity> update( + @PathVariable Long id, + @Valid @RequestBody SectionReqDto request) { + return ResponseEntity.ok(ApiResponse.ok(sectionService.update(id, request))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable Long id) { + sectionService.delete(id); + return ResponseEntity.ok(ApiResponse.ok("Deleted successfully", null)); + } +} diff --git a/src/main/java/aplp/backend/lms/section/application/dtos/SectionReqDto.java b/src/main/java/aplp/backend/lms/section/application/dtos/SectionReqDto.java new file mode 100644 index 0000000..ed8979b --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/application/dtos/SectionReqDto.java @@ -0,0 +1,29 @@ +package aplp.backend.lms.section.application.dtos; + +import aplp.backend.lms.section.domain.enums.SectionType; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record SectionReqDto( + @NotNull(message = "Lesson ID is required") + Long lessonId, + + @NotBlank(message = "Title is required") + @Size(max = 255, message = "Title must not exceed 255 characters") + String title, + + @NotNull(message = "Type is required") + SectionType type, + + @NotNull(message = "Position is required") + @Min(value = 1, message = "Position must be at least 1") + Integer position, + + String content, + + Integer durationSeconds, + + Boolean isFreePreview +) {} \ No newline at end of file diff --git a/src/main/java/aplp/backend/lms/section/application/dtos/SectionResDto.java b/src/main/java/aplp/backend/lms/section/application/dtos/SectionResDto.java new file mode 100644 index 0000000..23059c5 --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/application/dtos/SectionResDto.java @@ -0,0 +1,34 @@ +package aplp.backend.lms.section.application.dtos; + +import aplp.backend.lms.section.domain.entities.Section; +import aplp.backend.lms.section.domain.enums.SectionType; + +import java.time.LocalDateTime; + +public record SectionResDto( + Long id, + Long lessonId, + String title, + SectionType type, + int position, + String content, + int durationSeconds, + boolean isFreePreview, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static SectionResDto from(Section section) { + return new SectionResDto( + section.getId(), + section.getLesson().getId(), + section.getTitle(), + section.getType(), + section.getPosition(), + section.getContent(), + section.getDurationSeconds(), + section.isFreePreview(), + section.getCreatedAt(), + section.getUpdatedAt() + ); + } +} diff --git a/src/main/java/aplp/backend/lms/section/application/mappers/SectionMapper.java b/src/main/java/aplp/backend/lms/section/application/mappers/SectionMapper.java new file mode 100644 index 0000000..e026da7 --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/application/mappers/SectionMapper.java @@ -0,0 +1,31 @@ +package aplp.backend.lms.section.application.mappers; + +import aplp.backend.lms.lesson.domain.entities.Lesson; +import aplp.backend.lms.section.application.dtos.SectionReqDto; +import aplp.backend.lms.section.application.dtos.SectionResDto; +import aplp.backend.lms.section.domain.entities.Section; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; + +@Mapper(componentModel = "spring") +public interface SectionMapper { + + @Mapping(source = "lesson.id", target = "lessonId") + SectionResDto toResponse(Section section); + + @Mapping(source = "lessonId", target = "lesson") + Section toEntity(SectionReqDto request); + + void updateEntity( + SectionReqDto request, + @MappingTarget Section section + ); + + default Lesson mapLesson(Long lessonId) { + if (lessonId == null) return null; + Lesson lesson = new Lesson(); + lesson.setId(lessonId); + return lesson; + } +} diff --git a/src/main/java/aplp/backend/lms/section/application/services/SectionService.java b/src/main/java/aplp/backend/lms/section/application/services/SectionService.java new file mode 100644 index 0000000..d74eb6c --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/application/services/SectionService.java @@ -0,0 +1,61 @@ +package aplp.backend.lms.section.application.services; + +import aplp.backend.core.common.exception.ResourceNotFoundException; +import aplp.backend.lms.section.application.dtos.SectionReqDto; +import aplp.backend.lms.section.application.dtos.SectionResDto; +import aplp.backend.lms.section.application.mappers.SectionMapper; +import aplp.backend.lms.section.domain.entities.Section; +import aplp.backend.lms.section.domain.repositories.SectionRepository; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class SectionService { + + private final SectionRepository sectionRepository; + private final SectionMapper sectionMapper; + + public SectionService(SectionRepository sectionRepository, SectionMapper sectionMapper) { + this.sectionRepository = sectionRepository; + this.sectionMapper = sectionMapper; + } + + public List getAll() { + return sectionRepository.findAll() + .stream() + .map(SectionResDto::from) + .toList(); + } + + public SectionResDto getById(Long id) { + Section section = sectionRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Section not found")); + return SectionResDto.from(section); + } + + public List getByLessonId(Long lessonId) { + return sectionRepository.findByLessonId(lessonId) + .stream() + .map(SectionResDto::from) + .toList(); + } + + public SectionResDto create(SectionReqDto request) { + Section section = sectionMapper.toEntity(request); + return sectionMapper.toResponse(sectionRepository.save(section)); + } + + public SectionResDto update(Long id, SectionReqDto request) { + Section section = sectionRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Section not found")); + sectionMapper.updateEntity(request, section); + return sectionMapper.toResponse(sectionRepository.save(section)); + } + + public void delete(Long id) { + sectionRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Section not found")); + sectionRepository.deleteById(id); + } +} diff --git a/src/main/java/aplp/backend/lms/section/domain/entities/Section.java b/src/main/java/aplp/backend/lms/section/domain/entities/Section.java new file mode 100644 index 0000000..eaf1597 --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/domain/entities/Section.java @@ -0,0 +1,43 @@ +package aplp.backend.lms.section.domain.entities; + +import aplp.backend.core.common.entity.BaseEntity; +import aplp.backend.lms.lesson.domain.entities.Lesson; +import aplp.backend.lms.section.domain.enums.SectionType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Table(name = "sections") +@Getter +@Setter +@NoArgsConstructor +public class Section extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "lesson_id", nullable = false) + private Lesson lesson; + + @Column(nullable = false, length = 255) + private String title; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SectionType type; + + @Column(nullable = false) + private int position; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private String content; + + @Column(name = "duration_seconds") + private int durationSeconds; + + @Column(name = "is_free_preview", nullable = false) + private boolean isFreePreview; +} diff --git a/src/main/java/aplp/backend/lms/section/domain/enums/SectionType.java b/src/main/java/aplp/backend/lms/section/domain/enums/SectionType.java new file mode 100644 index 0000000..81f7ad7 --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/domain/enums/SectionType.java @@ -0,0 +1,21 @@ +package aplp.backend.lms.section.domain.enums; + +import com.fasterxml.jackson.annotation.JsonValue; + +public enum SectionType { + TEXT("text"), + VIDEO("video"), + QUIZ("quiz"), + EMBED("embed"); + + private final String value; + + SectionType(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } +} diff --git a/src/main/java/aplp/backend/lms/section/domain/repositories/SectionRepository.java b/src/main/java/aplp/backend/lms/section/domain/repositories/SectionRepository.java new file mode 100644 index 0000000..109b59e --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/domain/repositories/SectionRepository.java @@ -0,0 +1,21 @@ +package aplp.backend.lms.section.domain.repositories; + +import aplp.backend.lms.section.domain.entities.Section; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface SectionRepository { + + Optional
findById(Long id); + + List
findAll(); + + List
findByLessonId(Long lessonId); + + Section save(Section section); + + void deleteById(Long id); +} diff --git a/src/main/java/aplp/backend/lms/section/infrastructure/persistence/SectionJpaRepository.java b/src/main/java/aplp/backend/lms/section/infrastructure/persistence/SectionJpaRepository.java new file mode 100644 index 0000000..1f24462 --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/infrastructure/persistence/SectionJpaRepository.java @@ -0,0 +1,12 @@ +package aplp.backend.lms.section.infrastructure.persistence; + +import aplp.backend.lms.section.domain.entities.Section; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SectionJpaRepository extends JpaRepository { + List
findByLessonIdOrderByPositionAsc(Long lessonId); +} diff --git a/src/main/java/aplp/backend/lms/section/infrastructure/persistence/SectionRepositoryImpl.java b/src/main/java/aplp/backend/lms/section/infrastructure/persistence/SectionRepositoryImpl.java new file mode 100644 index 0000000..c0085d3 --- /dev/null +++ b/src/main/java/aplp/backend/lms/section/infrastructure/persistence/SectionRepositoryImpl.java @@ -0,0 +1,42 @@ +package aplp.backend.lms.section.infrastructure.persistence; + +import aplp.backend.lms.section.domain.entities.Section; +import aplp.backend.lms.section.domain.repositories.SectionRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class SectionRepositoryImpl implements SectionRepository { + private final SectionJpaRepository repository; + + public SectionRepositoryImpl(SectionJpaRepository repository) { + this.repository = repository; + } + + @Override + public Optional
findById(Long id) { + return repository.findById(id); + } + + @Override + public List
findAll() { + return repository.findAll(); + } + + @Override + public List
findByLessonId(Long lessonId) { + return repository.findByLessonIdOrderByPositionAsc(lessonId); + } + + @Override + public Section save(Section section) { + return repository.save(section); + } + + @Override + public void deleteById(Long id) { + repository.deleteById(id); + } +} diff --git a/src/main/resources/db/migration/V3__update_course_add_fields.sql b/src/main/resources/db/migration/V3__update_course_add_fields.sql new file mode 100644 index 0000000..5465a5f --- /dev/null +++ b/src/main/resources/db/migration/V3__update_course_add_fields.sql @@ -0,0 +1,16 @@ +-- Flyway Migration V3: Update courses table +-- Add course_code, slug, image, is_published + +ALTER TABLE courses ADD COLUMN course_code VARCHAR(50); +ALTER TABLE courses ADD COLUMN slug VARCHAR(255); +ALTER TABLE courses ADD COLUMN image VARCHAR(500); +ALTER TABLE courses ADD COLUMN is_published BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE courses ADD CONSTRAINT uq_course_code UNIQUE (course_code); +ALTER TABLE courses ADD CONSTRAINT uq_course_slug UNIQUE (slug); + +UPDATE courses SET slug = LOWER(REPLACE(REPLACE(TRIM(title), ' ', '-'), '.', '')); +UPDATE courses SET course_code = 'CRS-' || id; + +ALTER TABLE courses ALTER COLUMN course_code SET NOT NULL; +ALTER TABLE courses ALTER COLUMN slug SET NOT NULL; diff --git a/src/main/resources/db/migration/V4__update_lesson_add_course_fk_and_fields.sql b/src/main/resources/db/migration/V4__update_lesson_add_course_fk_and_fields.sql new file mode 100644 index 0000000..f290129 --- /dev/null +++ b/src/main/resources/db/migration/V4__update_lesson_add_course_fk_and_fields.sql @@ -0,0 +1,26 @@ +-- Flyway Migration V4: Update lessons table +-- Add course_id FK, slug, position, is_published; remove lesson_code + +ALTER TABLE lessons ADD COLUMN course_id BIGINT; +ALTER TABLE lessons ADD COLUMN slug VARCHAR(255); +ALTER TABLE lessons ADD COLUMN position INT; +ALTER TABLE lessons ADD COLUMN is_published BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE lessons RENAME COLUMN lesson_name TO title; + +UPDATE lessons SET slug = LOWER(REPLACE(REPLACE(TRIM(title), ' ', '-'), '.', '')); + +ALTER TABLE lessons ALTER COLUMN course_id SET NOT NULL; +ALTER TABLE lessons ALTER COLUMN slug SET NOT NULL; +ALTER TABLE lessons ALTER COLUMN position SET NOT NULL; + +ALTER TABLE lessons ADD CONSTRAINT fk_lesson_course + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE; + +ALTER TABLE lessons ADD CONSTRAINT uq_course_lesson_position UNIQUE (course_id, position); +ALTER TABLE lessons ADD CONSTRAINT uq_course_lesson_slug UNIQUE (course_id, slug); + +ALTER TABLE lessons DROP CONSTRAINT IF EXISTS lessons_lesson_code_key; +ALTER TABLE lessons DROP COLUMN lesson_code; + +CREATE INDEX idx_lessons_course_id ON lessons(course_id); diff --git a/src/main/resources/db/migration/V5__create_section_module.sql b/src/main/resources/db/migration/V5__create_section_module.sql new file mode 100644 index 0000000..1b280ff --- /dev/null +++ b/src/main/resources/db/migration/V5__create_section_module.sql @@ -0,0 +1,23 @@ +-- Flyway Migration V5: Create sections table + +CREATE TYPE section_type AS ENUM ('text', 'video', 'quiz', 'embed'); + +CREATE TABLE sections ( + id BIGSERIAL PRIMARY KEY, + lesson_id BIGINT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + type section_type NOT NULL DEFAULT 'text', + position INT NOT NULL, + content JSONB, + duration_seconds INT DEFAULT 0, + is_free_preview BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by VARCHAR(255), + updated_by VARCHAR(255), + deleted_at TIMESTAMPTZ, + deleted_by VARCHAR(255), + CONSTRAINT uq_lesson_section_position UNIQUE (lesson_id, position) +); + +CREATE INDEX idx_sections_lesson_id ON sections(lesson_id);