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
|
-- name: DeleteLmsPractice :exec
|
||||||
DELETE FROM lms_practices
|
DELETE FROM lms_practices
|
||||||
WHERE id = $1;
|
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"
|
"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
|
const CreateLmsPractice = `-- name: CreateLmsPractice :one
|
||||||
INSERT INTO lms_practices (
|
INSERT INTO lms_practices (
|
||||||
course_id, module_id, lesson_id,
|
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)
|
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)
|
UpdateLmsPractice(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error)
|
||||||
DeleteLmsPractice(ctx context.Context, id int64) 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 {
|
func (s *Store) DeleteLmsPractice(ctx context.Context, id int64) error {
|
||||||
return s.queries.DeleteLmsPractice(ctx, id)
|
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 (
|
import (
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
"Yimaru-Backend/internal/ports"
|
"Yimaru-Backend/internal/ports"
|
||||||
|
"Yimaru-Backend/internal/repository"
|
||||||
"Yimaru-Backend/internal/services/courses"
|
"Yimaru-Backend/internal/services/courses"
|
||||||
"Yimaru-Backend/internal/services/lessons"
|
"Yimaru-Backend/internal/services/lessons"
|
||||||
"Yimaru-Backend/internal/services/modules"
|
"Yimaru-Backend/internal/services/modules"
|
||||||
|
|
@ -16,6 +17,7 @@ var (
|
||||||
ErrPracticeNotFound = errors.New("practice not found")
|
ErrPracticeNotFound = errors.New("practice not found")
|
||||||
ErrQuestionSetNotFound = errors.New("question set not found")
|
ErrQuestionSetNotFound = errors.New("question set not found")
|
||||||
ErrInvalidPracticeParent = errors.New("parent_kind and parent_id do not match an allowed parent")
|
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 {
|
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) {
|
func (s *Service) Create(ctx context.Context, in domain.CreatePracticeInput) (domain.Practice, error) {
|
||||||
if err := s.validateQuestionSet(ctx, in.QuestionSetID); err != nil {
|
if err := s.validateQuestionSet(ctx, in.QuestionSetID); err != nil {
|
||||||
return domain.Practice{}, err
|
return domain.Practice{}, err
|
||||||
|
|
@ -114,7 +138,14 @@ func (s *Service) Create(ctx context.Context, in domain.CreatePracticeInput) (do
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Practice{}, err
|
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) {
|
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()})
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona not found", Error: err.Error()})
|
||||||
case errors.Is(err, practices.ErrInvalidPracticeParent):
|
case errors.Is(err, practices.ErrInvalidPracticeParent):
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid parent", Error: err.Error()})
|
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()})
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create practice", Error: err.Error()})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user