diff --git a/README.md b/README.md index d248eb0..24e7361 100644 --- a/README.md +++ b/README.md @@ -90,17 +90,17 @@ created_at – Audit timestamp Relationships: -One Course Category → Many Courses +One Course Category → Many Course Sub-categories Course Category -└── Courses[] +└── Course Sub-categories[] -2. Course +2. Course Sub-category -Table: courses +Table: course_sub_categories Purpose: -Represents a full course offering under a category. +A grouping within a category (e.g., Speaking, Listening under Learning English). Key Fields: @@ -114,23 +114,23 @@ Relationships: Belongs to one Course Category -Has many Sub-courses +Has many Courses Course Category -└── Course - └── Sub-courses[] +└── Course Sub-category + └── Courses[] -3. Sub-course +3. Course -Table: sub_courses +Table: courses Purpose: -A learning unit within a course representing different skill levels +A learning unit within a sub-category representing different skill levels (e.g., Beginner, Intermediate, Advanced). Key Fields: -course_id – FK → courses.id +sub_category_id – FK → course_sub_categories.id title, description @@ -144,27 +144,27 @@ is_active Relationships: -Belongs to one Course +Belongs to one Course Sub-category -Has many Sub-course Videos +Has many Course Videos Has many Practices -Course -└── Sub-course - ├── Sub-course Videos[] +Course Sub-category +└── Course + ├── Course Videos[] └── Practices[] -4. Sub-course Video +4. Course Video -Table: sub_course_videos +Table: course_videos Purpose: -Video learning content attached to a sub-course. +Video learning content attached to a course. Key Fields: -sub_course_id – FK → sub_courses.id +course_id – FK → courses.id title, description @@ -190,21 +190,21 @@ is_active Relationships: -Belongs to one Sub-course +Belongs to one Course -Sub-course -└── Sub-course Video +Course +└── Course Video 5. Practice Table: practices Purpose: -Exercises or assessments that belong to a sub-course. +Exercises or assessments that belong to a course. Key Fields: -sub_course_id – FK → sub_courses.id +course_id – FK → courses.id title, description @@ -216,11 +216,11 @@ is_active Relationships: -Belongs to one Sub-course +Belongs to one Course One Practice → Many Practice Questions -Sub-course +Course └── Practice └── Practice Questions[] @@ -258,17 +258,17 @@ Practice Complete Hierarchical Flow (Compact View) Course Category -└── Course - └── Sub-course (with levels: BEGINNER, INTERMEDIATE, ADVANCED) - ├── Sub-course Video +└── Course Sub-category + └── Course (with levels: BEGINNER, INTERMEDIATE, ADVANCED) + ├── Course Video └── Practice └── Practice Question Architectural Observations -Simple three-level hierarchy: Category → Course → Sub-course +Simple three-level hierarchy: Category → Sub-category → Course -Level is now a property of sub-course, not a separate entity +Level is now a property of Course, not a separate entity Cascade deletes ensure referential integrity diff --git a/db/migrations/000023_reorder_support.down.sql b/db/migrations/000023_reorder_support.down.sql new file mode 100644 index 0000000..5394ab3 --- /dev/null +++ b/db/migrations/000023_reorder_support.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE course_categories DROP COLUMN display_order; +ALTER TABLE courses DROP COLUMN display_order; +ALTER TABLE question_sets DROP COLUMN display_order; diff --git a/db/migrations/000023_reorder_support.up.sql b/db/migrations/000023_reorder_support.up.sql new file mode 100644 index 0000000..18c05fa --- /dev/null +++ b/db/migrations/000023_reorder_support.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE course_categories ADD COLUMN display_order INT NOT NULL DEFAULT 0; +ALTER TABLE courses ADD COLUMN display_order INT NOT NULL DEFAULT 0; +ALTER TABLE question_sets ADD COLUMN display_order INT NOT NULL DEFAULT 0; diff --git a/db/query/course_catagories.sql b/db/query/course_catagories.sql index 1cfe61b..2eb530f 100644 --- a/db/query/course_catagories.sql +++ b/db/query/course_catagories.sql @@ -21,7 +21,7 @@ SELECT is_active, created_at FROM course_categories -ORDER BY created_at DESC +ORDER BY display_order ASC, created_at DESC LIMIT sqlc.narg('limit')::INT OFFSET sqlc.narg('offset')::INT; @@ -37,3 +37,11 @@ WHERE id = $3; -- name: DeleteCourseCategory :exec DELETE FROM course_categories WHERE id = $1; + +-- name: ReorderCourseCategories :exec +UPDATE course_categories +SET display_order = bulk.position +FROM ( + SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position +) AS bulk +WHERE course_categories.id = bulk.id; diff --git a/db/query/courses.sql b/db/query/courses.sql index 87fafad..2e5b7eb 100644 --- a/db/query/courses.sql +++ b/db/query/courses.sql @@ -29,7 +29,7 @@ SELECT is_active FROM courses WHERE category_id = $1 -ORDER BY id DESC +ORDER BY display_order ASC, id ASC LIMIT sqlc.narg('limit')::INT OFFSET sqlc.narg('offset')::INT; @@ -48,3 +48,11 @@ WHERE id = $6; -- name: DeleteCourse :exec DELETE FROM courses WHERE id = $1; + +-- name: ReorderCourses :exec +UPDATE courses +SET display_order = bulk.position +FROM ( + SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position +) AS bulk +WHERE courses.id = bulk.id; diff --git a/db/query/learning_tree.sql b/db/query/learning_tree.sql index 62b9ece..63cd077 100644 --- a/db/query/learning_tree.sql +++ b/db/query/learning_tree.sql @@ -47,7 +47,7 @@ SELECT id, title, description, persona, status, FROM question_sets qs WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1 AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' -ORDER BY qs.created_at; +ORDER BY qs.display_order ASC, qs.created_at; -- name: GetSubCoursePrerequisitesForLearningPath :many SELECT p.prerequisite_sub_course_id, sc.title, sc.level diff --git a/db/query/question_sets.sql b/db/query/question_sets.sql index 295a103..4020ead 100644 --- a/db/query/question_sets.sql +++ b/db/query/question_sets.sql @@ -27,7 +27,7 @@ FROM question_sets WHERE owner_type = $1 AND owner_id = $2 AND status != 'ARCHIVED' -ORDER BY created_at DESC; +ORDER BY display_order ASC, created_at DESC; -- name: GetQuestionSetsByType :many SELECT @@ -114,3 +114,11 @@ SET sub_course_video_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2; + +-- name: ReorderQuestionSets :exec +UPDATE question_sets +SET display_order = bulk.position +FROM ( + SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position +) AS bulk +WHERE question_sets.id = bulk.id; diff --git a/db/query/sub_course_videos.sql b/db/query/sub_course_videos.sql index 0f2caf0..a44881f 100644 --- a/db/query/sub_course_videos.sql +++ b/db/query/sub_course_videos.sql @@ -112,3 +112,11 @@ WHERE id = $1; -- name: DeleteSubCourseVideo :exec DELETE FROM sub_course_videos WHERE id = $1; + +-- name: ReorderSubCourseVideos :exec +UPDATE sub_course_videos +SET display_order = bulk.position +FROM ( + SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position +) AS bulk +WHERE sub_course_videos.id = bulk.id; diff --git a/db/query/sub_courses.sql b/db/query/sub_courses.sql index 0af0348..979e532 100644 --- a/db/query/sub_courses.sql +++ b/db/query/sub_courses.sql @@ -80,3 +80,11 @@ RETURNING *; UPDATE sub_courses SET is_active = FALSE WHERE id = $1; + +-- name: ReorderSubCourses :exec +UPDATE sub_courses +SET display_order = bulk.position +FROM ( + SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position +) AS bulk +WHERE sub_courses.id = bulk.id; diff --git a/gen/db/course_catagories.sql.go b/gen/db/course_catagories.sql.go index b159b9d..e2467b7 100644 --- a/gen/db/course_catagories.sql.go +++ b/gen/db/course_catagories.sql.go @@ -17,7 +17,7 @@ INSERT INTO course_categories ( is_active ) VALUES ($1, COALESCE($2, true)) -RETURNING id, name, is_active, created_at +RETURNING id, name, is_active, created_at, display_order ` type CreateCourseCategoryParams struct { @@ -33,6 +33,7 @@ func (q *Queries) CreateCourseCategory(ctx context.Context, arg CreateCourseCate &i.Name, &i.IsActive, &i.CreatedAt, + &i.DisplayOrder, ) return i, err } @@ -55,7 +56,7 @@ SELECT is_active, created_at FROM course_categories -ORDER BY created_at DESC +ORDER BY display_order ASC, created_at DESC LIMIT $2::INT OFFSET $1::INT ` @@ -100,7 +101,7 @@ func (q *Queries) GetAllCourseCategories(ctx context.Context, arg GetAllCourseCa } const GetCourseCategoryByID = `-- name: GetCourseCategoryByID :one -SELECT id, name, is_active, created_at +SELECT id, name, is_active, created_at, display_order FROM course_categories WHERE id = $1 ` @@ -113,10 +114,30 @@ func (q *Queries) GetCourseCategoryByID(ctx context.Context, id int64) (CourseCa &i.Name, &i.IsActive, &i.CreatedAt, + &i.DisplayOrder, ) return i, err } +const ReorderCourseCategories = `-- name: ReorderCourseCategories :exec +UPDATE course_categories +SET display_order = bulk.position +FROM ( + SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position +) AS bulk +WHERE course_categories.id = bulk.id +` + +type ReorderCourseCategoriesParams struct { + Ids []int64 `json:"ids"` + Positions []int32 `json:"positions"` +} + +func (q *Queries) ReorderCourseCategories(ctx context.Context, arg ReorderCourseCategoriesParams) error { + _, err := q.db.Exec(ctx, ReorderCourseCategories, arg.Ids, arg.Positions) + return err +} + const UpdateCourseCategory = `-- name: UpdateCourseCategory :exec UPDATE course_categories SET diff --git a/gen/db/courses.sql.go b/gen/db/courses.sql.go index 7ffe791..453c079 100644 --- a/gen/db/courses.sql.go +++ b/gen/db/courses.sql.go @@ -21,7 +21,7 @@ INSERT INTO courses ( is_active ) VALUES ($1, $2, $3, $4, $5, COALESCE($6, true)) -RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url +RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order ` type CreateCourseParams struct { @@ -51,6 +51,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou &i.IsActive, &i.Thumbnail, &i.IntroVideoUrl, + &i.DisplayOrder, ) return i, err } @@ -66,7 +67,7 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error { } const GetCourseByID = `-- name: GetCourseByID :one -SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url +SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order FROM courses WHERE id = $1 ` @@ -82,6 +83,7 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) { &i.IsActive, &i.Thumbnail, &i.IntroVideoUrl, + &i.DisplayOrder, ) return i, err } @@ -98,7 +100,7 @@ SELECT is_active FROM courses WHERE category_id = $1 -ORDER BY id DESC +ORDER BY display_order ASC, id ASC LIMIT $3::INT OFFSET $2::INT ` @@ -149,6 +151,25 @@ func (q *Queries) GetCoursesByCategory(ctx context.Context, arg GetCoursesByCate return items, nil } +const ReorderCourses = `-- name: ReorderCourses :exec +UPDATE courses +SET display_order = bulk.position +FROM ( + SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position +) AS bulk +WHERE courses.id = bulk.id +` + +type ReorderCoursesParams struct { + Ids []int64 `json:"ids"` + Positions []int32 `json:"positions"` +} + +func (q *Queries) ReorderCourses(ctx context.Context, arg ReorderCoursesParams) error { + _, err := q.db.Exec(ctx, ReorderCourses, arg.Ids, arg.Positions) + return err +} + const UpdateCourse = `-- name: UpdateCourse :exec UPDATE courses SET diff --git a/gen/db/learning_tree.sql.go b/gen/db/learning_tree.sql.go index 05e8d52..9a06f5d 100644 --- a/gen/db/learning_tree.sql.go +++ b/gen/db/learning_tree.sql.go @@ -145,7 +145,7 @@ SELECT id, title, description, persona, status, FROM question_sets qs WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1 AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' -ORDER BY qs.created_at +ORDER BY qs.display_order ASC, qs.created_at ` type GetSubCoursePracticesForLearningPathRow struct { diff --git a/gen/db/models.go b/gen/db/models.go index 8e3fed1..cb8097c 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -30,13 +30,15 @@ type Course struct { IsActive bool `json:"is_active"` Thumbnail pgtype.Text `json:"thumbnail"` IntroVideoUrl pgtype.Text `json:"intro_video_url"` + DisplayOrder int32 `json:"display_order"` } type CourseCategory struct { - ID int64 `json:"id"` - Name string `json:"name"` - IsActive bool `json:"is_active"` - CreatedAt pgtype.Timestamptz `json:"created_at"` + ID int64 `json:"id"` + Name string `json:"name"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + DisplayOrder int32 `json:"display_order"` } type Device struct { @@ -170,6 +172,7 @@ type QuestionSet struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"` + DisplayOrder int32 `json:"display_order"` } type QuestionSetItem struct { diff --git a/gen/db/question_set_items.sql.go b/gen/db/question_set_items.sql.go index 0443f46..62cfb28 100644 --- a/gen/db/question_set_items.sql.go +++ b/gen/db/question_set_items.sql.go @@ -198,7 +198,7 @@ func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQu } const GetQuestionSetsContainingQuestion = `-- name: GetQuestionSetsContainingQuestion :many -SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id +SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order FROM question_sets qs JOIN question_set_items qsi ON qsi.set_id = qs.id WHERE qsi.question_id = $1 @@ -230,6 +230,7 @@ func (q *Queries) GetQuestionSetsContainingQuestion(ctx context.Context, questio &i.CreatedAt, &i.UpdatedAt, &i.SubCourseVideoID, + &i.DisplayOrder, ); err != nil { return nil, err } diff --git a/gen/db/question_sets.sql.go b/gen/db/question_sets.sql.go index fe8ae87..5c0689b 100644 --- a/gen/db/question_sets.sql.go +++ b/gen/db/question_sets.sql.go @@ -67,7 +67,7 @@ INSERT INTO question_sets ( sub_course_video_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12) -RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id +RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order ` type CreateQuestionSetParams struct { @@ -117,6 +117,7 @@ func (q *Queries) CreateQuestionSet(ctx context.Context, arg CreateQuestionSetPa &i.CreatedAt, &i.UpdatedAt, &i.SubCourseVideoID, + &i.DisplayOrder, ) return i, err } @@ -132,7 +133,7 @@ func (q *Queries) DeleteQuestionSet(ctx context.Context, id int64) error { } const GetInitialAssessmentSet = `-- name: GetInitialAssessmentSet :one -SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id +SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order FROM question_sets WHERE set_type = 'INITIAL_ASSESSMENT' AND status = 'PUBLISHED' @@ -159,12 +160,13 @@ func (q *Queries) GetInitialAssessmentSet(ctx context.Context) (QuestionSet, err &i.CreatedAt, &i.UpdatedAt, &i.SubCourseVideoID, + &i.DisplayOrder, ) return i, err } const GetPublishedQuestionSetsByOwner = `-- name: GetPublishedQuestionSetsByOwner :many -SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id +SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order FROM question_sets WHERE owner_type = $1 AND owner_id = $2 @@ -202,6 +204,7 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu &i.CreatedAt, &i.UpdatedAt, &i.SubCourseVideoID, + &i.DisplayOrder, ); err != nil { return nil, err } @@ -214,7 +217,7 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu } const GetQuestionSetByID = `-- name: GetQuestionSetByID :one -SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id +SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order FROM question_sets WHERE id = $1 ` @@ -238,17 +241,18 @@ func (q *Queries) GetQuestionSetByID(ctx context.Context, id int64) (QuestionSet &i.CreatedAt, &i.UpdatedAt, &i.SubCourseVideoID, + &i.DisplayOrder, ) return i, err } const GetQuestionSetsByOwner = `-- name: GetQuestionSetsByOwner :many -SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id +SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order FROM question_sets WHERE owner_type = $1 AND owner_id = $2 AND status != 'ARCHIVED' -ORDER BY created_at DESC +ORDER BY display_order ASC, created_at DESC ` type GetQuestionSetsByOwnerParams struct { @@ -281,6 +285,7 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet &i.CreatedAt, &i.UpdatedAt, &i.SubCourseVideoID, + &i.DisplayOrder, ); err != nil { return nil, err } @@ -295,7 +300,7 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet const GetQuestionSetsByType = `-- name: GetQuestionSetsByType :many SELECT COUNT(*) OVER () AS total_count, - qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id + qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order FROM question_sets qs WHERE set_type = $1 AND status != 'ARCHIVED' @@ -327,6 +332,7 @@ type GetQuestionSetsByTypeRow struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"` + DisplayOrder int32 `json:"display_order"` } func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSetsByTypeParams) ([]GetQuestionSetsByTypeRow, error) { @@ -355,6 +361,7 @@ func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSets &i.CreatedAt, &i.UpdatedAt, &i.SubCourseVideoID, + &i.DisplayOrder, ); err != nil { return nil, err } @@ -435,6 +442,25 @@ func (q *Queries) RemoveUserPersonaFromQuestionSet(ctx context.Context, arg Remo return err } +const ReorderQuestionSets = `-- name: ReorderQuestionSets :exec +UPDATE question_sets +SET display_order = bulk.position +FROM ( + SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position +) AS bulk +WHERE question_sets.id = bulk.id +` + +type ReorderQuestionSetsParams struct { + Ids []int64 `json:"ids"` + Positions []int32 `json:"positions"` +} + +func (q *Queries) ReorderQuestionSets(ctx context.Context, arg ReorderQuestionSetsParams) error { + _, err := q.db.Exec(ctx, ReorderQuestionSets, arg.Ids, arg.Positions) + return err +} + const UpdateQuestionSet = `-- name: UpdateQuestionSet :exec UPDATE question_sets SET diff --git a/gen/db/sub_course_videos.sql.go b/gen/db/sub_course_videos.sql.go index e968ea7..f19b47b 100644 --- a/gen/db/sub_course_videos.sql.go +++ b/gen/db/sub_course_videos.sql.go @@ -345,6 +345,25 @@ func (q *Queries) PublishSubCourseVideo(ctx context.Context, id int64) error { return err } +const ReorderSubCourseVideos = `-- name: ReorderSubCourseVideos :exec +UPDATE sub_course_videos +SET display_order = bulk.position +FROM ( + SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position +) AS bulk +WHERE sub_course_videos.id = bulk.id +` + +type ReorderSubCourseVideosParams struct { + Ids []int64 `json:"ids"` + Positions []int32 `json:"positions"` +} + +func (q *Queries) ReorderSubCourseVideos(ctx context.Context, arg ReorderSubCourseVideosParams) error { + _, err := q.db.Exec(ctx, ReorderSubCourseVideos, arg.Ids, arg.Positions) + return err +} + const UpdateSubCourseVideo = `-- name: UpdateSubCourseVideo :exec UPDATE sub_course_videos SET diff --git a/gen/db/sub_courses.sql.go b/gen/db/sub_courses.sql.go index 1dc7ce0..46b38dd 100644 --- a/gen/db/sub_courses.sql.go +++ b/gen/db/sub_courses.sql.go @@ -261,6 +261,25 @@ func (q *Queries) ListSubCoursesByCourse(ctx context.Context, courseID int64) ([ return items, nil } +const ReorderSubCourses = `-- name: ReorderSubCourses :exec +UPDATE sub_courses +SET display_order = bulk.position +FROM ( + SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position +) AS bulk +WHERE sub_courses.id = bulk.id +` + +type ReorderSubCoursesParams struct { + Ids []int64 `json:"ids"` + Positions []int32 `json:"positions"` +} + +func (q *Queries) ReorderSubCourses(ctx context.Context, arg ReorderSubCoursesParams) error { + _, err := q.db.Exec(ctx, ReorderSubCourses, arg.Ids, arg.Positions) + return err +} + const UpdateSubCourse = `-- name: UpdateSubCourse :exec UPDATE sub_courses SET diff --git a/internal/ports/course_management.go b/internal/ports/course_management.go index 4aeabd3..f0259d5 100644 --- a/internal/ports/course_management.go +++ b/internal/ports/course_management.go @@ -175,6 +175,13 @@ type CourseStore interface { // Learning Path (full nested structure for a course) GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error) + + // Reorder (drag-and-drop support) + ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error + ReorderCourses(ctx context.Context, ids []int64, positions []int32) error + ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error + ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error + ReorderQuestionSets(ctx context.Context, ids []int64, positions []int32) error } type ProgressionStore interface { diff --git a/internal/repository/course_catagories.go b/internal/repository/course_catagories.go index b941e18..72e9f85 100644 --- a/internal/repository/course_catagories.go +++ b/internal/repository/course_catagories.go @@ -119,3 +119,10 @@ func (s *Store) DeleteCourseCategory( return s.queries.DeleteCourseCategory(ctx, id) } + +func (s *Store) ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error { + return s.queries.ReorderCourseCategories(ctx, dbgen.ReorderCourseCategoriesParams{ + Ids: ids, + Positions: positions, + }) +} diff --git a/internal/repository/courses.go b/internal/repository/courses.go index 32dcfaf..23685de 100644 --- a/internal/repository/courses.go +++ b/internal/repository/courses.go @@ -158,6 +158,13 @@ func mapCourse(row dbgen.Course) domain.Course { } } +func (s *Store) ReorderCourses(ctx context.Context, ids []int64, positions []int32) error { + return s.queries.ReorderCourses(ctx, dbgen.ReorderCoursesParams{ + Ids: ids, + Positions: positions, + }) +} + func ptrText(t pgtype.Text) *string { if t.Valid { return &t.String diff --git a/internal/repository/questions.go b/internal/repository/questions.go index 076b3d3..ac64e0d 100644 --- a/internal/repository/questions.go +++ b/internal/repository/questions.go @@ -809,3 +809,10 @@ func (s *Store) GetUserPersonasByQuestionSetID(ctx context.Context, questionSetI } return result, nil } + +func (s *Store) ReorderQuestionSets(ctx context.Context, ids []int64, positions []int32) error { + return s.queries.ReorderQuestionSets(ctx, dbgen.ReorderQuestionSetsParams{ + Ids: ids, + Positions: positions, + }) +} diff --git a/internal/repository/sub_course_videos.go b/internal/repository/sub_course_videos.go index 3a60f30..67b3c20 100644 --- a/internal/repository/sub_course_videos.go +++ b/internal/repository/sub_course_videos.go @@ -283,3 +283,10 @@ func (s *Store) GetVideoByVimeoID(ctx context.Context, vimeoID string) (domain.S } return mapSubCourseVideoRow(row), nil } + +func (s *Store) ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error { + return s.queries.ReorderSubCourseVideos(ctx, dbgen.ReorderSubCourseVideosParams{ + Ids: ids, + Positions: positions, + }) +} diff --git a/internal/repository/sub_courses.go b/internal/repository/sub_courses.go index 43f0de6..05634ff 100644 --- a/internal/repository/sub_courses.go +++ b/internal/repository/sub_courses.go @@ -233,4 +233,11 @@ func (s *Store) DeleteSubCourse( }, nil } +func (s *Store) ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error { + return s.queries.ReorderSubCourses(ctx, dbgen.ReorderSubCoursesParams{ + Ids: ids, + Positions: positions, + }) +} + diff --git a/internal/services/course_management/learning_tree.go b/internal/services/course_management/learning_tree.go index b5ccb64..5b9850c 100644 --- a/internal/services/course_management/learning_tree.go +++ b/internal/services/course_management/learning_tree.go @@ -12,3 +12,23 @@ func (s *Service) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, func (s *Service) GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error) { return s.courseStore.GetCourseLearningPath(ctx, courseID) } + +func (s *Service) ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error { + return s.courseStore.ReorderCourseCategories(ctx, ids, positions) +} + +func (s *Service) ReorderCourses(ctx context.Context, ids []int64, positions []int32) error { + return s.courseStore.ReorderCourses(ctx, ids, positions) +} + +func (s *Service) ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error { + return s.courseStore.ReorderSubCourses(ctx, ids, positions) +} + +func (s *Service) ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error { + return s.courseStore.ReorderSubCourseVideos(ctx, ids, positions) +} + +func (s *Service) ReorderQuestionSets(ctx context.Context, ids []int64, positions []int32) error { + return s.courseStore.ReorderQuestionSets(ctx, ids, positions) +} diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index 67e813d..22e5da6 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -9,6 +9,7 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "course_categories.get", Name: "Get Course Category", Description: "Get a course category by ID", GroupName: "Course Categories"}, {Key: "course_categories.update", Name: "Update Course Category", Description: "Update a course category", GroupName: "Course Categories"}, {Key: "course_categories.delete", Name: "Delete Course Category", Description: "Delete a course category", GroupName: "Course Categories"}, + {Key: "course_categories.reorder", Name: "Reorder Course Categories", Description: "Reorder course categories", GroupName: "Course Categories"}, // Course Management - Courses {Key: "courses.create", Name: "Create Course", Description: "Create a new course", GroupName: "Courses"}, @@ -17,6 +18,7 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "courses.update", Name: "Update Course", Description: "Update a course", GroupName: "Courses"}, {Key: "courses.upload_thumbnail", Name: "Upload Course Thumbnail", Description: "Upload course thumbnail image", GroupName: "Courses"}, {Key: "courses.delete", Name: "Delete Course", Description: "Delete a course", GroupName: "Courses"}, + {Key: "courses.reorder", Name: "Reorder Courses", Description: "Reorder courses", GroupName: "Courses"}, // Course Management - Sub-courses {Key: "subcourses.create", Name: "Create Sub-course", Description: "Create a new sub-course", GroupName: "Sub-courses"}, @@ -28,6 +30,7 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "subcourses.upload_thumbnail", Name: "Upload Sub-course Thumbnail", Description: "Upload sub-course thumbnail", GroupName: "Sub-courses"}, {Key: "subcourses.deactivate", Name: "Deactivate Sub-course", Description: "Deactivate a sub-course", GroupName: "Sub-courses"}, {Key: "subcourses.delete", Name: "Delete Sub-course", Description: "Delete a sub-course", GroupName: "Sub-courses"}, + {Key: "subcourses.reorder", Name: "Reorder Sub-courses", Description: "Reorder sub-courses", GroupName: "Sub-courses"}, // Course Management - Videos {Key: "videos.create", Name: "Create Video", Description: "Create a sub-course video", GroupName: "Videos"}, @@ -40,9 +43,11 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "videos.publish", Name: "Publish Video", Description: "Publish a video", GroupName: "Videos"}, {Key: "videos.update", Name: "Update Video", Description: "Update a video", GroupName: "Videos"}, {Key: "videos.delete", Name: "Delete Video", Description: "Delete a video", GroupName: "Videos"}, + {Key: "videos.reorder", Name: "Reorder Videos", Description: "Reorder videos", GroupName: "Videos"}, // Learning Tree {Key: "learning_tree.get", Name: "Get Learning Tree", Description: "Get full learning tree", GroupName: "Learning Tree"}, + {Key: "practices.reorder", Name: "Reorder Practices", Description: "Reorder practices", GroupName: "Learning Tree"}, // Questions {Key: "questions.create", Name: "Create Question", Description: "Create a new question", GroupName: "Questions"}, @@ -222,13 +227,13 @@ var AllPermissions = []domain.PermissionSeed{ var DefaultRolePermissions = map[string][]string{ "ADMIN": { // Course Management (full access) - "course_categories.create", "course_categories.list", "course_categories.get", "course_categories.update", "course_categories.delete", - "courses.create", "courses.get", "courses.list_by_category", "courses.update", "courses.upload_thumbnail", "courses.delete", + "course_categories.create", "course_categories.list", "course_categories.get", "course_categories.update", "course_categories.delete", "course_categories.reorder", + "courses.create", "courses.get", "courses.list_by_category", "courses.update", "courses.upload_thumbnail", "courses.delete", "courses.reorder", "subcourses.create", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", - "subcourses.update", "subcourses.upload_thumbnail", "subcourses.deactivate", "subcourses.delete", + "subcourses.update", "subcourses.upload_thumbnail", "subcourses.deactivate", "subcourses.delete", "subcourses.reorder", "videos.create", "videos.create_vimeo", "videos.upload", "videos.import_vimeo", "videos.get", - "videos.list_by_subcourse", "videos.list_published", "videos.publish", "videos.update", "videos.delete", - "learning_tree.get", + "videos.list_by_subcourse", "videos.list_published", "videos.publish", "videos.update", "videos.delete", "videos.reorder", + "learning_tree.get", "practices.reorder", // Questions (full access) "questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete", diff --git a/internal/web_server/handlers/course_management.go b/internal/web_server/handlers/course_management.go index ed06f4e..2e3db6e 100644 --- a/internal/web_server/handlers/course_management.go +++ b/internal/web_server/handlers/course_management.go @@ -1455,6 +1455,257 @@ func (h *Handler) GetCourseLearningPath(c *fiber.Ctx) error { }) } +// Reorder Handlers — support drag-and-drop ordering from admin panel + +type reorderItem struct { + ID int64 `json:"id" validate:"required"` + Position int32 `json:"position"` +} + +type reorderReq struct { + Items []reorderItem `json:"items" validate:"required,min=1"` +} + +func parseReorderItems(items []reorderItem) ([]int64, []int32) { + ids := make([]int64, len(items)) + positions := make([]int32, len(items)) + for i, item := range items { + ids[i] = item.ID + positions[i] = item.Position + } + return ids, positions +} + +// ReorderCourseCategories godoc +// @Summary Reorder course categories +// @Description Updates the display_order of course categories for drag-and-drop sorting +// @Tags course-categories +// @Accept json +// @Produce json +// @Param body body reorderReq true "Reorder payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/categories/reorder [put] +func (h *Handler) ReorderCourseCategories(c *fiber.Ctx) error { + var req reorderReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + if len(req.Items) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Items array is required", + Error: "items must not be empty", + }) + } + + ids, positions := parseReorderItems(req.Items) + if err := h.courseMgmtSvc.ReorderCourseCategories(c.Context(), ids, positions); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to reorder course categories", + Error: err.Error(), + }) + } + + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions}) + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCategoryUpdated, domain.ResourceCategory, nil, "Reordered course categories", meta, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Course categories reordered successfully", + }) +} + +// ReorderCourses godoc +// @Summary Reorder courses within a category +// @Description Updates the display_order of courses for drag-and-drop sorting +// @Tags courses +// @Accept json +// @Produce json +// @Param body body reorderReq true "Reorder payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/courses/reorder [put] +func (h *Handler) ReorderCourses(c *fiber.Ctx) error { + var req reorderReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + if len(req.Items) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Items array is required", + Error: "items must not be empty", + }) + } + + ids, positions := parseReorderItems(req.Items) + if err := h.courseMgmtSvc.ReorderCourses(c.Context(), ids, positions); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to reorder courses", + Error: err.Error(), + }) + } + + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions}) + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseUpdated, domain.ResourceCourse, nil, "Reordered courses", meta, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Courses reordered successfully", + }) +} + +// ReorderSubCourses godoc +// @Summary Reorder sub-courses within a course +// @Description Updates the display_order of sub-courses for drag-and-drop sorting +// @Tags sub-courses +// @Accept json +// @Produce json +// @Param body body reorderReq true "Reorder payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-courses/reorder [put] +func (h *Handler) ReorderSubCourses(c *fiber.Ctx) error { + var req reorderReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + if len(req.Items) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Items array is required", + Error: "items must not be empty", + }) + } + + ids, positions := parseReorderItems(req.Items) + if err := h.courseMgmtSvc.ReorderSubCourses(c.Context(), ids, positions); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to reorder sub-courses", + Error: err.Error(), + }) + } + + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions}) + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseUpdated, domain.ResourceSubCourse, nil, "Reordered sub-courses", meta, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Sub-courses reordered successfully", + }) +} + +// ReorderSubCourseVideos godoc +// @Summary Reorder videos within a sub-course +// @Description Updates the display_order of videos for drag-and-drop sorting +// @Tags sub-course-videos +// @Accept json +// @Produce json +// @Param body body reorderReq true "Reorder payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/videos/reorder [put] +func (h *Handler) ReorderSubCourseVideos(c *fiber.Ctx) error { + var req reorderReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + if len(req.Items) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Items array is required", + Error: "items must not be empty", + }) + } + + ids, positions := parseReorderItems(req.Items) + if err := h.courseMgmtSvc.ReorderSubCourseVideos(c.Context(), ids, positions); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to reorder videos", + Error: err.Error(), + }) + } + + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions}) + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoUpdated, domain.ResourceVideo, nil, "Reordered sub-course videos", meta, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Videos reordered successfully", + }) +} + +// ReorderPractices godoc +// @Summary Reorder practices (question sets) within a sub-course +// @Description Updates the display_order of practices for drag-and-drop sorting +// @Tags question-sets +// @Accept json +// @Produce json +// @Param body body reorderReq true "Reorder payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/practices/reorder [put] +func (h *Handler) ReorderPractices(c *fiber.Ctx) error { + var req reorderReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + if len(req.Items) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Items array is required", + Error: "items must not be empty", + }) + } + + ids, positions := parseReorderItems(req.Items) + if err := h.courseMgmtSvc.ReorderQuestionSets(c.Context(), ids, positions); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to reorder practices", + Error: err.Error(), + }) + } + + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions}) + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, nil, "Reordered practices", meta, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Practices reordered successfully", + }) +} + // UploadSubCourseVideo godoc // @Summary Upload a video file and create sub-course video // @Description Accepts a video file upload, uploads it to Vimeo via TUS, and creates a sub-course video record diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 3c47a60..0ebed0d 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -71,6 +71,15 @@ func (a *App) initAppRoutes() { groupV1.Get("/assessment/questions", h.ListAssessmentQuestions) groupV1.Get("/assessment/questions/:id", h.GetAssessmentQuestionByID) + // Reorder (drag-and-drop support) + // Keep static reorder routes before dynamic `/:id` routes to avoid route collisions + // (e.g., `/courses/reorder` being parsed as `/courses/:id`). + groupV1.Put("/course-management/categories/reorder", a.authMiddleware, a.RequirePermission("course_categories.reorder"), h.ReorderCourseCategories) + groupV1.Put("/course-management/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCourses) + groupV1.Put("/course-management/sub-courses/reorder", a.authMiddleware, a.RequirePermission("subcourses.reorder"), h.ReorderSubCourses) + groupV1.Put("/course-management/videos/reorder", a.authMiddleware, a.RequirePermission("videos.reorder"), h.ReorderSubCourseVideos) + groupV1.Put("/course-management/practices/reorder", a.authMiddleware, a.RequirePermission("practices.reorder"), h.ReorderPractices) + // Course Categories groupV1.Post("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseCategory) groupV1.Get("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.list"), h.GetAllCourseCategories)