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
@@ -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);