diff --git a/internal/repository/lms_courses.go b/internal/repository/lms_courses.go index bcd47c7..55f7c26 100644 --- a/internal/repository/lms_courses.go +++ b/internal/repository/lms_courses.go @@ -105,18 +105,56 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, lim } func (s *Store) UpdateCourse(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error) { + sortParam := optionalInt4Update(input.SortOrder) var nameText pgtype.Text if input.Name != nil { nameText = pgtype.Text{String: *input.Name, Valid: true} } else { nameText = pgtype.Text{Valid: false} } + + if input.SortOrder != nil { + cur, err := s.GetCourseByID(ctx, id) + if err != nil { + return domain.Course{}, err + } + oldPos := int32(cur.SortOrder) + newPos := int32(*input.SortOrder) + if oldPos != newPos { + q, tx, err := s.BeginTx(ctx) + if err != nil { + return domain.Course{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + if err := repositionCourseSortOrder(ctx, tx, cur.ProgramID, id, oldPos, newPos); err != nil { + return domain.Course{}, err + } + c, err := q.UpdateCourse(ctx, dbgen.UpdateCourseParams{ + ID: id, + Name: nameText, + Description: optionalTextUpdate(input.Description), + Thumbnail: optionalTextUpdate(input.Thumbnail), + SortOrder: pgtype.Int4{Valid: false}, + }) + if err != nil { + return domain.Course{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.Course{}, err + } + out := courseToDomain(c) + out.HasPractice = cur.HasPractice + return out, nil + } + sortParam = pgtype.Int4{Valid: false} + } + c, err := s.queries.UpdateCourse(ctx, dbgen.UpdateCourseParams{ ID: id, Name: nameText, Description: optionalTextUpdate(input.Description), Thumbnail: optionalTextUpdate(input.Thumbnail), - SortOrder: optionalInt4Update(input.SortOrder), + SortOrder: sortParam, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/lms_lessons.go b/internal/repository/lms_lessons.go index 9cb490c..12aabf8 100644 --- a/internal/repository/lms_lessons.go +++ b/internal/repository/lms_lessons.go @@ -102,19 +102,58 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit } func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) { + sortParam := optionalInt4Update(input.SortOrder) var titleText pgtype.Text if input.Title != nil { titleText = pgtype.Text{String: *input.Title, Valid: true} } else { titleText = pgtype.Text{Valid: false} } + + if input.SortOrder != nil { + cur, err := s.GetLessonByID(ctx, id) + if err != nil { + return domain.Lesson{}, err + } + oldPos := int32(cur.SortOrder) + newPos := int32(*input.SortOrder) + if oldPos != newPos { + q, tx, err := s.BeginTx(ctx) + if err != nil { + return domain.Lesson{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + if err := repositionLessonSortOrder(ctx, tx, cur.ModuleID, id, oldPos, newPos); err != nil { + return domain.Lesson{}, err + } + l, err := q.UpdateLesson(ctx, dbgen.UpdateLessonParams{ + ID: id, + Title: titleText, + VideoUrl: optionalTextUpdate(input.VideoURL), + Thumbnail: optionalTextUpdate(input.Thumbnail), + Description: optionalTextUpdate(input.Description), + SortOrder: pgtype.Int4{Valid: false}, + }) + if err != nil { + return domain.Lesson{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.Lesson{}, err + } + out := lessonToDomain(l) + out.HasPractice = cur.HasPractice + return out, nil + } + sortParam = pgtype.Int4{Valid: false} + } + l, err := s.queries.UpdateLesson(ctx, dbgen.UpdateLessonParams{ ID: id, Title: titleText, VideoUrl: optionalTextUpdate(input.VideoURL), Thumbnail: optionalTextUpdate(input.Thumbnail), Description: optionalTextUpdate(input.Description), - SortOrder: optionalInt4Update(input.SortOrder), + SortOrder: sortParam, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/lms_modules.go b/internal/repository/lms_modules.go index 24c33df..8ee1f12 100644 --- a/internal/repository/lms_modules.go +++ b/internal/repository/lms_modules.go @@ -107,69 +107,27 @@ func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, co } func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error) { + cur, err := s.GetModuleByID(ctx, id) + if err != nil { + return domain.Module{}, err + } + q, tx, err := s.BeginTx(ctx) if err != nil { return domain.Module{}, err } - defer tx.Rollback(ctx) - - var current dbgen.Module - err = tx.QueryRow(ctx, ` -SELECT id, program_id, course_id, name, description, icon, sort_order, created_at, updated_at -FROM modules -WHERE id = $1 -FOR UPDATE -`, id).Scan( - ¤t.ID, - ¤t.ProgramID, - ¤t.CourseID, - ¤t.Name, - ¤t.Description, - ¤t.Icon, - ¤t.SortOrder, - ¤t.CreatedAt, - ¤t.UpdatedAt, - ) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return domain.Module{}, pgx.ErrNoRows - } - return domain.Module{}, err - } + defer func() { _ = tx.Rollback(ctx) }() + sortParam := optionalInt4Update(input.SortOrder) if input.SortOrder != nil { - targetSort := int32(*input.SortOrder) - if targetSort != current.SortOrder { - var conflictID int64 - err = tx.QueryRow(ctx, ` -SELECT id -FROM modules -WHERE course_id = $1 - AND sort_order = $2 - AND id <> $3 -FOR UPDATE -`, current.CourseID, targetSort, id).Scan(&conflictID) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { + oldPos := int32(cur.SortOrder) + newPos := int32(*input.SortOrder) + if oldPos != newPos { + if err := repositionModuleSortOrder(ctx, tx, cur.CourseID, id, oldPos, newPos); err != nil { return domain.Module{}, err } - if err == nil { - var tempSort int32 - if err := tx.QueryRow(ctx, ` -SELECT COALESCE(MIN(sort_order), 0) - 1 -FROM modules -WHERE course_id = $1 -`, current.CourseID).Scan(&tempSort); err != nil { - return domain.Module{}, err - } - - if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order = $1 WHERE id = $2`, tempSort, id); err != nil { - return domain.Module{}, err - } - if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order = $1 WHERE id = $2`, current.SortOrder, conflictID); err != nil { - return domain.Module{}, err - } - } } + sortParam = pgtype.Int4{Valid: false} } var nameText pgtype.Text @@ -183,7 +141,7 @@ WHERE course_id = $1 Name: nameText, Description: optionalTextUpdate(input.Description), Icon: optionalTextUpdate(input.Icon), - SortOrder: optionalInt4Update(input.SortOrder), + SortOrder: sortParam, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -195,7 +153,9 @@ WHERE course_id = $1 if err := tx.Commit(ctx); err != nil { return domain.Module{}, err } - return moduleToDomain(m), nil + out := moduleToDomain(m) + out.HasPractice = cur.HasPractice + return out, nil } func (s *Store) DeleteModule(ctx context.Context, id int64) error { diff --git a/internal/repository/lms_sort_order_shift.go b/internal/repository/lms_sort_order_shift.go new file mode 100644 index 0000000..05d279e --- /dev/null +++ b/internal/repository/lms_sort_order_shift.go @@ -0,0 +1,106 @@ +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5" +) + +// Sequential siblings (programs global, courses per program, modules per course, lessons per module) use unique sort_order. +// These helpers move id from oldPos to newPos without collisions by temporarily assigning sort_order = -id then shifting intermediates. + +func repositionProgramSortOrder(ctx context.Context, tx pgx.Tx, id int64, oldPos, newPos int32) error { + if oldPos == newPos { + return nil + } + if _, err := tx.Exec(ctx, `UPDATE programs SET sort_order = -id WHERE id = $1`, id); err != nil { + return err + } + if newPos < oldPos { + if _, err := tx.Exec(ctx, ` +UPDATE programs SET sort_order = sort_order + 1 +WHERE sort_order >= $1 AND sort_order < $2 AND id <> $3`, newPos, oldPos, id); err != nil { + return err + } + } else { + if _, err := tx.Exec(ctx, ` +UPDATE programs SET sort_order = sort_order - 1 +WHERE sort_order > $1 AND sort_order <= $2 AND id <> $3`, oldPos, newPos, id); err != nil { + return err + } + } + _, err := tx.Exec(ctx, `UPDATE programs SET sort_order = $1 WHERE id = $2`, newPos, id) + return err +} + +func repositionCourseSortOrder(ctx context.Context, tx pgx.Tx, programID, id int64, oldPos, newPos int32) error { + if oldPos == newPos { + return nil + } + if _, err := tx.Exec(ctx, `UPDATE courses SET sort_order = -id WHERE id = $1`, id); err != nil { + return err + } + if newPos < oldPos { + if _, err := tx.Exec(ctx, ` +UPDATE courses SET sort_order = sort_order + 1 +WHERE program_id = $4 AND sort_order >= $1 AND sort_order < $2 AND id <> $3`, newPos, oldPos, id, programID); err != nil { + return err + } + } else { + if _, err := tx.Exec(ctx, ` +UPDATE courses SET sort_order = sort_order - 1 +WHERE program_id = $4 AND sort_order > $1 AND sort_order <= $2 AND id <> $3`, oldPos, newPos, id, programID); err != nil { + return err + } + } + _, err := tx.Exec(ctx, `UPDATE courses SET sort_order = $1 WHERE id = $2`, newPos, id) + return err +} + +func repositionModuleSortOrder(ctx context.Context, tx pgx.Tx, courseID, id int64, oldPos, newPos int32) error { + if oldPos == newPos { + return nil + } + if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order = -id WHERE id = $1`, id); err != nil { + return err + } + if newPos < oldPos { + if _, err := tx.Exec(ctx, ` +UPDATE modules SET sort_order = sort_order + 1 +WHERE course_id = $4 AND sort_order >= $1 AND sort_order < $2 AND id <> $3`, newPos, oldPos, id, courseID); err != nil { + return err + } + } else { + if _, err := tx.Exec(ctx, ` +UPDATE modules SET sort_order = sort_order - 1 +WHERE course_id = $4 AND sort_order > $1 AND sort_order <= $2 AND id <> $3`, oldPos, newPos, id, courseID); err != nil { + return err + } + } + _, err := tx.Exec(ctx, `UPDATE modules SET sort_order = $1 WHERE id = $2`, newPos, id) + return err +} + +func repositionLessonSortOrder(ctx context.Context, tx pgx.Tx, moduleID, id int64, oldPos, newPos int32) error { + if oldPos == newPos { + return nil + } + if _, err := tx.Exec(ctx, `UPDATE lessons SET sort_order = -id WHERE id = $1`, id); err != nil { + return err + } + if newPos < oldPos { + if _, err := tx.Exec(ctx, ` +UPDATE lessons SET sort_order = sort_order + 1 +WHERE module_id = $4 AND sort_order >= $1 AND sort_order < $2 AND id <> $3`, newPos, oldPos, id, moduleID); err != nil { + return err + } + } else { + if _, err := tx.Exec(ctx, ` +UPDATE lessons SET sort_order = sort_order - 1 +WHERE module_id = $4 AND sort_order > $1 AND sort_order <= $2 AND id <> $3`, oldPos, newPos, id, moduleID); err != nil { + return err + } + } + _, err := tx.Exec(ctx, `UPDATE lessons SET sort_order = $1 WHERE id = $2`, newPos, id) + return err +} diff --git a/internal/repository/programs.go b/internal/repository/programs.go index 5bfafc7..fbbcce3 100644 --- a/internal/repository/programs.go +++ b/internal/repository/programs.go @@ -28,11 +28,36 @@ func programToDomain(p dbgen.Program) domain.Program { } func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error) { + if input.SortOrder != nil { + q, tx, err := s.BeginTx(ctx) + if err != nil { + return domain.Program{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + target := int32(*input.SortOrder) + if _, err := tx.Exec(ctx, `UPDATE programs SET sort_order = sort_order + 1 WHERE sort_order >= $1`, target); err != nil { + return domain.Program{}, err + } + p, err := q.CreateProgram(ctx, dbgen.CreateProgramParams{ + Name: input.Name, + Description: toPgText(input.Description), + Thumbnail: toPgText(input.Thumbnail), + SortOrder: pgtype.Int4{Int32: target, Valid: true}, + }) + if err != nil { + return domain.Program{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.Program{}, err + } + return programToDomain(p), nil + } + p, err := s.queries.CreateProgram(ctx, dbgen.CreateProgramParams{ Name: input.Name, Description: toPgText(input.Description), Thumbnail: toPgText(input.Thumbnail), - SortOrder: optionalInt4Update(input.SortOrder), + SortOrder: pgtype.Int4{Valid: false}, }) if err != nil { return domain.Program{}, err @@ -100,6 +125,47 @@ func optionalInt4Update(v *int) pgtype.Int4 { } func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) { + sortParam := optionalInt4Update(input.SortOrder) + if input.SortOrder != nil { + cur, err := s.GetProgramByID(ctx, id) + if err != nil { + return domain.Program{}, err + } + oldPos := int32(cur.SortOrder) + newPos := int32(*input.SortOrder) + if oldPos != newPos { + q, tx, err := s.BeginTx(ctx) + if err != nil { + return domain.Program{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + if err := repositionProgramSortOrder(ctx, tx, id, oldPos, newPos); err != nil { + return domain.Program{}, err + } + var nameText pgtype.Text + if input.Name != nil { + nameText = pgtype.Text{String: *input.Name, Valid: true} + } else { + nameText = pgtype.Text{Valid: false} + } + p, err := q.UpdateProgram(ctx, dbgen.UpdateProgramParams{ + ID: id, + Name: nameText, + Description: optionalTextUpdate(input.Description), + Thumbnail: optionalTextUpdate(input.Thumbnail), + SortOrder: pgtype.Int4{Valid: false}, + }) + if err != nil { + return domain.Program{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.Program{}, err + } + return programToDomain(p), nil + } + sortParam = pgtype.Int4{Valid: false} + } + var nameText pgtype.Text if input.Name != nil { nameText = pgtype.Text{String: *input.Name, Valid: true} @@ -111,7 +177,7 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update Name: nameText, Description: optionalTextUpdate(input.Description), Thumbnail: optionalTextUpdate(input.Thumbnail), - SortOrder: optionalInt4Update(input.SortOrder), + SortOrder: sortParam, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) {