added more structure to lessons

This commit is contained in:
Yared Yemane 2026-04-17 08:27:40 -07:00
parent 1026354c24
commit 518c3ee751
10 changed files with 2854 additions and 445 deletions

View File

@ -0,0 +1,18 @@
-- Restores legacy lesson columns. Rows will have NULL question_set_id until repopulated.
ALTER TABLE sub_module_lessons
ADD COLUMN IF NOT EXISTS question_set_id BIGINT REFERENCES question_sets(id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS intro_video_url TEXT;
UPDATE sub_module_lessons
SET intro_video_url = teaching_video_url
WHERE teaching_video_url IS NOT NULL;
ALTER TABLE sub_module_lessons
DROP COLUMN IF EXISTS title,
DROP COLUMN IF EXISTS description,
DROP COLUMN IF EXISTS thumbnail,
DROP COLUMN IF EXISTS teaching_text,
DROP COLUMN IF EXISTS teaching_image_url,
DROP COLUMN IF EXISTS teaching_audio_url,
DROP COLUMN IF EXISTS teaching_video_url;

View File

@ -0,0 +1,37 @@
-- Lessons are teaching content only (text, images, audio, video, thumbnail).
-- Question sets remain linked to practices, not lessons.
ALTER TABLE sub_module_lessons
ADD COLUMN IF NOT EXISTS title VARCHAR(255),
ADD COLUMN IF NOT EXISTS description TEXT,
ADD COLUMN IF NOT EXISTS thumbnail TEXT,
ADD COLUMN IF NOT EXISTS teaching_text TEXT,
ADD COLUMN IF NOT EXISTS teaching_image_url TEXT,
ADD COLUMN IF NOT EXISTS teaching_audio_url TEXT,
ADD COLUMN IF NOT EXISTS teaching_video_url TEXT;
UPDATE sub_module_lessons sml
SET
title = qs.title,
description = qs.description
FROM question_sets qs
WHERE sml.question_set_id IS NOT NULL
AND qs.id = sml.question_set_id;
UPDATE sub_module_lessons
SET title = 'Lesson'
WHERE title IS NULL OR trim(title) = '';
UPDATE sub_module_lessons
SET teaching_video_url = intro_video_url
WHERE intro_video_url IS NOT NULL;
ALTER TABLE sub_module_lessons DROP CONSTRAINT IF EXISTS sub_module_lessons_question_set_id_fkey;
ALTER TABLE sub_module_lessons DROP CONSTRAINT IF EXISTS sub_module_lessons_question_set_id_key;
ALTER TABLE sub_module_lessons DROP COLUMN IF EXISTS question_set_id;
ALTER TABLE sub_module_lessons DROP COLUMN IF EXISTS intro_video_url;
ALTER TABLE sub_module_lessons
ALTER COLUMN title SET NOT NULL,
ALTER COLUMN title SET DEFAULT 'Lesson';

View File

@ -83,43 +83,17 @@ WHERE sub_module_id = $1
ORDER BY display_order ASC, id ASC;
-- name: GetSubModuleLessons :many
SELECT
smp.id,
smp.sub_module_id,
smp.question_set_id,
smp.intro_video_url,
smp.display_order,
smp.is_active,
qs.title,
qs.description,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_lessons smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.sub_module_id = $1
AND smp.is_active = TRUE
AND qs.set_type = 'QUIZ'
ORDER BY smp.display_order ASC, smp.id ASC;
SELECT *
FROM sub_module_lessons
WHERE sub_module_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetSubModuleLessonByID :one
SELECT
smp.id,
smp.sub_module_id,
smp.question_set_id,
smp.intro_video_url,
smp.display_order,
smp.is_active,
qs.title,
qs.description,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_lessons smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.id = $1
AND smp.is_active = TRUE
AND qs.set_type = 'QUIZ';
SELECT *
FROM sub_module_lessons
WHERE id = $1
AND is_active = TRUE;
-- name: GetSubModulePractices :many
SELECT
@ -289,26 +263,47 @@ VALUES (
)
RETURNING *;
-- name: AttachQuestionSetLessonToSubModule :one
-- name: CreateSubModuleLesson :one
INSERT INTO sub_module_lessons (
sub_module_id,
question_set_id,
intro_video_url,
title,
description,
thumbnail,
teaching_text,
teaching_image_url,
teaching_audio_url,
teaching_video_url,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
COALESCE($9, 0),
COALESCE($10, TRUE)
)
RETURNING *;
-- name: UpdateSubModuleLesson :one
UPDATE sub_module_lessons
SET
sub_module_id = $1,
question_set_id = $2,
intro_video_url = $3,
display_order = $4,
is_active = $5
WHERE id = $6
title = $2,
description = $3,
thumbnail = $4,
teaching_text = $5,
teaching_image_url = $6,
teaching_audio_url = $7,
teaching_video_url = $8,
display_order = $9,
is_active = $10
WHERE id = $11
RETURNING *;
-- name: CreateSubModulePractice :one

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -911,19 +911,6 @@ definitions:
required:
- user_id
type: object
handlers.attachSubModuleLessonReq:
properties:
display_order:
type: integer
intro_video_url:
type: string
is_active:
type: boolean
question_set_id:
type: integer
sub_module_id:
type: integer
type: object
handlers.autoRenewReq:
properties:
auto_renew:
@ -1133,6 +1120,29 @@ definitions:
- set_type
- title
type: object
handlers.createSubModuleLessonReq:
properties:
description:
type: string
display_order:
type: integer
is_active:
type: boolean
sub_module_id:
type: integer
teaching_audio_url:
type: string
teaching_image_url:
type: string
teaching_text:
type: string
teaching_video_url:
type: string
thumbnail:
type: string
title:
type: string
type: object
handlers.createSubModulePracticeReq:
properties:
description:
@ -1488,6 +1498,29 @@ definitions:
title:
type: string
type: object
handlers.updateSubModuleLessonReq:
properties:
description:
type: string
display_order:
type: integer
is_active:
type: boolean
sub_module_id:
type: integer
teaching_audio_url:
type: string
teaching_image_url:
type: string
teaching_text:
type: string
teaching_video_url:
type: string
thumbnail:
type: string
title:
type: string
type: object
handlers.verifyOTPReq:
properties:
otp:
@ -2447,6 +2480,31 @@ paths:
tags:
- course-management
/api/v1/course-management/courses:
get:
description: Returns all courses with pagination
parameters:
- description: Offset
in: query
name: offset
type: integer
- description: Limit
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List all courses
tags:
- course-management
post:
consumes:
- application/json
@ -2503,6 +2561,36 @@ paths:
summary: Delete course
tags:
- course-management
get:
description: Returns one course by ID
parameters:
- description: Course ID
in: path
name: courseId
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Get course detail
tags:
- course-management
put:
consumes:
- application/json
@ -2591,6 +2679,33 @@ paths:
summary: Get course learning path
tags:
- course-management
/api/v1/course-management/courses/{courseId}/levels:
get:
description: Returns all active levels for one course
parameters:
- description: Course ID
in: path
name: courseId
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List levels by course
tags:
- course-management
/api/v1/course-management/courses/{courseId}/thumbnail:
post:
consumes:
@ -2643,7 +2758,84 @@ paths:
summary: Get unified course hierarchy
tags:
- course-management
/api/v1/course-management/human-language/courses:
get:
description: Returns all courses under Human Language category
parameters:
- description: Offset
in: query
name: offset
type: integer
- description: Limit
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List Human Language courses
tags:
- course-management
/api/v1/course-management/human-language/sub-categories:
get:
description: Returns active sub-categories under Human Language category
parameters:
- description: Offset
in: query
name: offset
type: integer
- description: Limit
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List Human Language sub-categories
tags:
- course-management
/api/v1/course-management/levels:
get:
description: Returns all levels with pagination
parameters:
- description: Offset
in: query
name: offset
type: integer
- description: Limit
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List all levels
tags:
- course-management
post:
consumes:
- application/json
@ -2673,7 +2865,90 @@ paths:
summary: Create level
tags:
- course-management
/api/v1/course-management/levels/{levelId}:
get:
description: Returns one level by ID
parameters:
- description: Level ID
in: path
name: levelId
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Get level detail
tags:
- course-management
/api/v1/course-management/levels/{levelId}/modules:
get:
description: Returns all active modules for one level
parameters:
- description: Level ID
in: path
name: levelId
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List modules by level
tags:
- course-management
/api/v1/course-management/modules:
get:
description: Returns all modules with pagination
parameters:
- description: Offset
in: query
name: offset
type: integer
- description: Limit
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List all modules
tags:
- course-management
post:
consumes:
- application/json
@ -2703,7 +2978,123 @@ paths:
summary: Create module
tags:
- course-management
/api/v1/course-management/modules/{moduleId}:
get:
description: Returns one module by ID
parameters:
- description: Module ID
in: path
name: moduleId
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Get module detail
tags:
- course-management
/api/v1/course-management/modules/{moduleId}/sub-modules:
get:
description: Returns all active sub-modules for one module
parameters:
- description: Module ID
in: path
name: moduleId
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List sub-modules by module
tags:
- course-management
/api/v1/course-management/practices/{practiceId}:
get:
consumes:
- application/json
description: Returns one active practice by practice ID
parameters:
- description: Practice ID
in: path
name: practiceId
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Get practice detail
tags:
- course-management
/api/v1/course-management/sub-categories:
get:
description: Returns all active course sub-categories
parameters:
- description: Offset
in: query
name: offset
type: integer
- description: Limit
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List course sub-categories
tags:
- course-management
post:
consumes:
- application/json
@ -2733,18 +3124,54 @@ paths:
summary: Create course sub-category
tags:
- course-management
/api/v1/course-management/sub-categories/{subCategoryId}/courses:
get:
description: Returns courses for one sub-category
parameters:
- description: Sub-category ID
in: path
name: subCategoryId
required: true
type: integer
- description: Offset
in: query
name: offset
type: integer
- description: Limit
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List courses by sub-category
tags:
- course-management
/api/v1/course-management/sub-module-lessons:
post:
consumes:
- application/json
description: Links a question set lesson to a sub-module
description: Creates a sub-module lesson with teaching content (text, image,
audio, video URLs) and optional thumbnail
parameters:
- description: Attach lesson payload
- description: Create lesson payload
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.attachSubModuleLessonReq'
$ref: '#/definitions/handlers.createSubModuleLessonReq'
produces:
- application/json
responses:
@ -2760,7 +3187,79 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Attach lesson to sub-module
summary: Create lesson under sub-module
tags:
- course-management
/api/v1/course-management/sub-module-lessons/{lessonId}:
get:
consumes:
- application/json
description: Returns one active lesson detail by lesson ID
parameters:
- description: Lesson ID
in: path
name: lessonId
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Get lesson detail
tags:
- course-management
put:
consumes:
- application/json
description: Updates lesson teaching content, thumbnail, ordering, and active
flag
parameters:
- description: Lesson ID
in: path
name: lessonId
required: true
type: integer
- description: Update lesson payload
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.updateSubModuleLessonReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Update lesson detail
tags:
- course-management
/api/v1/course-management/sub-module-practices:
@ -2825,6 +3324,31 @@ paths:
tags:
- course-management
/api/v1/course-management/sub-modules:
get:
description: Returns all sub-modules with pagination
parameters:
- description: Offset
in: query
name: offset
type: integer
- description: Limit
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List all sub-modules
tags:
- course-management
post:
consumes:
- application/json
@ -2854,6 +3378,95 @@ paths:
summary: Create sub-module
tags:
- course-management
/api/v1/course-management/sub-modules/{subModuleId}:
get:
description: Returns one sub-module by ID
parameters:
- description: Sub-module ID
in: path
name: subModuleId
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Get sub-module detail
tags:
- course-management
/api/v1/course-management/sub-modules/{subModuleId}/lessons:
get:
consumes:
- application/json
description: Returns all active lessons for a sub-module (teaching content metadata)
parameters:
- description: Sub-module ID
in: path
name: subModuleId
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Get lessons under sub-module
tags:
- course-management
/api/v1/course-management/sub-modules/{subModuleId}/practices:
get:
consumes:
- application/json
description: Returns all active practices attached to a sub-module
parameters:
- description: Sub-module ID
in: path
name: subModuleId
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Get practices under sub-module
tags:
- course-management
/api/v1/files/audio:
post:
consumes:

View File

@ -11,47 +11,6 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const AttachQuestionSetLessonToSubModule = `-- name: AttachQuestionSetLessonToSubModule :one
INSERT INTO sub_module_lessons (
sub_module_id,
question_set_id,
intro_video_url,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING id, sub_module_id, question_set_id, intro_video_url, display_order, is_active, created_at
`
type AttachQuestionSetLessonToSubModuleParams struct {
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
Column4 interface{} `json:"column_4"`
Column5 interface{} `json:"column_5"`
}
func (q *Queries) AttachQuestionSetLessonToSubModule(ctx context.Context, arg AttachQuestionSetLessonToSubModuleParams) (SubModuleLesson, error) {
row := q.db.QueryRow(ctx, AttachQuestionSetLessonToSubModule,
arg.SubModuleID,
arg.QuestionSetID,
arg.IntroVideoUrl,
arg.Column4,
arg.Column5,
)
var i SubModuleLesson
err := row.Scan(
&i.ID,
&i.SubModuleID,
&i.QuestionSetID,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
)
return i, err
}
const CreateCourseSubCategory = `-- name: CreateCourseSubCategory :one
INSERT INTO course_sub_categories (
category_id,
@ -213,6 +172,78 @@ func (q *Queries) CreateSubModule(ctx context.Context, arg CreateSubModuleParams
return i, err
}
const CreateSubModuleLesson = `-- name: CreateSubModuleLesson :one
INSERT INTO sub_module_lessons (
sub_module_id,
title,
description,
thumbnail,
teaching_text,
teaching_image_url,
teaching_audio_url,
teaching_video_url,
display_order,
is_active
)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
COALESCE($9, 0),
COALESCE($10, TRUE)
)
RETURNING id, sub_module_id, display_order, is_active, created_at, title, description, thumbnail, teaching_text, teaching_image_url, teaching_audio_url, teaching_video_url
`
type CreateSubModuleLessonParams struct {
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
TeachingText pgtype.Text `json:"teaching_text"`
TeachingImageUrl pgtype.Text `json:"teaching_image_url"`
TeachingAudioUrl pgtype.Text `json:"teaching_audio_url"`
TeachingVideoUrl pgtype.Text `json:"teaching_video_url"`
Column9 interface{} `json:"column_9"`
Column10 interface{} `json:"column_10"`
}
func (q *Queries) CreateSubModuleLesson(ctx context.Context, arg CreateSubModuleLessonParams) (SubModuleLesson, error) {
row := q.db.QueryRow(ctx, CreateSubModuleLesson,
arg.SubModuleID,
arg.Title,
arg.Description,
arg.Thumbnail,
arg.TeachingText,
arg.TeachingImageUrl,
arg.TeachingAudioUrl,
arg.TeachingVideoUrl,
arg.Column9,
arg.Column10,
)
var i SubModuleLesson
err := row.Scan(
&i.ID,
&i.SubModuleID,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.TeachingText,
&i.TeachingImageUrl,
&i.TeachingAudioUrl,
&i.TeachingVideoUrl,
)
return i, err
}
const CreateSubModulePractice = `-- name: CreateSubModulePractice :one
INSERT INTO sub_module_practices (
sub_module_id,
@ -907,114 +938,62 @@ func (q *Queries) GetSubModuleByID(ctx context.Context, id int64) (SubModule, er
}
const GetSubModuleLessonByID = `-- name: GetSubModuleLessonByID :one
SELECT
smp.id,
smp.sub_module_id,
smp.question_set_id,
smp.intro_video_url,
smp.display_order,
smp.is_active,
qs.title,
qs.description,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_lessons smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.id = $1
AND smp.is_active = TRUE
AND qs.set_type = 'QUIZ'
SELECT id, sub_module_id, display_order, is_active, created_at, title, description, thumbnail, teaching_text, teaching_image_url, teaching_audio_url, teaching_video_url
FROM sub_module_lessons
WHERE id = $1
AND is_active = TRUE
`
type GetSubModuleLessonByIDRow struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
SetType string `json:"set_type"`
QuestionCount int64 `json:"question_count"`
}
func (q *Queries) GetSubModuleLessonByID(ctx context.Context, id int64) (GetSubModuleLessonByIDRow, error) {
func (q *Queries) GetSubModuleLessonByID(ctx context.Context, id int64) (SubModuleLesson, error) {
row := q.db.QueryRow(ctx, GetSubModuleLessonByID, id)
var i GetSubModuleLessonByIDRow
var i SubModuleLesson
err := row.Scan(
&i.ID,
&i.SubModuleID,
&i.QuestionSetID,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
&i.Title,
&i.Description,
&i.Status,
&i.SetType,
&i.QuestionCount,
&i.Thumbnail,
&i.TeachingText,
&i.TeachingImageUrl,
&i.TeachingAudioUrl,
&i.TeachingVideoUrl,
)
return i, err
}
const GetSubModuleLessons = `-- name: GetSubModuleLessons :many
SELECT
smp.id,
smp.sub_module_id,
smp.question_set_id,
smp.intro_video_url,
smp.display_order,
smp.is_active,
qs.title,
qs.description,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_lessons smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.sub_module_id = $1
AND smp.is_active = TRUE
AND qs.set_type = 'QUIZ'
ORDER BY smp.display_order ASC, smp.id ASC
SELECT id, sub_module_id, display_order, is_active, created_at, title, description, thumbnail, teaching_text, teaching_image_url, teaching_audio_url, teaching_video_url
FROM sub_module_lessons
WHERE sub_module_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC
`
type GetSubModuleLessonsRow struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
SetType string `json:"set_type"`
QuestionCount int64 `json:"question_count"`
}
func (q *Queries) GetSubModuleLessons(ctx context.Context, subModuleID int64) ([]GetSubModuleLessonsRow, error) {
func (q *Queries) GetSubModuleLessons(ctx context.Context, subModuleID int64) ([]SubModuleLesson, error) {
rows, err := q.db.Query(ctx, GetSubModuleLessons, subModuleID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubModuleLessonsRow
var items []SubModuleLesson
for rows.Next() {
var i GetSubModuleLessonsRow
var i SubModuleLesson
if err := rows.Scan(
&i.ID,
&i.SubModuleID,
&i.QuestionSetID,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
&i.Title,
&i.Description,
&i.Status,
&i.SetType,
&i.QuestionCount,
&i.Thumbnail,
&i.TeachingText,
&i.TeachingImageUrl,
&i.TeachingAudioUrl,
&i.TeachingVideoUrl,
); err != nil {
return nil, err
}
@ -1242,28 +1221,43 @@ const UpdateSubModuleLesson = `-- name: UpdateSubModuleLesson :one
UPDATE sub_module_lessons
SET
sub_module_id = $1,
question_set_id = $2,
intro_video_url = $3,
display_order = $4,
is_active = $5
WHERE id = $6
RETURNING id, sub_module_id, question_set_id, intro_video_url, display_order, is_active, created_at
title = $2,
description = $3,
thumbnail = $4,
teaching_text = $5,
teaching_image_url = $6,
teaching_audio_url = $7,
teaching_video_url = $8,
display_order = $9,
is_active = $10
WHERE id = $11
RETURNING id, sub_module_id, display_order, is_active, created_at, title, description, thumbnail, teaching_text, teaching_image_url, teaching_audio_url, teaching_video_url
`
type UpdateSubModuleLessonParams struct {
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
TeachingText pgtype.Text `json:"teaching_text"`
TeachingImageUrl pgtype.Text `json:"teaching_image_url"`
TeachingAudioUrl pgtype.Text `json:"teaching_audio_url"`
TeachingVideoUrl pgtype.Text `json:"teaching_video_url"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateSubModuleLesson(ctx context.Context, arg UpdateSubModuleLessonParams) (SubModuleLesson, error) {
row := q.db.QueryRow(ctx, UpdateSubModuleLesson,
arg.SubModuleID,
arg.QuestionSetID,
arg.IntroVideoUrl,
arg.Title,
arg.Description,
arg.Thumbnail,
arg.TeachingText,
arg.TeachingImageUrl,
arg.TeachingAudioUrl,
arg.TeachingVideoUrl,
arg.DisplayOrder,
arg.IsActive,
arg.ID,
@ -1272,11 +1266,16 @@ func (q *Queries) UpdateSubModuleLesson(ctx context.Context, arg UpdateSubModule
err := row.Scan(
&i.ID,
&i.SubModuleID,
&i.QuestionSetID,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.TeachingText,
&i.TeachingImageUrl,
&i.TeachingAudioUrl,
&i.TeachingVideoUrl,
)
return i, err
}

View File

@ -356,13 +356,18 @@ type SubModule struct {
}
type SubModuleLesson struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
TeachingText pgtype.Text `json:"teaching_text"`
TeachingImageUrl pgtype.Text `json:"teaching_image_url"`
TeachingAudioUrl pgtype.Text `json:"teaching_audio_url"`
TeachingVideoUrl pgtype.Text `json:"teaching_video_url"`
}
type SubModulePractice struct {

View File

@ -101,35 +101,30 @@ type updatePracticeReq struct {
IsActive *bool `json:"is_active"`
}
type attachSubModuleLessonReq struct {
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoURL *string `json:"intro_video_url"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type updateLessonQuestionReq struct {
QuestionID int64 `json:"question_id"`
DisplayOrder *int32 `json:"display_order"`
type createSubModuleLessonReq struct {
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description *string `json:"description"`
Thumbnail *string `json:"thumbnail"`
TeachingText *string `json:"teaching_text"`
TeachingImageURL *string `json:"teaching_image_url"`
TeachingAudioURL *string `json:"teaching_audio_url"`
TeachingVideoURL *string `json:"teaching_video_url"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type updateSubModuleLessonReq struct {
SubModuleID *int64 `json:"sub_module_id"`
QuestionSetID *int64 `json:"question_set_id"`
IntroVideoURL *string `json:"intro_video_url"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
Title *string `json:"title"`
Description *string `json:"description"`
BannerImage *string `json:"banner_image"`
Persona *string `json:"persona"`
TimeLimitMinutes *int32 `json:"time_limit_minutes"`
PassingScore *int32 `json:"passing_score"`
ShuffleQuestions *bool `json:"shuffle_questions"`
Status *string `json:"status"`
SubCourseVideoID *int64 `json:"sub_course_video_id"`
Questions []updateLessonQuestionReq `json:"questions"`
SubModuleID *int64 `json:"sub_module_id"`
Title *string `json:"title"`
Description *string `json:"description"`
Thumbnail *string `json:"thumbnail"`
TeachingText *string `json:"teaching_text"`
TeachingImageURL *string `json:"teaching_image_url"`
TeachingAudioURL *string `json:"teaching_audio_url"`
TeachingVideoURL *string `json:"teaching_video_url"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createSubModulePracticeReq struct {
@ -159,6 +154,16 @@ func toText(v *string) pgtype.Text {
return pgtype.Text{String: *v, Valid: true}
}
func mergeTextField(current pgtype.Text, req *string) pgtype.Text {
if req == nil {
return current
}
if *req == "" {
return pgtype.Text{Valid: false}
}
return pgtype.Text{String: *req, Valid: true}
}
func toInt4(v *int32) pgtype.Int4 {
if v == nil {
return pgtype.Int4{Valid: false}
@ -1515,41 +1520,46 @@ func (h *Handler) CreateSubModuleVideo(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Sub-module video created", Data: created})
}
// AttachSubModuleLesson godoc
// @Summary Attach lesson to sub-module
// @Description Links a question set lesson to a sub-module
// CreateSubModuleLesson godoc
// @Summary Create lesson under sub-module
// @Description Creates a sub-module lesson with teaching content (text, image, audio, video URLs) and optional thumbnail
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body attachSubModuleLessonReq true "Attach lesson payload"
// @Param body body createSubModuleLessonReq true "Create lesson payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-module-lessons [post]
func (h *Handler) AttachSubModuleLesson(c *fiber.Ctx) error {
var req attachSubModuleLessonReq
func (h *Handler) CreateSubModuleLesson(c *fiber.Ctx) error {
var req createSubModuleLessonReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.SubModuleID <= 0 || req.QuestionSetID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id and question_set_id are required"})
if req.SubModuleID <= 0 || strings.TrimSpace(req.Title) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id and title are required"})
}
attached, err := h.analyticsDB.AttachQuestionSetLessonToSubModule(c.Context(), dbgen.AttachQuestionSetLessonToSubModuleParams{
SubModuleID: req.SubModuleID,
QuestionSetID: req.QuestionSetID,
IntroVideoUrl: toText(req.IntroVideoURL),
Column4: intOrNil(req.DisplayOrder),
Column5: boolOrNil(req.IsActive),
created, err := h.analyticsDB.CreateSubModuleLesson(c.Context(), dbgen.CreateSubModuleLessonParams{
SubModuleID: req.SubModuleID,
Title: strings.TrimSpace(req.Title),
Description: toText(req.Description),
Thumbnail: toText(req.Thumbnail),
TeachingText: toText(req.TeachingText),
TeachingImageUrl: toText(req.TeachingImageURL),
TeachingAudioUrl: toText(req.TeachingAudioURL),
TeachingVideoUrl: toText(req.TeachingVideoURL),
Column9: intOrNil(req.DisplayOrder),
Column10: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to attach lesson", Error: err.Error()})
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create lesson", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Lesson attached to sub-module", Data: attached})
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Lesson created", Data: created})
}
// GetSubModuleLessons godoc
// @Summary Get lessons under sub-module
// @Description Returns all active lessons attached to a sub-module with question-set details
// @Description Returns all active lessons for a sub-module (teaching content metadata)
// @Tags course-management
// @Accept json
// @Produce json
@ -1618,7 +1628,7 @@ func (h *Handler) GetSubModuleLessonByID(c *fiber.Ctx) error {
// UpdateSubModuleLesson godoc
// @Summary Update lesson detail
// @Description Updates lesson metadata, linked question-set metadata, and optionally replaces lesson questions
// @Description Updates lesson teaching content, thumbnail, ordering, and active flag
// @Tags course-management
// @Accept json
// @Produce json
@ -1662,18 +1672,21 @@ func (h *Handler) UpdateSubModuleLesson(c *fiber.Ctx) error {
targetSubModuleID = *req.SubModuleID
}
targetQuestionSetID := currentLesson.QuestionSetID
if req.QuestionSetID != nil {
if *req.QuestionSetID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "question_set_id must be a positive integer"})
targetTitle := currentLesson.Title
if req.Title != nil {
t := strings.TrimSpace(*req.Title)
if t == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "title cannot be empty"})
}
targetQuestionSetID = *req.QuestionSetID
targetTitle = t
}
targetIntroVideoURL := currentLesson.IntroVideoUrl
if req.IntroVideoURL != nil {
targetIntroVideoURL = toText(req.IntroVideoURL)
}
targetDescription := mergeTextField(currentLesson.Description, req.Description)
targetThumbnail := mergeTextField(currentLesson.Thumbnail, req.Thumbnail)
targetTeachingText := mergeTextField(currentLesson.TeachingText, req.TeachingText)
targetTeachingImage := mergeTextField(currentLesson.TeachingImageUrl, req.TeachingImageURL)
targetTeachingAudio := mergeTextField(currentLesson.TeachingAudioUrl, req.TeachingAudioURL)
targetTeachingVideo := mergeTextField(currentLesson.TeachingVideoUrl, req.TeachingVideoURL)
targetDisplayOrder := currentLesson.DisplayOrder
if req.DisplayOrder != nil {
@ -1686,12 +1699,17 @@ func (h *Handler) UpdateSubModuleLesson(c *fiber.Ctx) error {
}
if _, err := h.analyticsDB.UpdateSubModuleLesson(c.Context(), dbgen.UpdateSubModuleLessonParams{
SubModuleID: targetSubModuleID,
QuestionSetID: targetQuestionSetID,
IntroVideoUrl: targetIntroVideoURL,
DisplayOrder: targetDisplayOrder,
IsActive: targetIsActive,
ID: lessonID,
SubModuleID: targetSubModuleID,
Title: targetTitle,
Description: targetDescription,
Thumbnail: targetThumbnail,
TeachingText: targetTeachingText,
TeachingImageUrl: targetTeachingImage,
TeachingAudioUrl: targetTeachingAudio,
TeachingVideoUrl: targetTeachingVideo,
DisplayOrder: targetDisplayOrder,
IsActive: targetIsActive,
ID: lessonID,
}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update lesson",
@ -1699,125 +1717,6 @@ func (h *Handler) UpdateSubModuleLesson(c *fiber.Ctx) error {
})
}
currentSet, err := h.questionsSvc.GetQuestionSetByID(c.Context(), targetQuestionSetID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load linked question set",
Error: err.Error(),
})
}
shouldUpdateSet := req.Title != nil || req.Description != nil || req.BannerImage != nil ||
req.Persona != nil || req.TimeLimitMinutes != nil || req.PassingScore != nil ||
req.ShuffleQuestions != nil || req.Status != nil || req.SubCourseVideoID != nil
if shouldUpdateSet {
title := currentSet.Title
if req.Title != nil {
title = *req.Title
}
input := domain.CreateQuestionSetInput{
Title: title,
Description: currentSet.Description,
BannerImage: currentSet.BannerImage,
Persona: currentSet.Persona,
TimeLimitMinutes: currentSet.TimeLimitMinutes,
PassingScore: currentSet.PassingScore,
SubCourseVideoID: currentSet.SubCourseVideoID,
IntroVideoURL: req.IntroVideoURL,
ShuffleQuestions: &currentSet.ShuffleQuestions,
}
currentStatus := currentSet.Status
input.Status = &currentStatus
if req.Description != nil {
input.Description = req.Description
}
if req.BannerImage != nil {
input.BannerImage = req.BannerImage
}
if req.Persona != nil {
input.Persona = req.Persona
}
if req.TimeLimitMinutes != nil {
input.TimeLimitMinutes = req.TimeLimitMinutes
}
if req.PassingScore != nil {
input.PassingScore = req.PassingScore
}
if req.ShuffleQuestions != nil {
input.ShuffleQuestions = req.ShuffleQuestions
}
if req.Status != nil {
input.Status = req.Status
}
if req.SubCourseVideoID != nil {
input.SubCourseVideoID = req.SubCourseVideoID
}
if err := h.questionsSvc.UpdateQuestionSet(c.Context(), targetQuestionSetID, input); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update linked question set",
Error: err.Error(),
})
}
}
if req.Questions != nil {
seen := make(map[int64]struct{}, len(req.Questions))
for idx, q := range req.Questions {
if q.QuestionID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Each question_id must be a positive integer"})
}
if _, exists := seen[q.QuestionID]; exists {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Duplicate question_id values are not allowed"})
}
seen[q.QuestionID] = struct{}{}
order := q.DisplayOrder
if order == nil {
defaultOrder := int32(idx)
order = &defaultOrder
}
if _, err := h.questionsSvc.GetQuestionByID(c.Context(), q.QuestionID); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_id in questions payload",
Error: err.Error(),
})
}
if _, err := h.questionsSvc.AddQuestionToSet(c.Context(), targetQuestionSetID, q.QuestionID, order); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to upsert lesson question",
Error: err.Error(),
})
}
}
existingItems, err := h.questionsSvc.GetQuestionSetItems(c.Context(), targetQuestionSetID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load existing lesson questions",
Error: err.Error(),
})
}
for _, item := range existingItems {
if _, keep := seen[item.QuestionID]; keep {
continue
}
if err := h.questionsSvc.RemoveQuestionFromSet(c.Context(), targetQuestionSetID, item.QuestionID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to remove question from lesson",
Error: err.Error(),
})
}
}
}
updatedLesson, err := h.analyticsDB.GetSubModuleLessonByID(c.Context(), lessonID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
@ -1826,20 +1725,9 @@ func (h *Handler) UpdateSubModuleLesson(c *fiber.Ctx) error {
})
}
updatedQuestions, err := h.questionsSvc.GetQuestionSetItems(c.Context(), updatedLesson.QuestionSetID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Lesson updated but failed to fetch latest questions",
Error: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Lesson updated successfully",
Data: map[string]interface{}{
"lesson": updatedLesson,
"questions": updatedQuestions,
},
Data: updatedLesson,
})
}

View File

@ -116,10 +116,10 @@ func (a *App) initAppRoutes() {
groupV1.Post("/course-management/sub-module-videos", a.authMiddleware, a.RequirePermission("videos.create"), h.CreateSubModuleVideo)
groupV1.Put("/course-management/sub-module-videos/:videoId", a.authMiddleware, a.RequirePermission("videos.update"), h.UpdateSubModuleVideo)
groupV1.Delete("/course-management/sub-module-videos/:videoId", a.authMiddleware, a.RequirePermission("videos.delete"), h.DeleteSubModuleVideo)
groupV1.Get("/course-management/sub-modules/:subModuleId/lessons", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetSubModuleLessons)
groupV1.Get("/course-management/sub-module-lessons/:lessonId", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetSubModuleLessonByID)
groupV1.Put("/course-management/sub-module-lessons/:lessonId", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdateSubModuleLesson)
groupV1.Post("/course-management/sub-module-lessons", a.authMiddleware, a.RequirePermission("question_sets.update"), h.AttachSubModuleLesson)
groupV1.Get("/course-management/sub-modules/:subModuleId/lessons", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetSubModuleLessons)
groupV1.Get("/course-management/sub-module-lessons/:lessonId", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetSubModuleLessonByID)
groupV1.Put("/course-management/sub-module-lessons/:lessonId", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateSubModuleLesson)
groupV1.Post("/course-management/sub-module-lessons", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateSubModuleLesson)
groupV1.Get("/course-management/sub-modules/:subModuleId/practices", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetSubModulePractices)
groupV1.Get("/course-management/practices/:practiceId", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetSubModulePracticeByID)
groupV1.Post("/course-management/sub-module-practices", a.authMiddleware, a.RequirePermission("question_sets.update"), h.CreateSubModulePractice)