diff --git a/db/migrations/000077_lms_one_practice_per_parent.down.sql b/db/migrations/000077_lms_one_practice_per_parent.down.sql new file mode 100644 index 0000000..bfd51aa --- /dev/null +++ b/db/migrations/000077_lms_one_practice_per_parent.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_lms_practices_one_per_lesson; +DROP INDEX IF EXISTS idx_lms_practices_one_per_module; +DROP INDEX IF EXISTS idx_lms_practices_one_per_course; diff --git a/db/migrations/000077_lms_one_practice_per_parent.up.sql b/db/migrations/000077_lms_one_practice_per_parent.up.sql new file mode 100644 index 0000000..bc6020f --- /dev/null +++ b/db/migrations/000077_lms_one_practice_per_parent.up.sql @@ -0,0 +1,31 @@ +-- Learn English: at most one lms_practice per course, module, or lesson parent. +-- Remove duplicate rows (keep the earliest practice per parent) before adding constraints. +DELETE FROM lms_practices a +USING lms_practices b +WHERE a.course_id IS NOT NULL + AND a.course_id = b.course_id + AND a.id > b.id; + +DELETE FROM lms_practices a +USING lms_practices b +WHERE a.module_id IS NOT NULL + AND a.module_id = b.module_id + AND a.id > b.id; + +DELETE FROM lms_practices a +USING lms_practices b +WHERE a.lesson_id IS NOT NULL + AND a.lesson_id = b.lesson_id + AND a.id > b.id; + +CREATE UNIQUE INDEX idx_lms_practices_one_per_course + ON lms_practices (course_id) + WHERE course_id IS NOT NULL; + +CREATE UNIQUE INDEX idx_lms_practices_one_per_module + ON lms_practices (module_id) + WHERE module_id IS NOT NULL; + +CREATE UNIQUE INDEX idx_lms_practices_one_per_lesson + ON lms_practices (lesson_id) + WHERE lesson_id IS NOT NULL; diff --git a/db/query/lms_practices.sql b/db/query/lms_practices.sql index f3e48de..28165eb 100644 --- a/db/query/lms_practices.sql +++ b/db/query/lms_practices.sql @@ -109,3 +109,18 @@ RETURNING *; -- name: DeleteLmsPractice :exec DELETE FROM lms_practices WHERE id = $1; + +-- name: CountLmsPracticesByCourseID :one +SELECT COUNT(*)::bigint +FROM lms_practices +WHERE course_id = $1; + +-- name: CountLmsPracticesByModuleID :one +SELECT COUNT(*)::bigint +FROM lms_practices +WHERE module_id = $1; + +-- name: CountLmsPracticesByLessonID :one +SELECT COUNT(*)::bigint +FROM lms_practices +WHERE lesson_id = $1; diff --git a/gen/db/lms_practices.sql.go b/gen/db/lms_practices.sql.go index 8039241..583740a 100644 --- a/gen/db/lms_practices.sql.go +++ b/gen/db/lms_practices.sql.go @@ -11,6 +11,45 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const CountLmsPracticesByCourseID = `-- name: CountLmsPracticesByCourseID :one +SELECT COUNT(*)::bigint +FROM lms_practices +WHERE course_id = $1 +` + +func (q *Queries) CountLmsPracticesByCourseID(ctx context.Context, courseID pgtype.Int8) (int64, error) { + row := q.db.QueryRow(ctx, CountLmsPracticesByCourseID, courseID) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + +const CountLmsPracticesByLessonID = `-- name: CountLmsPracticesByLessonID :one +SELECT COUNT(*)::bigint +FROM lms_practices +WHERE lesson_id = $1 +` + +func (q *Queries) CountLmsPracticesByLessonID(ctx context.Context, lessonID pgtype.Int8) (int64, error) { + row := q.db.QueryRow(ctx, CountLmsPracticesByLessonID, lessonID) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + +const CountLmsPracticesByModuleID = `-- name: CountLmsPracticesByModuleID :one +SELECT COUNT(*)::bigint +FROM lms_practices +WHERE module_id = $1 +` + +func (q *Queries) CountLmsPracticesByModuleID(ctx context.Context, moduleID pgtype.Int8) (int64, error) { + row := q.db.QueryRow(ctx, CountLmsPracticesByModuleID, moduleID) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + const CreateLmsPractice = `-- name: CreateLmsPractice :one INSERT INTO lms_practices ( course_id, module_id, lesson_id, diff --git a/internal/ports/lms_practice.go b/internal/ports/lms_practice.go index 3ddddfa..078ebc0 100644 --- a/internal/ports/lms_practice.go +++ b/internal/ports/lms_practice.go @@ -30,4 +30,7 @@ type LmsPracticeStore interface { ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) UpdateLmsPractice(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error) DeleteLmsPractice(ctx context.Context, id int64) error + CountLmsPracticesByCourseID(ctx context.Context, courseID int64) (int64, error) + CountLmsPracticesByModuleID(ctx context.Context, moduleID int64) (int64, error) + CountLmsPracticesByLessonID(ctx context.Context, lessonID int64) (int64, error) } diff --git a/internal/repository/lms_practices.go b/internal/repository/lms_practices.go index a84eb16..0137759 100644 --- a/internal/repository/lms_practices.go +++ b/internal/repository/lms_practices.go @@ -259,3 +259,15 @@ func (s *Store) UpdateLmsPractice(ctx context.Context, id int64, input domain.Up func (s *Store) DeleteLmsPractice(ctx context.Context, id int64) error { return s.queries.DeleteLmsPractice(ctx, id) } + +func (s *Store) CountLmsPracticesByCourseID(ctx context.Context, courseID int64) (int64, error) { + return s.queries.CountLmsPracticesByCourseID(ctx, int64PtrToPg8(&courseID)) +} + +func (s *Store) CountLmsPracticesByModuleID(ctx context.Context, moduleID int64) (int64, error) { + return s.queries.CountLmsPracticesByModuleID(ctx, int64PtrToPg8(&moduleID)) +} + +func (s *Store) CountLmsPracticesByLessonID(ctx context.Context, lessonID int64) (int64, error) { + return s.queries.CountLmsPracticesByLessonID(ctx, int64PtrToPg8(&lessonID)) +} diff --git a/internal/services/practices/service.go b/internal/services/practices/service.go index 0551988..8723467 100644 --- a/internal/services/practices/service.go +++ b/internal/services/practices/service.go @@ -3,6 +3,7 @@ package practices import ( "Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/ports" + "Yimaru-Backend/internal/repository" "Yimaru-Backend/internal/services/courses" "Yimaru-Backend/internal/services/lessons" "Yimaru-Backend/internal/services/modules" @@ -13,9 +14,10 @@ import ( ) var ( - ErrPracticeNotFound = errors.New("practice not found") - ErrQuestionSetNotFound = errors.New("question set not found") - ErrInvalidPracticeParent = errors.New("parent_kind and parent_id do not match an allowed parent") + ErrPracticeNotFound = errors.New("practice not found") + ErrQuestionSetNotFound = errors.New("question set not found") + ErrInvalidPracticeParent = errors.New("parent_kind and parent_id do not match an allowed parent") + ErrPracticeParentLimitExceeded = errors.New("this course, module, or lesson already has a practice") ) type Service struct { @@ -101,6 +103,28 @@ func (s *Service) resolveParent(ctx context.Context, in domain.CreatePracticeInp } } +func (s *Service) ensureParentPracticeLimit(ctx context.Context, courseID, moduleID, lessonID *int64) error { + var count int64 + var err error + switch { + case courseID != nil: + count, err = s.practices.CountLmsPracticesByCourseID(ctx, *courseID) + case moduleID != nil: + count, err = s.practices.CountLmsPracticesByModuleID(ctx, *moduleID) + case lessonID != nil: + count, err = s.practices.CountLmsPracticesByLessonID(ctx, *lessonID) + default: + return ErrInvalidPracticeParent + } + if err != nil { + return err + } + if count >= 1 { + return ErrPracticeParentLimitExceeded + } + return nil +} + func (s *Service) Create(ctx context.Context, in domain.CreatePracticeInput) (domain.Practice, error) { if err := s.validateQuestionSet(ctx, in.QuestionSetID); err != nil { return domain.Practice{}, err @@ -114,7 +138,14 @@ func (s *Service) Create(ctx context.Context, in domain.CreatePracticeInput) (do if err != nil { return domain.Practice{}, err } - return s.practices.CreateLmsPractice(ctx, in, courseID, moduleID, lessonID) + if err := s.ensureParentPracticeLimit(ctx, courseID, moduleID, lessonID); err != nil { + return domain.Practice{}, err + } + p, err := s.practices.CreateLmsPractice(ctx, in, courseID, moduleID, lessonID) + if err != nil && repository.IsUniqueViolation(err) { + return domain.Practice{}, ErrPracticeParentLimitExceeded + } + return p, err } func (s *Service) TryGetByQuestionSetID(ctx context.Context, questionSetID int64) (domain.Practice, bool, error) { diff --git a/internal/web_server/handlers/practice_handler.go b/internal/web_server/handlers/practice_handler.go index 65386e1..d809a7b 100644 --- a/internal/web_server/handlers/practice_handler.go +++ b/internal/web_server/handlers/practice_handler.go @@ -48,6 +48,11 @@ func (h *Handler) CreatePractice(c *fiber.Ctx) error { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona not found", Error: err.Error()}) case errors.Is(err, practices.ErrInvalidPracticeParent): return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid parent", Error: err.Error()}) + case errors.Is(err, practices.ErrPracticeParentLimitExceeded): + return c.Status(fiber.StatusConflict).JSON(domain.ErrorResponse{ + Message: "This course, module, or lesson already has a practice", + Error: err.Error(), + }) } return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create practice", Error: err.Error()}) }