learning flow fixes
This commit is contained in:
parent
f9da45da62
commit
3500db6435
66
README.md
66
README.md
|
|
@ -90,17 +90,17 @@ created_at – Audit timestamp
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
|
|
||||||
One Course Category → Many Courses
|
One Course Category → Many Course Sub-categories
|
||||||
|
|
||||||
Course Category
|
Course Category
|
||||||
└── Courses[]
|
└── Course Sub-categories[]
|
||||||
|
|
||||||
2. Course
|
2. Course Sub-category
|
||||||
|
|
||||||
Table: courses
|
Table: course_sub_categories
|
||||||
|
|
||||||
Purpose:
|
Purpose:
|
||||||
Represents a full course offering under a category.
|
A grouping within a category (e.g., Speaking, Listening under Learning English).
|
||||||
|
|
||||||
Key Fields:
|
Key Fields:
|
||||||
|
|
||||||
|
|
@ -114,23 +114,23 @@ Relationships:
|
||||||
|
|
||||||
Belongs to one Course Category
|
Belongs to one Course Category
|
||||||
|
|
||||||
Has many Sub-courses
|
Has many Courses
|
||||||
|
|
||||||
Course Category
|
Course Category
|
||||||
└── Course
|
└── Course Sub-category
|
||||||
└── Sub-courses[]
|
└── Courses[]
|
||||||
|
|
||||||
3. Sub-course
|
3. Course
|
||||||
|
|
||||||
Table: sub_courses
|
Table: courses
|
||||||
|
|
||||||
Purpose:
|
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).
|
(e.g., Beginner, Intermediate, Advanced).
|
||||||
|
|
||||||
Key Fields:
|
Key Fields:
|
||||||
|
|
||||||
course_id – FK → courses.id
|
sub_category_id – FK → course_sub_categories.id
|
||||||
|
|
||||||
title, description
|
title, description
|
||||||
|
|
||||||
|
|
@ -144,27 +144,27 @@ is_active
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
|
|
||||||
Belongs to one Course
|
Belongs to one Course Sub-category
|
||||||
|
|
||||||
Has many Sub-course Videos
|
Has many Course Videos
|
||||||
|
|
||||||
Has many Practices
|
Has many Practices
|
||||||
|
|
||||||
Course
|
Course Sub-category
|
||||||
└── Sub-course
|
└── Course
|
||||||
├── Sub-course Videos[]
|
├── Course Videos[]
|
||||||
└── Practices[]
|
└── Practices[]
|
||||||
|
|
||||||
4. Sub-course Video
|
4. Course Video
|
||||||
|
|
||||||
Table: sub_course_videos
|
Table: course_videos
|
||||||
|
|
||||||
Purpose:
|
Purpose:
|
||||||
Video learning content attached to a sub-course.
|
Video learning content attached to a course.
|
||||||
|
|
||||||
Key Fields:
|
Key Fields:
|
||||||
|
|
||||||
sub_course_id – FK → sub_courses.id
|
course_id – FK → courses.id
|
||||||
|
|
||||||
title, description
|
title, description
|
||||||
|
|
||||||
|
|
@ -190,21 +190,21 @@ is_active
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
|
|
||||||
Belongs to one Sub-course
|
Belongs to one Course
|
||||||
|
|
||||||
Sub-course
|
Course
|
||||||
└── Sub-course Video
|
└── Course Video
|
||||||
|
|
||||||
5. Practice
|
5. Practice
|
||||||
|
|
||||||
Table: practices
|
Table: practices
|
||||||
|
|
||||||
Purpose:
|
Purpose:
|
||||||
Exercises or assessments that belong to a sub-course.
|
Exercises or assessments that belong to a course.
|
||||||
|
|
||||||
Key Fields:
|
Key Fields:
|
||||||
|
|
||||||
sub_course_id – FK → sub_courses.id
|
course_id – FK → courses.id
|
||||||
|
|
||||||
title, description
|
title, description
|
||||||
|
|
||||||
|
|
@ -216,11 +216,11 @@ is_active
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
|
|
||||||
Belongs to one Sub-course
|
Belongs to one Course
|
||||||
|
|
||||||
One Practice → Many Practice Questions
|
One Practice → Many Practice Questions
|
||||||
|
|
||||||
Sub-course
|
Course
|
||||||
└── Practice
|
└── Practice
|
||||||
└── Practice Questions[]
|
└── Practice Questions[]
|
||||||
|
|
||||||
|
|
@ -258,17 +258,17 @@ Practice
|
||||||
|
|
||||||
Complete Hierarchical Flow (Compact View)
|
Complete Hierarchical Flow (Compact View)
|
||||||
Course Category
|
Course Category
|
||||||
└── Course
|
└── Course Sub-category
|
||||||
└── Sub-course (with levels: BEGINNER, INTERMEDIATE, ADVANCED)
|
└── Course (with levels: BEGINNER, INTERMEDIATE, ADVANCED)
|
||||||
├── Sub-course Video
|
├── Course Video
|
||||||
└── Practice
|
└── Practice
|
||||||
└── Practice Question
|
└── Practice Question
|
||||||
|
|
||||||
Architectural Observations
|
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
|
Cascade deletes ensure referential integrity
|
||||||
|
|
||||||
|
|
|
||||||
3
db/migrations/000023_reorder_support.down.sql
Normal file
3
db/migrations/000023_reorder_support.down.sql
Normal 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;
|
||||||
3
db/migrations/000023_reorder_support.up.sql
Normal file
3
db/migrations/000023_reorder_support.up.sql
Normal 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;
|
||||||
|
|
@ -21,7 +21,7 @@ SELECT
|
||||||
is_active,
|
is_active,
|
||||||
created_at
|
created_at
|
||||||
FROM course_categories
|
FROM course_categories
|
||||||
ORDER BY created_at DESC
|
ORDER BY display_order ASC, created_at DESC
|
||||||
LIMIT sqlc.narg('limit')::INT
|
LIMIT sqlc.narg('limit')::INT
|
||||||
OFFSET sqlc.narg('offset')::INT;
|
OFFSET sqlc.narg('offset')::INT;
|
||||||
|
|
||||||
|
|
@ -37,3 +37,11 @@ WHERE id = $3;
|
||||||
-- name: DeleteCourseCategory :exec
|
-- name: DeleteCourseCategory :exec
|
||||||
DELETE FROM course_categories
|
DELETE FROM course_categories
|
||||||
WHERE id = $1;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ SELECT
|
||||||
is_active
|
is_active
|
||||||
FROM courses
|
FROM courses
|
||||||
WHERE category_id = $1
|
WHERE category_id = $1
|
||||||
ORDER BY id DESC
|
ORDER BY display_order ASC, id ASC
|
||||||
LIMIT sqlc.narg('limit')::INT
|
LIMIT sqlc.narg('limit')::INT
|
||||||
OFFSET sqlc.narg('offset')::INT;
|
OFFSET sqlc.narg('offset')::INT;
|
||||||
|
|
||||||
|
|
@ -48,3 +48,11 @@ WHERE id = $6;
|
||||||
-- name: DeleteCourse :exec
|
-- name: DeleteCourse :exec
|
||||||
DELETE FROM courses
|
DELETE FROM courses
|
||||||
WHERE id = $1;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ SELECT id, title, description, persona, status,
|
||||||
FROM question_sets qs
|
FROM question_sets qs
|
||||||
WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1
|
WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1
|
||||||
AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED'
|
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
|
-- name: GetSubCoursePrerequisitesForLearningPath :many
|
||||||
SELECT p.prerequisite_sub_course_id, sc.title, sc.level
|
SELECT p.prerequisite_sub_course_id, sc.title, sc.level
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ FROM question_sets
|
||||||
WHERE owner_type = $1
|
WHERE owner_type = $1
|
||||||
AND owner_id = $2
|
AND owner_id = $2
|
||||||
AND status != 'ARCHIVED'
|
AND status != 'ARCHIVED'
|
||||||
ORDER BY created_at DESC;
|
ORDER BY display_order ASC, created_at DESC;
|
||||||
|
|
||||||
-- name: GetQuestionSetsByType :many
|
-- name: GetQuestionSetsByType :many
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -114,3 +114,11 @@ SET
|
||||||
sub_course_video_id = $1,
|
sub_course_video_id = $1,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $2;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -112,3 +112,11 @@ WHERE id = $1;
|
||||||
-- name: DeleteSubCourseVideo :exec
|
-- name: DeleteSubCourseVideo :exec
|
||||||
DELETE FROM sub_course_videos
|
DELETE FROM sub_course_videos
|
||||||
WHERE id = $1;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -80,3 +80,11 @@ RETURNING *;
|
||||||
UPDATE sub_courses
|
UPDATE sub_courses
|
||||||
SET is_active = FALSE
|
SET is_active = FALSE
|
||||||
WHERE id = $1;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ INSERT INTO course_categories (
|
||||||
is_active
|
is_active
|
||||||
)
|
)
|
||||||
VALUES ($1, COALESCE($2, true))
|
VALUES ($1, COALESCE($2, true))
|
||||||
RETURNING id, name, is_active, created_at
|
RETURNING id, name, is_active, created_at, display_order
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateCourseCategoryParams struct {
|
type CreateCourseCategoryParams struct {
|
||||||
|
|
@ -33,6 +33,7 @@ func (q *Queries) CreateCourseCategory(ctx context.Context, arg CreateCourseCate
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.DisplayOrder,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +56,7 @@ SELECT
|
||||||
is_active,
|
is_active,
|
||||||
created_at
|
created_at
|
||||||
FROM course_categories
|
FROM course_categories
|
||||||
ORDER BY created_at DESC
|
ORDER BY display_order ASC, created_at DESC
|
||||||
LIMIT $2::INT
|
LIMIT $2::INT
|
||||||
OFFSET $1::INT
|
OFFSET $1::INT
|
||||||
`
|
`
|
||||||
|
|
@ -100,7 +101,7 @@ func (q *Queries) GetAllCourseCategories(ctx context.Context, arg GetAllCourseCa
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetCourseCategoryByID = `-- name: GetCourseCategoryByID :one
|
const GetCourseCategoryByID = `-- name: GetCourseCategoryByID :one
|
||||||
SELECT id, name, is_active, created_at
|
SELECT id, name, is_active, created_at, display_order
|
||||||
FROM course_categories
|
FROM course_categories
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
@ -113,10 +114,30 @@ func (q *Queries) GetCourseCategoryByID(ctx context.Context, id int64) (CourseCa
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.DisplayOrder,
|
||||||
)
|
)
|
||||||
return i, err
|
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
|
const UpdateCourseCategory = `-- name: UpdateCourseCategory :exec
|
||||||
UPDATE course_categories
|
UPDATE course_categories
|
||||||
SET
|
SET
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ INSERT INTO courses (
|
||||||
is_active
|
is_active
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true))
|
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 {
|
type CreateCourseParams struct {
|
||||||
|
|
@ -51,6 +51,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.Thumbnail,
|
&i.Thumbnail,
|
||||||
&i.IntroVideoUrl,
|
&i.IntroVideoUrl,
|
||||||
|
&i.DisplayOrder,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +67,7 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetCourseByID = `-- name: GetCourseByID :one
|
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
|
FROM courses
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
@ -82,6 +83,7 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.Thumbnail,
|
&i.Thumbnail,
|
||||||
&i.IntroVideoUrl,
|
&i.IntroVideoUrl,
|
||||||
|
&i.DisplayOrder,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +100,7 @@ SELECT
|
||||||
is_active
|
is_active
|
||||||
FROM courses
|
FROM courses
|
||||||
WHERE category_id = $1
|
WHERE category_id = $1
|
||||||
ORDER BY id DESC
|
ORDER BY display_order ASC, id ASC
|
||||||
LIMIT $3::INT
|
LIMIT $3::INT
|
||||||
OFFSET $2::INT
|
OFFSET $2::INT
|
||||||
`
|
`
|
||||||
|
|
@ -149,6 +151,25 @@ func (q *Queries) GetCoursesByCategory(ctx context.Context, arg GetCoursesByCate
|
||||||
return items, nil
|
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
|
const UpdateCourse = `-- name: UpdateCourse :exec
|
||||||
UPDATE courses
|
UPDATE courses
|
||||||
SET
|
SET
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ SELECT id, title, description, persona, status,
|
||||||
FROM question_sets qs
|
FROM question_sets qs
|
||||||
WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1
|
WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1
|
||||||
AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED'
|
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 {
|
type GetSubCoursePracticesForLearningPathRow struct {
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,15 @@ type Course struct {
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
|
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
|
||||||
|
DisplayOrder int32 `json:"display_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CourseCategory struct {
|
type CourseCategory struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
DisplayOrder int32 `json:"display_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Device struct {
|
type Device struct {
|
||||||
|
|
@ -170,6 +172,7 @@ type QuestionSet struct {
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
|
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
|
||||||
|
DisplayOrder int32 `json:"display_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type QuestionSetItem struct {
|
type QuestionSetItem struct {
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQu
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetQuestionSetsContainingQuestion = `-- name: GetQuestionSetsContainingQuestion :many
|
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
|
FROM question_sets qs
|
||||||
JOIN question_set_items qsi ON qsi.set_id = qs.id
|
JOIN question_set_items qsi ON qsi.set_id = qs.id
|
||||||
WHERE qsi.question_id = $1
|
WHERE qsi.question_id = $1
|
||||||
|
|
@ -230,6 +230,7 @@ func (q *Queries) GetQuestionSetsContainingQuestion(ctx context.Context, questio
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SubCourseVideoID,
|
&i.SubCourseVideoID,
|
||||||
|
&i.DisplayOrder,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ INSERT INTO question_sets (
|
||||||
sub_course_video_id
|
sub_course_video_id
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12)
|
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 {
|
type CreateQuestionSetParams struct {
|
||||||
|
|
@ -117,6 +117,7 @@ func (q *Queries) CreateQuestionSet(ctx context.Context, arg CreateQuestionSetPa
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SubCourseVideoID,
|
&i.SubCourseVideoID,
|
||||||
|
&i.DisplayOrder,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +133,7 @@ func (q *Queries) DeleteQuestionSet(ctx context.Context, id int64) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetInitialAssessmentSet = `-- name: GetInitialAssessmentSet :one
|
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
|
FROM question_sets
|
||||||
WHERE set_type = 'INITIAL_ASSESSMENT'
|
WHERE set_type = 'INITIAL_ASSESSMENT'
|
||||||
AND status = 'PUBLISHED'
|
AND status = 'PUBLISHED'
|
||||||
|
|
@ -159,12 +160,13 @@ func (q *Queries) GetInitialAssessmentSet(ctx context.Context) (QuestionSet, err
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SubCourseVideoID,
|
&i.SubCourseVideoID,
|
||||||
|
&i.DisplayOrder,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetPublishedQuestionSetsByOwner = `-- name: GetPublishedQuestionSetsByOwner :many
|
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
|
FROM question_sets
|
||||||
WHERE owner_type = $1
|
WHERE owner_type = $1
|
||||||
AND owner_id = $2
|
AND owner_id = $2
|
||||||
|
|
@ -202,6 +204,7 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SubCourseVideoID,
|
&i.SubCourseVideoID,
|
||||||
|
&i.DisplayOrder,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +217,7 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetQuestionSetByID = `-- name: GetQuestionSetByID :one
|
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
|
FROM question_sets
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
@ -238,17 +241,18 @@ func (q *Queries) GetQuestionSetByID(ctx context.Context, id int64) (QuestionSet
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SubCourseVideoID,
|
&i.SubCourseVideoID,
|
||||||
|
&i.DisplayOrder,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetQuestionSetsByOwner = `-- name: GetQuestionSetsByOwner :many
|
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
|
FROM question_sets
|
||||||
WHERE owner_type = $1
|
WHERE owner_type = $1
|
||||||
AND owner_id = $2
|
AND owner_id = $2
|
||||||
AND status != 'ARCHIVED'
|
AND status != 'ARCHIVED'
|
||||||
ORDER BY created_at DESC
|
ORDER BY display_order ASC, created_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetQuestionSetsByOwnerParams struct {
|
type GetQuestionSetsByOwnerParams struct {
|
||||||
|
|
@ -281,6 +285,7 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SubCourseVideoID,
|
&i.SubCourseVideoID,
|
||||||
|
&i.DisplayOrder,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -295,7 +300,7 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet
|
||||||
const GetQuestionSetsByType = `-- name: GetQuestionSetsByType :many
|
const GetQuestionSetsByType = `-- name: GetQuestionSetsByType :many
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) OVER () AS total_count,
|
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
|
FROM question_sets qs
|
||||||
WHERE set_type = $1
|
WHERE set_type = $1
|
||||||
AND status != 'ARCHIVED'
|
AND status != 'ARCHIVED'
|
||||||
|
|
@ -327,6 +332,7 @@ type GetQuestionSetsByTypeRow struct {
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
|
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
|
||||||
|
DisplayOrder int32 `json:"display_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSetsByTypeParams) ([]GetQuestionSetsByTypeRow, error) {
|
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.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SubCourseVideoID,
|
&i.SubCourseVideoID,
|
||||||
|
&i.DisplayOrder,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -435,6 +442,25 @@ func (q *Queries) RemoveUserPersonaFromQuestionSet(ctx context.Context, arg Remo
|
||||||
return err
|
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
|
const UpdateQuestionSet = `-- name: UpdateQuestionSet :exec
|
||||||
UPDATE question_sets
|
UPDATE question_sets
|
||||||
SET
|
SET
|
||||||
|
|
|
||||||
|
|
@ -345,6 +345,25 @@ func (q *Queries) PublishSubCourseVideo(ctx context.Context, id int64) error {
|
||||||
return err
|
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
|
const UpdateSubCourseVideo = `-- name: UpdateSubCourseVideo :exec
|
||||||
UPDATE sub_course_videos
|
UPDATE sub_course_videos
|
||||||
SET
|
SET
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,25 @@ func (q *Queries) ListSubCoursesByCourse(ctx context.Context, courseID int64) ([
|
||||||
return items, nil
|
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
|
const UpdateSubCourse = `-- name: UpdateSubCourse :exec
|
||||||
UPDATE sub_courses
|
UPDATE sub_courses
|
||||||
SET
|
SET
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,13 @@ type CourseStore interface {
|
||||||
|
|
||||||
// Learning Path (full nested structure for a course)
|
// Learning Path (full nested structure for a course)
|
||||||
GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error)
|
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 {
|
type ProgressionStore interface {
|
||||||
|
|
|
||||||
|
|
@ -119,3 +119,10 @@ func (s *Store) DeleteCourseCategory(
|
||||||
|
|
||||||
return s.queries.DeleteCourseCategory(ctx, id)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
func ptrText(t pgtype.Text) *string {
|
||||||
if t.Valid {
|
if t.Valid {
|
||||||
return &t.String
|
return &t.String
|
||||||
|
|
|
||||||
|
|
@ -809,3 +809,10 @@ func (s *Store) GetUserPersonasByQuestionSetID(ctx context.Context, questionSetI
|
||||||
}
|
}
|
||||||
return result, nil
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -283,3 +283,10 @@ func (s *Store) GetVideoByVimeoID(ctx context.Context, vimeoID string) (domain.S
|
||||||
}
|
}
|
||||||
return mapSubCourseVideoRow(row), nil
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -233,4 +233,11 @@ func (s *Store) DeleteSubCourse(
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error {
|
||||||
|
return s.queries.ReorderSubCourses(ctx, dbgen.ReorderSubCoursesParams{
|
||||||
|
Ids: ids,
|
||||||
|
Positions: positions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func (s *Service) GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error) {
|
||||||
return s.courseStore.GetCourseLearningPath(ctx, courseID)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.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
|
// Course Management - Courses
|
||||||
{Key: "courses.create", Name: "Create Course", Description: "Create a new course", GroupName: "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.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.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.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
|
// Course Management - Sub-courses
|
||||||
{Key: "subcourses.create", Name: "Create Sub-course", Description: "Create a new sub-course", GroupName: "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.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.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.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
|
// Course Management - Videos
|
||||||
{Key: "videos.create", Name: "Create Video", Description: "Create a sub-course video", GroupName: "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.publish", Name: "Publish Video", Description: "Publish a video", GroupName: "Videos"},
|
||||||
{Key: "videos.update", Name: "Update Video", Description: "Update 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.delete", Name: "Delete Video", Description: "Delete a video", GroupName: "Videos"},
|
||||||
|
{Key: "videos.reorder", Name: "Reorder Videos", Description: "Reorder videos", GroupName: "Videos"},
|
||||||
|
|
||||||
// Learning Tree
|
// Learning Tree
|
||||||
{Key: "learning_tree.get", Name: "Get Learning Tree", Description: "Get full learning tree", GroupName: "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
|
// Questions
|
||||||
{Key: "questions.create", Name: "Create Question", Description: "Create a new question", GroupName: "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{
|
var DefaultRolePermissions = map[string][]string{
|
||||||
"ADMIN": {
|
"ADMIN": {
|
||||||
// Course Management (full access)
|
// Course Management (full access)
|
||||||
"course_categories.create", "course_categories.list", "course_categories.get", "course_categories.update", "course_categories.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.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.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.create", "videos.create_vimeo", "videos.upload", "videos.import_vimeo", "videos.get",
|
||||||
"videos.list_by_subcourse", "videos.list_published", "videos.publish", "videos.update", "videos.delete",
|
"videos.list_by_subcourse", "videos.list_published", "videos.publish", "videos.update", "videos.delete", "videos.reorder",
|
||||||
"learning_tree.get",
|
"learning_tree.get", "practices.reorder",
|
||||||
|
|
||||||
// Questions (full access)
|
// Questions (full access)
|
||||||
"questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete",
|
"questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete",
|
||||||
|
|
|
||||||
|
|
@ -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
|
// UploadSubCourseVideo godoc
|
||||||
// @Summary Upload a video file and create sub-course video
|
// @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
|
// @Description Accepts a video file upload, uploads it to Vimeo via TUS, and creates a sub-course video record
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,15 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Get("/assessment/questions", h.ListAssessmentQuestions)
|
groupV1.Get("/assessment/questions", h.ListAssessmentQuestions)
|
||||||
groupV1.Get("/assessment/questions/:id", h.GetAssessmentQuestionByID)
|
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
|
// Course Categories
|
||||||
groupV1.Post("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseCategory)
|
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)
|
groupV1.Get("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.list"), h.GetAllCourseCategories)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user