diff --git a/db/migrations/000040_sub_module_content_inactive_purge_tracking.down.sql b/db/migrations/000040_sub_module_content_inactive_purge_tracking.down.sql new file mode 100644 index 0000000..d3ea0c5 --- /dev/null +++ b/db/migrations/000040_sub_module_content_inactive_purge_tracking.down.sql @@ -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; diff --git a/db/migrations/000040_sub_module_content_inactive_purge_tracking.up.sql b/db/migrations/000040_sub_module_content_inactive_purge_tracking.up.sql new file mode 100644 index 0000000..3a9b641 --- /dev/null +++ b/db/migrations/000040_sub_module_content_inactive_purge_tracking.up.sql @@ -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; diff --git a/db/query/hierarchy.sql b/db/query/hierarchy.sql index f988833..320c05a 100644 --- a/db/query/hierarchy.sql +++ b/db/query/hierarchy.sql @@ -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, diff --git a/docs/docs.go b/docs/docs.go index 3b5ba54..dc0bc20 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": { diff --git a/docs/swagger.json b/docs/swagger.json index d65ae15..1096361 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 7778169..5955e23 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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: diff --git a/gen/db/compat_course_management.go b/gen/db/compat_course_management.go index ffa2cee..0a029f3 100644 --- a/gen/db/compat_course_management.go +++ b/gen/db/compat_course_management.go @@ -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 diff --git a/gen/db/hierarchy.sql.go b/gen/db/hierarchy.sql.go index 54212ac..d46b37e 100644 --- a/gen/db/hierarchy.sql.go +++ b/gen/db/hierarchy.sql.go @@ -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 } diff --git a/gen/db/models.go b/gen/db/models.go index f21f980..0310673 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -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 { diff --git a/internal/config/config.go b/internal/config/config.go index 9b5b17a..87f28b0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 8fc3f15..69fc796 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -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), + ) + } +} diff --git a/internal/web_server/handlers/hierarchy_handler.go b/internal/web_server/handlers/hierarchy_handler.go index 8e249b0..28bfe47 100644 --- a/internal/web_server/handlers/hierarchy_handler.go +++ b/internal/web_server/handlers/hierarchy_handler.go @@ -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 diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index 09e0d7d..e9cceea 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -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", diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index d356122..8c69c7f 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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)