feat: limit Learn English parents to one LMS practice each

Reject creating a second practice on the same course, module, or lesson with 409 Conflict, and enforce the rule in the database via unique partial indexes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-09 04:28:14 -07:00
parent b780db5307
commit 8dd1d40a16
8 changed files with 143 additions and 4 deletions

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -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()})
}