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:
parent
b780db5307
commit
8dd1d40a16
|
|
@ -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;
|
||||
31
db/migrations/000077_lms_one_practice_per_parent.up.sql
Normal file
31
db/migrations/000077_lms_one_practice_per_parent.up.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user