learning flow fixes

This commit is contained in:
Yared Yemane 2026-03-07 08:18:13 -08:00
parent f9da45da62
commit 3500db6435
27 changed files with 544 additions and 61 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,6 +30,7 @@ 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 {
@ -37,6 +38,7 @@ type CourseCategory struct {
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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