feat: practice detail API, inactive purge tracking, and related plumbing

- Add GET /api/v1/course-management/practices/:practiceId/detail with full question items

- Add migration 000040 for sub-module content inactive purge tracking

- Hierarchy queries, sqlc gen, config/app purge job, swagger refresh

Made-with: Cursor
This commit is contained in:
Yared Yemane 2026-04-20 08:24:59 -07:00
parent 90baa582be
commit de95c4d0d2
14 changed files with 601 additions and 124 deletions

View File

@ -0,0 +1,3 @@
ALTER TABLE sub_module_lessons DROP COLUMN IF EXISTS inactive_since;
ALTER TABLE sub_module_practices DROP COLUMN IF EXISTS inactive_since;
ALTER TABLE sub_module_capstones DROP COLUMN IF EXISTS inactive_since;

View File

@ -0,0 +1,26 @@
-- Track when submodule lessons, practices, and capstones became inactive for retention-based hard delete.
ALTER TABLE sub_module_lessons
ADD COLUMN IF NOT EXISTS inactive_since TIMESTAMPTZ;
ALTER TABLE sub_module_practices
ADD COLUMN IF NOT EXISTS inactive_since TIMESTAMPTZ;
ALTER TABLE sub_module_capstones
ADD COLUMN IF NOT EXISTS inactive_since TIMESTAMPTZ;
-- Existing inactive rows: start retention window from migration time (conservative).
UPDATE sub_module_lessons
SET inactive_since = NOW()
WHERE is_active = FALSE
AND inactive_since IS NULL;
UPDATE sub_module_practices
SET inactive_since = NOW()
WHERE is_active = FALSE
AND inactive_since IS NULL;
UPDATE sub_module_capstones
SET inactive_since = NOW()
WHERE is_active = FALSE
AND inactive_since IS NULL;

View File

@ -111,6 +111,7 @@ SELECT
smp.question_set_id,
smp.display_order,
smp.is_active,
smp.inactive_since,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
@ -132,6 +133,7 @@ SELECT
smp.question_set_id,
smp.display_order,
smp.is_active,
smp.inactive_since,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
@ -152,6 +154,7 @@ SELECT
smc.question_set_id,
smc.display_order,
smc.is_active,
smc.inactive_since,
qs.status,
qs.set_type,
qs.time_limit_minutes,
@ -176,6 +179,7 @@ SELECT
smc.question_set_id,
smc.display_order,
smc.is_active,
smc.inactive_since,
qs.status,
qs.set_type,
qs.time_limit_minutes,
@ -374,7 +378,8 @@ INSERT INTO sub_module_lessons (
teaching_audio_url,
teaching_video_url,
display_order,
is_active
is_active,
inactive_since
)
VALUES (
$1,
@ -386,7 +391,8 @@ VALUES (
$7,
$8,
COALESCE($9, 0),
COALESCE($10, TRUE)
COALESCE($10, TRUE),
CASE WHEN COALESCE($10, TRUE) THEN NULL ELSE NOW() END
)
RETURNING *;
@ -402,7 +408,12 @@ SET
teaching_audio_url = $7,
teaching_video_url = $8,
display_order = $9,
is_active = $10
is_active = $10,
inactive_since = CASE
WHEN $10 THEN NULL
WHEN is_active THEN NOW()
ELSE inactive_since
END
WHERE id = $11
RETURNING *;
@ -415,9 +426,10 @@ INSERT INTO sub_module_practices (
intro_video_url,
question_set_id,
display_order,
is_active
is_active,
inactive_since
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE))
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE), CASE WHEN COALESCE($8, TRUE) THEN NULL ELSE NOW() END)
RETURNING *;
-- name: CreateSubModuleCapstone :one
@ -429,9 +441,10 @@ INSERT INTO sub_module_capstones (
thumbnail,
question_set_id,
display_order,
is_active
is_active,
inactive_since
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE))
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE), CASE WHEN COALESCE($8, TRUE) THEN NULL ELSE NOW() END)
RETURNING *;
-- name: UpdateSubModuleCapstone :one
@ -442,10 +455,43 @@ SET
tips = $3,
thumbnail = $4,
display_order = $5,
is_active = $6
is_active = $6,
inactive_since = CASE
WHEN $6 THEN NULL
WHEN is_active THEN NOW()
ELSE inactive_since
END
WHERE id = $7
RETURNING *;
-- name: PurgeInactiveSubModuleLessonsBefore :execrows
DELETE FROM sub_module_lessons
WHERE is_active = FALSE
AND inactive_since IS NOT NULL
AND inactive_since < $1;
-- name: PurgeInactiveSubModulePracticesBefore :execrows
DELETE FROM question_sets qs
USING (
SELECT question_set_id
FROM sub_module_practices
WHERE is_active = FALSE
AND inactive_since IS NOT NULL
AND inactive_since < $1
) doomed
WHERE qs.id = doomed.question_set_id;
-- name: PurgeInactiveSubModuleCapstonesBefore :execrows
DELETE FROM question_sets qs
USING (
SELECT question_set_id
FROM sub_module_capstones
WHERE is_active = FALSE
AND inactive_since IS NOT NULL
AND inactive_since < $1
) doomed
WHERE qs.id = doomed.question_set_id;
-- name: GetModuleCapstones :many
SELECT
mc.id,

View File

@ -2220,6 +2220,53 @@ const docTemplate = `{
}
}
},
"/api/v1/course-management/practices/{practiceId}/detail": {
"get": {
"description": "Returns one active practice with question-set fields and the ordered question list (full item detail)",
"produces": [
"application/json"
],
"tags": [
"course-management"
],
"summary": "Get practice with full question list",
"parameters": [
{
"type": "integer",
"description": "Practice ID (sub_module_practices.id)",
"name": "practiceId",
"in": "path",
"required": true
}
],
"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"
}
}
}
}
},
"/api/v1/course-management/sub-categories": {
"get": {
"description": "Returns all active course sub-categories",
@ -2822,7 +2869,7 @@ const docTemplate = `{
},
"/api/v1/course-management/sub-modules/{subModuleId}/lessons": {
"get": {
"description": "Returns all active lessons for a sub-module (teaching content metadata)",
"description": "Returns lessons for a sub-module. By default only active lessons; pass include_inactive=true to include inactive rows (e.g. admin / CMS).",
"consumes": [
"application/json"
],
@ -2840,6 +2887,12 @@ const docTemplate = `{
"name": "subModuleId",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "Include inactive lessons",
"name": "include_inactive",
"in": "query"
}
],
"responses": {

View File

@ -2212,6 +2212,53 @@
}
}
},
"/api/v1/course-management/practices/{practiceId}/detail": {
"get": {
"description": "Returns one active practice with question-set fields and the ordered question list (full item detail)",
"produces": [
"application/json"
],
"tags": [
"course-management"
],
"summary": "Get practice with full question list",
"parameters": [
{
"type": "integer",
"description": "Practice ID (sub_module_practices.id)",
"name": "practiceId",
"in": "path",
"required": true
}
],
"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"
}
}
}
}
},
"/api/v1/course-management/sub-categories": {
"get": {
"description": "Returns all active course sub-categories",
@ -2814,7 +2861,7 @@
},
"/api/v1/course-management/sub-modules/{subModuleId}/lessons": {
"get": {
"description": "Returns all active lessons for a sub-module (teaching content metadata)",
"description": "Returns lessons for a sub-module. By default only active lessons; pass include_inactive=true to include inactive rows (e.g. admin / CMS).",
"consumes": [
"application/json"
],
@ -2832,6 +2879,12 @@
"name": "subModuleId",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "Include inactive lessons",
"name": "include_inactive",
"in": "query"
}
],
"responses": {

View File

@ -3569,6 +3569,38 @@ paths:
summary: Get practice detail
tags:
- course-management
/api/v1/course-management/practices/{practiceId}/detail:
get:
description: Returns one active practice with question-set fields and the ordered
question list (full item detail)
parameters:
- description: Practice ID (sub_module_practices.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 with full question list
tags:
- course-management
/api/v1/course-management/sub-categories:
get:
description: Returns all active course sub-categories
@ -3973,13 +4005,18 @@ paths:
get:
consumes:
- application/json
description: Returns all active lessons for a sub-module (teaching content metadata)
description: Returns lessons for a sub-module. By default only active lessons;
pass include_inactive=true to include inactive rows (e.g. admin / CMS).
parameters:
- description: Sub-module ID
in: path
name: subModuleId
required: true
type: integer
- description: Include inactive lessons
in: query
name: include_inactive
type: boolean
produces:
- application/json
responses:

View File

@ -113,7 +113,13 @@ WHERE id = $2
_, err = q.db.Exec(ctx, `
UPDATE sub_module_practices
SET is_active = $1
SET
is_active = $1,
inactive_since = CASE
WHEN $1 THEN NULL
WHEN is_active THEN NOW()
ELSE inactive_since
END
WHERE question_set_id = $2
`, isActive, id)
return err

View File

@ -258,10 +258,11 @@ INSERT INTO sub_module_capstones (
thumbnail,
question_set_id,
display_order,
is_active
is_active,
inactive_since
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE))
RETURNING id, sub_module_id, title, description, tips, thumbnail, question_set_id, display_order, is_active, created_at
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE), CASE WHEN COALESCE($8, TRUE) THEN NULL ELSE NOW() END)
RETURNING id, sub_module_id, title, description, tips, thumbnail, question_set_id, display_order, is_active, created_at, inactive_since
`
type CreateSubModuleCapstoneParams struct {
@ -298,6 +299,7 @@ func (q *Queries) CreateSubModuleCapstone(ctx context.Context, arg CreateSubModu
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
&i.InactiveSince,
)
return i, err
}
@ -313,7 +315,8 @@ INSERT INTO sub_module_lessons (
teaching_audio_url,
teaching_video_url,
display_order,
is_active
is_active,
inactive_since
)
VALUES (
$1,
@ -325,9 +328,10 @@ VALUES (
$7,
$8,
COALESCE($9, 0),
COALESCE($10, TRUE)
COALESCE($10, TRUE),
CASE WHEN COALESCE($10, TRUE) THEN NULL ELSE NOW() END
)
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
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, inactive_since
`
type CreateSubModuleLessonParams struct {
@ -370,6 +374,7 @@ func (q *Queries) CreateSubModuleLesson(ctx context.Context, arg CreateSubModule
&i.TeachingImageUrl,
&i.TeachingAudioUrl,
&i.TeachingVideoUrl,
&i.InactiveSince,
)
return i, err
}
@ -383,10 +388,11 @@ INSERT INTO sub_module_practices (
intro_video_url,
question_set_id,
display_order,
is_active
is_active,
inactive_since
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE))
RETURNING id, sub_module_id, question_set_id, intro_video_url, display_order, is_active, created_at, title, description, thumbnail
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE), CASE WHEN COALESCE($8, TRUE) THEN NULL ELSE NOW() END)
RETURNING id, sub_module_id, question_set_id, intro_video_url, display_order, is_active, created_at, title, description, thumbnail, inactive_since
`
type CreateSubModulePracticeParams struct {
@ -423,6 +429,7 @@ func (q *Queries) CreateSubModulePractice(ctx context.Context, arg CreateSubModu
&i.Title,
&i.Description,
&i.Thumbnail,
&i.InactiveSince,
)
return i, err
}
@ -1268,6 +1275,7 @@ SELECT
smc.question_set_id,
smc.display_order,
smc.is_active,
smc.inactive_since,
qs.status,
qs.set_type,
qs.time_limit_minutes,
@ -1282,21 +1290,22 @@ WHERE smc.id = $1
`
type GetSubModuleCapstoneByIDRow struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Tips pgtype.Text `json:"tips"`
Thumbnail pgtype.Text `json:"thumbnail"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
Status string `json:"status"`
SetType string `json:"set_type"`
TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"`
PassingScore pgtype.Int4 `json:"passing_score"`
ShuffleQuestions bool `json:"shuffle_questions"`
QuestionCount int64 `json:"question_count"`
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Tips pgtype.Text `json:"tips"`
Thumbnail pgtype.Text `json:"thumbnail"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
Status string `json:"status"`
SetType string `json:"set_type"`
TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"`
PassingScore pgtype.Int4 `json:"passing_score"`
ShuffleQuestions bool `json:"shuffle_questions"`
QuestionCount int64 `json:"question_count"`
}
func (q *Queries) GetSubModuleCapstoneByID(ctx context.Context, id int64) (GetSubModuleCapstoneByIDRow, error) {
@ -1312,6 +1321,7 @@ func (q *Queries) GetSubModuleCapstoneByID(ctx context.Context, id int64) (GetSu
&i.QuestionSetID,
&i.DisplayOrder,
&i.IsActive,
&i.InactiveSince,
&i.Status,
&i.SetType,
&i.TimeLimitMinutes,
@ -1333,6 +1343,7 @@ SELECT
smc.question_set_id,
smc.display_order,
smc.is_active,
smc.inactive_since,
qs.status,
qs.set_type,
qs.time_limit_minutes,
@ -1348,21 +1359,22 @@ ORDER BY smc.display_order ASC, smc.id ASC
`
type GetSubModuleCapstonesRow struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Tips pgtype.Text `json:"tips"`
Thumbnail pgtype.Text `json:"thumbnail"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
Status string `json:"status"`
SetType string `json:"set_type"`
TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"`
PassingScore pgtype.Int4 `json:"passing_score"`
ShuffleQuestions bool `json:"shuffle_questions"`
QuestionCount int64 `json:"question_count"`
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Tips pgtype.Text `json:"tips"`
Thumbnail pgtype.Text `json:"thumbnail"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
Status string `json:"status"`
SetType string `json:"set_type"`
TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"`
PassingScore pgtype.Int4 `json:"passing_score"`
ShuffleQuestions bool `json:"shuffle_questions"`
QuestionCount int64 `json:"question_count"`
}
func (q *Queries) GetSubModuleCapstones(ctx context.Context, subModuleID int64) ([]GetSubModuleCapstonesRow, error) {
@ -1384,6 +1396,7 @@ func (q *Queries) GetSubModuleCapstones(ctx context.Context, subModuleID int64)
&i.QuestionSetID,
&i.DisplayOrder,
&i.IsActive,
&i.InactiveSince,
&i.Status,
&i.SetType,
&i.TimeLimitMinutes,
@ -1402,7 +1415,7 @@ func (q *Queries) GetSubModuleCapstones(ctx context.Context, subModuleID int64)
}
const GetSubModuleLessonByID = `-- name: GetSubModuleLessonByID :one
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
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, inactive_since
FROM sub_module_lessons
WHERE id = $1
`
@ -1423,12 +1436,13 @@ func (q *Queries) GetSubModuleLessonByID(ctx context.Context, id int64) (SubModu
&i.TeachingImageUrl,
&i.TeachingAudioUrl,
&i.TeachingVideoUrl,
&i.InactiveSince,
)
return i, err
}
const GetSubModuleLessons = `-- name: GetSubModuleLessons :many
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
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, inactive_since
FROM sub_module_lessons
WHERE sub_module_id = $1
AND is_active = TRUE
@ -1457,6 +1471,7 @@ func (q *Queries) GetSubModuleLessons(ctx context.Context, subModuleID int64) ([
&i.TeachingImageUrl,
&i.TeachingAudioUrl,
&i.TeachingVideoUrl,
&i.InactiveSince,
); err != nil {
return nil, err
}
@ -1469,7 +1484,7 @@ func (q *Queries) GetSubModuleLessons(ctx context.Context, subModuleID int64) ([
}
const GetSubModuleLessonsAll = `-- name: GetSubModuleLessonsAll :many
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
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, inactive_since
FROM sub_module_lessons
WHERE sub_module_id = $1
ORDER BY display_order ASC, id ASC
@ -1497,6 +1512,7 @@ func (q *Queries) GetSubModuleLessonsAll(ctx context.Context, subModuleID int64)
&i.TeachingImageUrl,
&i.TeachingAudioUrl,
&i.TeachingVideoUrl,
&i.InactiveSince,
); err != nil {
return nil, err
}
@ -1519,6 +1535,7 @@ SELECT
smp.question_set_id,
smp.display_order,
smp.is_active,
smp.inactive_since,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
@ -1530,18 +1547,19 @@ WHERE smp.id = $1
`
type GetSubModulePracticeByIDRow struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
Status string `json:"status"`
SetType string `json:"set_type"`
QuestionCount int64 `json:"question_count"`
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
Status string `json:"status"`
SetType string `json:"set_type"`
QuestionCount int64 `json:"question_count"`
}
func (q *Queries) GetSubModulePracticeByID(ctx context.Context, id int64) (GetSubModulePracticeByIDRow, error) {
@ -1557,6 +1575,7 @@ func (q *Queries) GetSubModulePracticeByID(ctx context.Context, id int64) (GetSu
&i.QuestionSetID,
&i.DisplayOrder,
&i.IsActive,
&i.InactiveSince,
&i.Status,
&i.SetType,
&i.QuestionCount,
@ -1575,6 +1594,7 @@ SELECT
smp.question_set_id,
smp.display_order,
smp.is_active,
smp.inactive_since,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
@ -1587,18 +1607,19 @@ ORDER BY smp.display_order ASC, smp.id ASC
`
type GetSubModulePracticesRow struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
Status string `json:"status"`
SetType string `json:"set_type"`
QuestionCount int64 `json:"question_count"`
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
Status string `json:"status"`
SetType string `json:"set_type"`
QuestionCount int64 `json:"question_count"`
}
func (q *Queries) GetSubModulePractices(ctx context.Context, subModuleID int64) ([]GetSubModulePracticesRow, error) {
@ -1620,6 +1641,7 @@ func (q *Queries) GetSubModulePractices(ctx context.Context, subModuleID int64)
&i.QuestionSetID,
&i.DisplayOrder,
&i.IsActive,
&i.InactiveSince,
&i.Status,
&i.SetType,
&i.QuestionCount,
@ -1722,6 +1744,61 @@ func (q *Queries) GetSubModulesByModuleID(ctx context.Context, moduleID int64) (
return items, nil
}
const PurgeInactiveSubModuleCapstonesBefore = `-- name: PurgeInactiveSubModuleCapstonesBefore :execrows
DELETE FROM question_sets qs
USING (
SELECT question_set_id
FROM sub_module_capstones
WHERE is_active = FALSE
AND inactive_since IS NOT NULL
AND inactive_since < $1
) doomed
WHERE qs.id = doomed.question_set_id
`
func (q *Queries) PurgeInactiveSubModuleCapstonesBefore(ctx context.Context, inactiveSince pgtype.Timestamptz) (int64, error) {
result, err := q.db.Exec(ctx, PurgeInactiveSubModuleCapstonesBefore, inactiveSince)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const PurgeInactiveSubModuleLessonsBefore = `-- name: PurgeInactiveSubModuleLessonsBefore :execrows
DELETE FROM sub_module_lessons
WHERE is_active = FALSE
AND inactive_since IS NOT NULL
AND inactive_since < $1
`
func (q *Queries) PurgeInactiveSubModuleLessonsBefore(ctx context.Context, inactiveSince pgtype.Timestamptz) (int64, error) {
result, err := q.db.Exec(ctx, PurgeInactiveSubModuleLessonsBefore, inactiveSince)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const PurgeInactiveSubModulePracticesBefore = `-- name: PurgeInactiveSubModulePracticesBefore :execrows
DELETE FROM question_sets qs
USING (
SELECT question_set_id
FROM sub_module_practices
WHERE is_active = FALSE
AND inactive_since IS NOT NULL
AND inactive_since < $1
) doomed
WHERE qs.id = doomed.question_set_id
`
func (q *Queries) PurgeInactiveSubModulePracticesBefore(ctx context.Context, inactiveSince pgtype.Timestamptz) (int64, error) {
result, err := q.db.Exec(ctx, PurgeInactiveSubModulePracticesBefore, inactiveSince)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const UpdateLevel = `-- name: UpdateLevel :one
UPDATE levels
SET
@ -1917,9 +1994,14 @@ SET
tips = $3,
thumbnail = $4,
display_order = $5,
is_active = $6
is_active = $6,
inactive_since = CASE
WHEN $6 THEN NULL
WHEN is_active THEN NOW()
ELSE inactive_since
END
WHERE id = $7
RETURNING id, sub_module_id, title, description, tips, thumbnail, question_set_id, display_order, is_active, created_at
RETURNING id, sub_module_id, title, description, tips, thumbnail, question_set_id, display_order, is_active, created_at, inactive_since
`
type UpdateSubModuleCapstoneParams struct {
@ -1954,6 +2036,7 @@ func (q *Queries) UpdateSubModuleCapstone(ctx context.Context, arg UpdateSubModu
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
&i.InactiveSince,
)
return i, err
}
@ -1970,9 +2053,14 @@ SET
teaching_audio_url = $7,
teaching_video_url = $8,
display_order = $9,
is_active = $10
is_active = $10,
inactive_since = CASE
WHEN $10 THEN NULL
WHEN is_active THEN NOW()
ELSE inactive_since
END
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
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, inactive_since
`
type UpdateSubModuleLessonParams struct {
@ -2017,6 +2105,7 @@ func (q *Queries) UpdateSubModuleLesson(ctx context.Context, arg UpdateSubModule
&i.TeachingImageUrl,
&i.TeachingAudioUrl,
&i.TeachingVideoUrl,
&i.InactiveSince,
)
return i, err
}

View File

@ -385,6 +385,7 @@ type SubModuleCapstone struct {
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
}
type SubModuleLesson struct {
@ -400,6 +401,7 @@ type SubModuleLesson struct {
TeachingImageUrl pgtype.Text `json:"teaching_image_url"`
TeachingAudioUrl pgtype.Text `json:"teaching_audio_url"`
TeachingVideoUrl pgtype.Text `json:"teaching_video_url"`
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
}
type SubModulePractice struct {
@ -413,6 +415,7 @@ type SubModulePractice struct {
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
}
type SubModuleVideo struct {

View File

@ -140,6 +140,9 @@ type Config struct {
AccountDeletionPurgeEnabled bool
AccountDeletionPurgeInterval time.Duration
AccountDeletionPurgeBatchSize int32
InactiveSubModuleContentPurgeEnabled bool
InactiveSubModuleContentPurgeInterval time.Duration
InactiveSubModuleContentRetentionDays int
DBResetReseedEnabled bool
DBResetReseedToken string
DBSeedDir string
@ -565,6 +568,38 @@ func (c *Config) loadEnv() error {
}
}
// Hard-delete inactive submodule lessons / practices / capstones after a retention period
inactiveContentPurge := strings.TrimSpace(os.Getenv("INACTIVE_SUBMODULE_CONTENT_PURGE_ENABLED"))
if inactiveContentPurge == "" {
c.InactiveSubModuleContentPurgeEnabled = false
} else {
c.InactiveSubModuleContentPurgeEnabled = inactiveContentPurge == "true" || inactiveContentPurge == "1"
}
inactiveContentPurgeInterval := strings.TrimSpace(os.Getenv("INACTIVE_SUBMODULE_CONTENT_PURGE_INTERVAL"))
if inactiveContentPurgeInterval == "" {
c.InactiveSubModuleContentPurgeInterval = 24 * time.Hour
} else {
interval, err := time.ParseDuration(inactiveContentPurgeInterval)
if err != nil || interval <= 0 {
c.InactiveSubModuleContentPurgeInterval = 24 * time.Hour
} else {
c.InactiveSubModuleContentPurgeInterval = interval
}
}
retentionDaysStr := strings.TrimSpace(os.Getenv("INACTIVE_SUBMODULE_CONTENT_RETENTION_DAYS"))
if retentionDaysStr == "" {
c.InactiveSubModuleContentRetentionDays = 30
} else {
days, err := strconv.Atoi(retentionDaysStr)
if err != nil || days < 1 {
c.InactiveSubModuleContentRetentionDays = 30
} else {
c.InactiveSubModuleContentRetentionDays = days
}
}
// Dangerous DB reset+reseed endpoint configuration
// Enabled by default and does not require .env variables.
// Optional token can still be set programmatically if needed.

View File

@ -35,6 +35,7 @@ import (
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/jackc/pgx/v5/pgtype"
)
type App struct {
@ -65,8 +66,9 @@ type App struct {
Logger *slog.Logger
mongoLoggerSvc *zap.Logger
analyticsDB *dbgen.Queries
rbacSvc *rbacservice.Service
stopPurgeWorker context.CancelFunc
rbacSvc *rbacservice.Service
stopPurgeWorker context.CancelFunc
stopInactiveSubModuleContentPurge context.CancelFunc
}
func NewApp(
@ -152,6 +154,8 @@ func NewApp(
func (a *App) Run() error {
a.startAccountDeletionPurgeWorker()
defer a.stopAccountDeletionPurgeWorker()
a.startInactiveSubModuleContentPurgeWorker()
defer a.stopInactiveSubModuleContentPurgeWorker()
return a.fiber.Listen(fmt.Sprintf(":%d", a.port))
}
@ -216,3 +220,86 @@ func (a *App) runAccountDeletionPurgeOnce(ctx context.Context, batchSize int32)
a.logger.Info("account deletion purge run completed", "deleted_count", deletedCount, "batch_size", batchSize)
}
}
func (a *App) startInactiveSubModuleContentPurgeWorker() {
if a.cfg == nil || !a.cfg.InactiveSubModuleContentPurgeEnabled {
a.logger.Info("inactive submodule content purge worker disabled")
return
}
interval := a.cfg.InactiveSubModuleContentPurgeInterval
if interval <= 0 {
interval = 24 * time.Hour
}
retentionDays := a.cfg.InactiveSubModuleContentRetentionDays
if retentionDays < 1 {
retentionDays = 30
}
retention := time.Duration(retentionDays) * 24 * time.Hour
ctx, cancel := context.WithCancel(context.Background())
a.stopInactiveSubModuleContentPurge = cancel
a.logger.Info(
"starting inactive submodule content purge worker",
"interval", interval.String(),
"retention_days", retentionDays,
)
go func() {
a.runInactiveSubModuleContentPurgeOnce(ctx, retention)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
a.logger.Info("inactive submodule content purge worker stopped")
return
case <-ticker.C:
a.runInactiveSubModuleContentPurgeOnce(ctx, retention)
}
}
}()
}
func (a *App) stopInactiveSubModuleContentPurgeWorker() {
if a.stopInactiveSubModuleContentPurge != nil {
a.stopInactiveSubModuleContentPurge()
}
}
func (a *App) runInactiveSubModuleContentPurgeOnce(ctx context.Context, retention time.Duration) {
cutoff := time.Now().Add(-retention)
cutoffParam := pgtype.Timestamptz{Time: cutoff, Valid: true}
nLessons, err := a.analyticsDB.PurgeInactiveSubModuleLessonsBefore(ctx, cutoffParam)
if err != nil {
a.logger.Error("purge inactive submodule lessons failed", "error", err)
return
}
nPractices, err := a.analyticsDB.PurgeInactiveSubModulePracticesBefore(ctx, cutoffParam)
if err != nil {
a.logger.Error("purge inactive submodule practices failed", "error", err)
return
}
nCapstones, err := a.analyticsDB.PurgeInactiveSubModuleCapstonesBefore(ctx, cutoffParam)
if err != nil {
a.logger.Error("purge inactive submodule capstones failed", "error", err)
return
}
if nLessons > 0 || nPractices > 0 || nCapstones > 0 {
a.logger.Info(
"inactive submodule content purge run completed",
"lessons_deleted", nLessons,
"practice_question_sets_deleted", nPractices,
"capstone_question_sets_deleted", nCapstones,
"cutoff", cutoff.UTC().Format(time.RFC3339),
)
}
}

View File

@ -2203,6 +2203,58 @@ func (h *Handler) GetSubModulePracticeByID(c *fiber.Ctx) error {
})
}
// GetSubModulePracticeDetail godoc
// @Summary Get practice with full question list
// @Description Returns one active practice with question-set fields and the ordered question list (full item detail)
// @Tags course-management
// @Produce json
// @Param practiceId path int true "Practice ID (sub_module_practices.id)"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/practices/{practiceId}/detail [get]
func (h *Handler) GetSubModulePracticeDetail(c *fiber.Ctx) error {
practiceID, err := strconv.ParseInt(c.Params("practiceId"), 10, 64)
if err != nil || practiceID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid practice ID",
Error: "practiceId must be a positive integer",
})
}
practice, err := h.analyticsDB.GetSubModulePracticeByID(c.Context(), practiceID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found",
Error: err.Error(),
})
}
const pageSize int32 = 500
var allItems []domain.QuestionSetItemWithQuestion
var offset int32
for {
batch, total, err := h.questionsSvc.GetQuestionSetItemsPaginated(c.Context(), practice.QuestionSetID, nil, pageSize, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load practice questions",
Error: err.Error(),
})
}
allItems = append(allItems, batch...)
if int64(len(allItems)) >= total || len(batch) == 0 {
break
}
offset += pageSize
}
return c.JSON(domain.Response{
Message: "Practice retrieved successfully",
Data: map[string]interface{}{
"practice": practice,
"questions": questionSetItemsToRes(allItems),
},
})
}
// GetSubModuleCapstones godoc
// @Summary List capstones under sub-module
// @Description Returns active capstones for a sub-module with question-set settings and question counts

View File

@ -1066,6 +1066,30 @@ type paginatedQuestionSetItemsRes struct {
Offset int32 `json:"offset"`
}
func questionSetItemsToRes(items []domain.QuestionSetItemWithQuestion) []questionSetItemRes {
out := make([]questionSetItemRes, 0, len(items))
for _, item := range items {
out = append(out, questionSetItemRes{
ID: item.ID,
SetID: item.SetID,
QuestionID: item.QuestionID,
DisplayOrder: item.DisplayOrder,
QuestionText: item.QuestionText,
QuestionType: item.QuestionType,
DifficultyLevel: item.DifficultyLevel,
Points: item.Points,
Explanation: item.Explanation,
Tips: item.Tips,
VoicePrompt: item.VoicePrompt,
SampleAnswerVoicePrompt: item.SampleAnswerVoicePrompt,
ImageURL: item.ImageURL,
AudioCorrectAnswerText: item.AudioCorrectAnswerText,
QuestionStatus: item.QuestionStatus,
})
}
return out
}
// AddQuestionToSet godoc
// @Summary Add question to set
// @Description Links a question to a question set
@ -1162,26 +1186,7 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error {
})
}
var itemResponses []questionSetItemRes
for _, item := range items {
itemResponses = append(itemResponses, questionSetItemRes{
ID: item.ID,
SetID: item.SetID,
QuestionID: item.QuestionID,
DisplayOrder: item.DisplayOrder,
QuestionText: item.QuestionText,
QuestionType: item.QuestionType,
DifficultyLevel: item.DifficultyLevel,
Points: item.Points,
Explanation: item.Explanation,
Tips: item.Tips,
VoicePrompt: item.VoicePrompt,
SampleAnswerVoicePrompt: item.SampleAnswerVoicePrompt,
ImageURL: item.ImageURL,
AudioCorrectAnswerText: item.AudioCorrectAnswerText,
QuestionStatus: item.QuestionStatus,
})
}
itemResponses := questionSetItemsToRes(items)
return c.JSON(domain.Response{
Message: "Questions retrieved successfully",
@ -1263,26 +1268,7 @@ func (h *Handler) GetQuestionsByPractice(c *fiber.Ctx) error {
})
}
itemResponses := make([]questionSetItemRes, 0, len(items))
for _, item := range items {
itemResponses = append(itemResponses, questionSetItemRes{
ID: item.ID,
SetID: item.SetID,
QuestionID: item.QuestionID,
DisplayOrder: item.DisplayOrder,
QuestionText: item.QuestionText,
QuestionType: item.QuestionType,
DifficultyLevel: item.DifficultyLevel,
Points: item.Points,
Explanation: item.Explanation,
Tips: item.Tips,
VoicePrompt: item.VoicePrompt,
SampleAnswerVoicePrompt: item.SampleAnswerVoicePrompt,
ImageURL: item.ImageURL,
AudioCorrectAnswerText: item.AudioCorrectAnswerText,
QuestionStatus: item.QuestionStatus,
})
}
itemResponses := questionSetItemsToRes(items)
return c.JSON(domain.Response{
Message: "Practice questions retrieved successfully",

View File

@ -123,6 +123,7 @@ func (a *App) initAppRoutes() {
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/detail", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetSubModulePracticeDetail)
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)
groupV1.Put("/course-management/practices/:practiceId", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdatePractice)