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:
parent
90baa582be
commit
de95c4d0d2
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -111,6 +111,7 @@ SELECT
|
||||||
smp.question_set_id,
|
smp.question_set_id,
|
||||||
smp.display_order,
|
smp.display_order,
|
||||||
smp.is_active,
|
smp.is_active,
|
||||||
|
smp.inactive_since,
|
||||||
qs.status,
|
qs.status,
|
||||||
qs.set_type,
|
qs.set_type,
|
||||||
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
|
(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.question_set_id,
|
||||||
smp.display_order,
|
smp.display_order,
|
||||||
smp.is_active,
|
smp.is_active,
|
||||||
|
smp.inactive_since,
|
||||||
qs.status,
|
qs.status,
|
||||||
qs.set_type,
|
qs.set_type,
|
||||||
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
|
(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.question_set_id,
|
||||||
smc.display_order,
|
smc.display_order,
|
||||||
smc.is_active,
|
smc.is_active,
|
||||||
|
smc.inactive_since,
|
||||||
qs.status,
|
qs.status,
|
||||||
qs.set_type,
|
qs.set_type,
|
||||||
qs.time_limit_minutes,
|
qs.time_limit_minutes,
|
||||||
|
|
@ -176,6 +179,7 @@ SELECT
|
||||||
smc.question_set_id,
|
smc.question_set_id,
|
||||||
smc.display_order,
|
smc.display_order,
|
||||||
smc.is_active,
|
smc.is_active,
|
||||||
|
smc.inactive_since,
|
||||||
qs.status,
|
qs.status,
|
||||||
qs.set_type,
|
qs.set_type,
|
||||||
qs.time_limit_minutes,
|
qs.time_limit_minutes,
|
||||||
|
|
@ -374,7 +378,8 @@ INSERT INTO sub_module_lessons (
|
||||||
teaching_audio_url,
|
teaching_audio_url,
|
||||||
teaching_video_url,
|
teaching_video_url,
|
||||||
display_order,
|
display_order,
|
||||||
is_active
|
is_active,
|
||||||
|
inactive_since
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1,
|
$1,
|
||||||
|
|
@ -386,7 +391,8 @@ VALUES (
|
||||||
$7,
|
$7,
|
||||||
$8,
|
$8,
|
||||||
COALESCE($9, 0),
|
COALESCE($9, 0),
|
||||||
COALESCE($10, TRUE)
|
COALESCE($10, TRUE),
|
||||||
|
CASE WHEN COALESCE($10, TRUE) THEN NULL ELSE NOW() END
|
||||||
)
|
)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
|
@ -402,7 +408,12 @@ SET
|
||||||
teaching_audio_url = $7,
|
teaching_audio_url = $7,
|
||||||
teaching_video_url = $8,
|
teaching_video_url = $8,
|
||||||
display_order = $9,
|
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
|
WHERE id = $11
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
|
@ -415,9 +426,10 @@ INSERT INTO sub_module_practices (
|
||||||
intro_video_url,
|
intro_video_url,
|
||||||
question_set_id,
|
question_set_id,
|
||||||
display_order,
|
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 *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: CreateSubModuleCapstone :one
|
-- name: CreateSubModuleCapstone :one
|
||||||
|
|
@ -429,9 +441,10 @@ INSERT INTO sub_module_capstones (
|
||||||
thumbnail,
|
thumbnail,
|
||||||
question_set_id,
|
question_set_id,
|
||||||
display_order,
|
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 *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: UpdateSubModuleCapstone :one
|
-- name: UpdateSubModuleCapstone :one
|
||||||
|
|
@ -442,10 +455,43 @@ SET
|
||||||
tips = $3,
|
tips = $3,
|
||||||
thumbnail = $4,
|
thumbnail = $4,
|
||||||
display_order = $5,
|
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
|
WHERE id = $7
|
||||||
RETURNING *;
|
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
|
-- name: GetModuleCapstones :many
|
||||||
SELECT
|
SELECT
|
||||||
mc.id,
|
mc.id,
|
||||||
|
|
|
||||||
55
docs/docs.go
55
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": {
|
"/api/v1/course-management/sub-categories": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns all active course sub-categories",
|
"description": "Returns all active course sub-categories",
|
||||||
|
|
@ -2822,7 +2869,7 @@ const docTemplate = `{
|
||||||
},
|
},
|
||||||
"/api/v1/course-management/sub-modules/{subModuleId}/lessons": {
|
"/api/v1/course-management/sub-modules/{subModuleId}/lessons": {
|
||||||
"get": {
|
"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": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
|
@ -2840,6 +2887,12 @@ const docTemplate = `{
|
||||||
"name": "subModuleId",
|
"name": "subModuleId",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Include inactive lessons",
|
||||||
|
"name": "include_inactive",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
"/api/v1/course-management/sub-categories": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns all active course sub-categories",
|
"description": "Returns all active course sub-categories",
|
||||||
|
|
@ -2814,7 +2861,7 @@
|
||||||
},
|
},
|
||||||
"/api/v1/course-management/sub-modules/{subModuleId}/lessons": {
|
"/api/v1/course-management/sub-modules/{subModuleId}/lessons": {
|
||||||
"get": {
|
"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": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
|
@ -2832,6 +2879,12 @@
|
||||||
"name": "subModuleId",
|
"name": "subModuleId",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Include inactive lessons",
|
||||||
|
"name": "include_inactive",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
|
||||||
|
|
@ -3569,6 +3569,38 @@ paths:
|
||||||
summary: Get practice detail
|
summary: Get practice detail
|
||||||
tags:
|
tags:
|
||||||
- course-management
|
- 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:
|
/api/v1/course-management/sub-categories:
|
||||||
get:
|
get:
|
||||||
description: Returns all active course sub-categories
|
description: Returns all active course sub-categories
|
||||||
|
|
@ -3973,13 +4005,18 @@ paths:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- 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:
|
parameters:
|
||||||
- description: Sub-module ID
|
- description: Sub-module ID
|
||||||
in: path
|
in: path
|
||||||
name: subModuleId
|
name: subModuleId
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: Include inactive lessons
|
||||||
|
in: query
|
||||||
|
name: include_inactive
|
||||||
|
type: boolean
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,13 @@ WHERE id = $2
|
||||||
|
|
||||||
_, err = q.db.Exec(ctx, `
|
_, err = q.db.Exec(ctx, `
|
||||||
UPDATE sub_module_practices
|
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
|
WHERE question_set_id = $2
|
||||||
`, isActive, id)
|
`, isActive, id)
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -258,10 +258,11 @@ INSERT INTO sub_module_capstones (
|
||||||
thumbnail,
|
thumbnail,
|
||||||
question_set_id,
|
question_set_id,
|
||||||
display_order,
|
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 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 CreateSubModuleCapstoneParams struct {
|
type CreateSubModuleCapstoneParams struct {
|
||||||
|
|
@ -298,6 +299,7 @@ func (q *Queries) CreateSubModuleCapstone(ctx context.Context, arg CreateSubModu
|
||||||
&i.DisplayOrder,
|
&i.DisplayOrder,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.InactiveSince,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -313,7 +315,8 @@ INSERT INTO sub_module_lessons (
|
||||||
teaching_audio_url,
|
teaching_audio_url,
|
||||||
teaching_video_url,
|
teaching_video_url,
|
||||||
display_order,
|
display_order,
|
||||||
is_active
|
is_active,
|
||||||
|
inactive_since
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1,
|
$1,
|
||||||
|
|
@ -325,9 +328,10 @@ VALUES (
|
||||||
$7,
|
$7,
|
||||||
$8,
|
$8,
|
||||||
COALESCE($9, 0),
|
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 {
|
type CreateSubModuleLessonParams struct {
|
||||||
|
|
@ -370,6 +374,7 @@ func (q *Queries) CreateSubModuleLesson(ctx context.Context, arg CreateSubModule
|
||||||
&i.TeachingImageUrl,
|
&i.TeachingImageUrl,
|
||||||
&i.TeachingAudioUrl,
|
&i.TeachingAudioUrl,
|
||||||
&i.TeachingVideoUrl,
|
&i.TeachingVideoUrl,
|
||||||
|
&i.InactiveSince,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -383,10 +388,11 @@ INSERT INTO sub_module_practices (
|
||||||
intro_video_url,
|
intro_video_url,
|
||||||
question_set_id,
|
question_set_id,
|
||||||
display_order,
|
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 id, sub_module_id, question_set_id, intro_video_url, display_order, is_active, created_at, title, description, thumbnail
|
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 {
|
type CreateSubModulePracticeParams struct {
|
||||||
|
|
@ -423,6 +429,7 @@ func (q *Queries) CreateSubModulePractice(ctx context.Context, arg CreateSubModu
|
||||||
&i.Title,
|
&i.Title,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Thumbnail,
|
&i.Thumbnail,
|
||||||
|
&i.InactiveSince,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -1268,6 +1275,7 @@ SELECT
|
||||||
smc.question_set_id,
|
smc.question_set_id,
|
||||||
smc.display_order,
|
smc.display_order,
|
||||||
smc.is_active,
|
smc.is_active,
|
||||||
|
smc.inactive_since,
|
||||||
qs.status,
|
qs.status,
|
||||||
qs.set_type,
|
qs.set_type,
|
||||||
qs.time_limit_minutes,
|
qs.time_limit_minutes,
|
||||||
|
|
@ -1282,21 +1290,22 @@ WHERE smc.id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetSubModuleCapstoneByIDRow struct {
|
type GetSubModuleCapstoneByIDRow struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
SubModuleID int64 `json:"sub_module_id"`
|
SubModuleID int64 `json:"sub_module_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
Tips pgtype.Text `json:"tips"`
|
Tips pgtype.Text `json:"tips"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
QuestionSetID int64 `json:"question_set_id"`
|
QuestionSetID int64 `json:"question_set_id"`
|
||||||
DisplayOrder int32 `json:"display_order"`
|
DisplayOrder int32 `json:"display_order"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
Status string `json:"status"`
|
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
|
||||||
SetType string `json:"set_type"`
|
Status string `json:"status"`
|
||||||
TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"`
|
SetType string `json:"set_type"`
|
||||||
PassingScore pgtype.Int4 `json:"passing_score"`
|
TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"`
|
||||||
ShuffleQuestions bool `json:"shuffle_questions"`
|
PassingScore pgtype.Int4 `json:"passing_score"`
|
||||||
QuestionCount int64 `json:"question_count"`
|
ShuffleQuestions bool `json:"shuffle_questions"`
|
||||||
|
QuestionCount int64 `json:"question_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetSubModuleCapstoneByID(ctx context.Context, id int64) (GetSubModuleCapstoneByIDRow, error) {
|
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.QuestionSetID,
|
||||||
&i.DisplayOrder,
|
&i.DisplayOrder,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
|
&i.InactiveSince,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
&i.SetType,
|
&i.SetType,
|
||||||
&i.TimeLimitMinutes,
|
&i.TimeLimitMinutes,
|
||||||
|
|
@ -1333,6 +1343,7 @@ SELECT
|
||||||
smc.question_set_id,
|
smc.question_set_id,
|
||||||
smc.display_order,
|
smc.display_order,
|
||||||
smc.is_active,
|
smc.is_active,
|
||||||
|
smc.inactive_since,
|
||||||
qs.status,
|
qs.status,
|
||||||
qs.set_type,
|
qs.set_type,
|
||||||
qs.time_limit_minutes,
|
qs.time_limit_minutes,
|
||||||
|
|
@ -1348,21 +1359,22 @@ ORDER BY smc.display_order ASC, smc.id ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetSubModuleCapstonesRow struct {
|
type GetSubModuleCapstonesRow struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
SubModuleID int64 `json:"sub_module_id"`
|
SubModuleID int64 `json:"sub_module_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
Tips pgtype.Text `json:"tips"`
|
Tips pgtype.Text `json:"tips"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
QuestionSetID int64 `json:"question_set_id"`
|
QuestionSetID int64 `json:"question_set_id"`
|
||||||
DisplayOrder int32 `json:"display_order"`
|
DisplayOrder int32 `json:"display_order"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
Status string `json:"status"`
|
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
|
||||||
SetType string `json:"set_type"`
|
Status string `json:"status"`
|
||||||
TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"`
|
SetType string `json:"set_type"`
|
||||||
PassingScore pgtype.Int4 `json:"passing_score"`
|
TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"`
|
||||||
ShuffleQuestions bool `json:"shuffle_questions"`
|
PassingScore pgtype.Int4 `json:"passing_score"`
|
||||||
QuestionCount int64 `json:"question_count"`
|
ShuffleQuestions bool `json:"shuffle_questions"`
|
||||||
|
QuestionCount int64 `json:"question_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetSubModuleCapstones(ctx context.Context, subModuleID int64) ([]GetSubModuleCapstonesRow, error) {
|
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.QuestionSetID,
|
||||||
&i.DisplayOrder,
|
&i.DisplayOrder,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
|
&i.InactiveSince,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
&i.SetType,
|
&i.SetType,
|
||||||
&i.TimeLimitMinutes,
|
&i.TimeLimitMinutes,
|
||||||
|
|
@ -1402,7 +1415,7 @@ func (q *Queries) GetSubModuleCapstones(ctx context.Context, subModuleID int64)
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetSubModuleLessonByID = `-- name: GetSubModuleLessonByID :one
|
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
|
FROM sub_module_lessons
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
@ -1423,12 +1436,13 @@ func (q *Queries) GetSubModuleLessonByID(ctx context.Context, id int64) (SubModu
|
||||||
&i.TeachingImageUrl,
|
&i.TeachingImageUrl,
|
||||||
&i.TeachingAudioUrl,
|
&i.TeachingAudioUrl,
|
||||||
&i.TeachingVideoUrl,
|
&i.TeachingVideoUrl,
|
||||||
|
&i.InactiveSince,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetSubModuleLessons = `-- name: GetSubModuleLessons :many
|
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
|
FROM sub_module_lessons
|
||||||
WHERE sub_module_id = $1
|
WHERE sub_module_id = $1
|
||||||
AND is_active = TRUE
|
AND is_active = TRUE
|
||||||
|
|
@ -1457,6 +1471,7 @@ func (q *Queries) GetSubModuleLessons(ctx context.Context, subModuleID int64) ([
|
||||||
&i.TeachingImageUrl,
|
&i.TeachingImageUrl,
|
||||||
&i.TeachingAudioUrl,
|
&i.TeachingAudioUrl,
|
||||||
&i.TeachingVideoUrl,
|
&i.TeachingVideoUrl,
|
||||||
|
&i.InactiveSince,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -1469,7 +1484,7 @@ func (q *Queries) GetSubModuleLessons(ctx context.Context, subModuleID int64) ([
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetSubModuleLessonsAll = `-- name: GetSubModuleLessonsAll :many
|
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
|
FROM sub_module_lessons
|
||||||
WHERE sub_module_id = $1
|
WHERE sub_module_id = $1
|
||||||
ORDER BY display_order ASC, id ASC
|
ORDER BY display_order ASC, id ASC
|
||||||
|
|
@ -1497,6 +1512,7 @@ func (q *Queries) GetSubModuleLessonsAll(ctx context.Context, subModuleID int64)
|
||||||
&i.TeachingImageUrl,
|
&i.TeachingImageUrl,
|
||||||
&i.TeachingAudioUrl,
|
&i.TeachingAudioUrl,
|
||||||
&i.TeachingVideoUrl,
|
&i.TeachingVideoUrl,
|
||||||
|
&i.InactiveSince,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -1519,6 +1535,7 @@ SELECT
|
||||||
smp.question_set_id,
|
smp.question_set_id,
|
||||||
smp.display_order,
|
smp.display_order,
|
||||||
smp.is_active,
|
smp.is_active,
|
||||||
|
smp.inactive_since,
|
||||||
qs.status,
|
qs.status,
|
||||||
qs.set_type,
|
qs.set_type,
|
||||||
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
|
(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 {
|
type GetSubModulePracticeByIDRow struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
SubModuleID int64 `json:"sub_module_id"`
|
SubModuleID int64 `json:"sub_module_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
|
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
|
||||||
QuestionSetID int64 `json:"question_set_id"`
|
QuestionSetID int64 `json:"question_set_id"`
|
||||||
DisplayOrder int32 `json:"display_order"`
|
DisplayOrder int32 `json:"display_order"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
Status string `json:"status"`
|
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
|
||||||
SetType string `json:"set_type"`
|
Status string `json:"status"`
|
||||||
QuestionCount int64 `json:"question_count"`
|
SetType string `json:"set_type"`
|
||||||
|
QuestionCount int64 `json:"question_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetSubModulePracticeByID(ctx context.Context, id int64) (GetSubModulePracticeByIDRow, error) {
|
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.QuestionSetID,
|
||||||
&i.DisplayOrder,
|
&i.DisplayOrder,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
|
&i.InactiveSince,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
&i.SetType,
|
&i.SetType,
|
||||||
&i.QuestionCount,
|
&i.QuestionCount,
|
||||||
|
|
@ -1575,6 +1594,7 @@ SELECT
|
||||||
smp.question_set_id,
|
smp.question_set_id,
|
||||||
smp.display_order,
|
smp.display_order,
|
||||||
smp.is_active,
|
smp.is_active,
|
||||||
|
smp.inactive_since,
|
||||||
qs.status,
|
qs.status,
|
||||||
qs.set_type,
|
qs.set_type,
|
||||||
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
|
(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 {
|
type GetSubModulePracticesRow struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
SubModuleID int64 `json:"sub_module_id"`
|
SubModuleID int64 `json:"sub_module_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
|
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
|
||||||
QuestionSetID int64 `json:"question_set_id"`
|
QuestionSetID int64 `json:"question_set_id"`
|
||||||
DisplayOrder int32 `json:"display_order"`
|
DisplayOrder int32 `json:"display_order"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
Status string `json:"status"`
|
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
|
||||||
SetType string `json:"set_type"`
|
Status string `json:"status"`
|
||||||
QuestionCount int64 `json:"question_count"`
|
SetType string `json:"set_type"`
|
||||||
|
QuestionCount int64 `json:"question_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetSubModulePractices(ctx context.Context, subModuleID int64) ([]GetSubModulePracticesRow, error) {
|
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.QuestionSetID,
|
||||||
&i.DisplayOrder,
|
&i.DisplayOrder,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
|
&i.InactiveSince,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
&i.SetType,
|
&i.SetType,
|
||||||
&i.QuestionCount,
|
&i.QuestionCount,
|
||||||
|
|
@ -1722,6 +1744,61 @@ func (q *Queries) GetSubModulesByModuleID(ctx context.Context, moduleID int64) (
|
||||||
return items, nil
|
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
|
const UpdateLevel = `-- name: UpdateLevel :one
|
||||||
UPDATE levels
|
UPDATE levels
|
||||||
SET
|
SET
|
||||||
|
|
@ -1917,9 +1994,14 @@ SET
|
||||||
tips = $3,
|
tips = $3,
|
||||||
thumbnail = $4,
|
thumbnail = $4,
|
||||||
display_order = $5,
|
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
|
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 {
|
type UpdateSubModuleCapstoneParams struct {
|
||||||
|
|
@ -1954,6 +2036,7 @@ func (q *Queries) UpdateSubModuleCapstone(ctx context.Context, arg UpdateSubModu
|
||||||
&i.DisplayOrder,
|
&i.DisplayOrder,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.InactiveSince,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -1970,9 +2053,14 @@ SET
|
||||||
teaching_audio_url = $7,
|
teaching_audio_url = $7,
|
||||||
teaching_video_url = $8,
|
teaching_video_url = $8,
|
||||||
display_order = $9,
|
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
|
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 {
|
type UpdateSubModuleLessonParams struct {
|
||||||
|
|
@ -2017,6 +2105,7 @@ func (q *Queries) UpdateSubModuleLesson(ctx context.Context, arg UpdateSubModule
|
||||||
&i.TeachingImageUrl,
|
&i.TeachingImageUrl,
|
||||||
&i.TeachingAudioUrl,
|
&i.TeachingAudioUrl,
|
||||||
&i.TeachingVideoUrl,
|
&i.TeachingVideoUrl,
|
||||||
|
&i.InactiveSince,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,7 @@ type SubModuleCapstone struct {
|
||||||
DisplayOrder int32 `json:"display_order"`
|
DisplayOrder int32 `json:"display_order"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubModuleLesson struct {
|
type SubModuleLesson struct {
|
||||||
|
|
@ -400,6 +401,7 @@ type SubModuleLesson struct {
|
||||||
TeachingImageUrl pgtype.Text `json:"teaching_image_url"`
|
TeachingImageUrl pgtype.Text `json:"teaching_image_url"`
|
||||||
TeachingAudioUrl pgtype.Text `json:"teaching_audio_url"`
|
TeachingAudioUrl pgtype.Text `json:"teaching_audio_url"`
|
||||||
TeachingVideoUrl pgtype.Text `json:"teaching_video_url"`
|
TeachingVideoUrl pgtype.Text `json:"teaching_video_url"`
|
||||||
|
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubModulePractice struct {
|
type SubModulePractice struct {
|
||||||
|
|
@ -413,6 +415,7 @@ type SubModulePractice struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description pgtype.Text `json:"description"`
|
Description pgtype.Text `json:"description"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
|
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubModuleVideo struct {
|
type SubModuleVideo struct {
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,9 @@ type Config struct {
|
||||||
AccountDeletionPurgeEnabled bool
|
AccountDeletionPurgeEnabled bool
|
||||||
AccountDeletionPurgeInterval time.Duration
|
AccountDeletionPurgeInterval time.Duration
|
||||||
AccountDeletionPurgeBatchSize int32
|
AccountDeletionPurgeBatchSize int32
|
||||||
|
InactiveSubModuleContentPurgeEnabled bool
|
||||||
|
InactiveSubModuleContentPurgeInterval time.Duration
|
||||||
|
InactiveSubModuleContentRetentionDays int
|
||||||
DBResetReseedEnabled bool
|
DBResetReseedEnabled bool
|
||||||
DBResetReseedToken string
|
DBResetReseedToken string
|
||||||
DBSeedDir 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
|
// Dangerous DB reset+reseed endpoint configuration
|
||||||
// Enabled by default and does not require .env variables.
|
// Enabled by default and does not require .env variables.
|
||||||
// Optional token can still be set programmatically if needed.
|
// Optional token can still be set programmatically if needed.
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import (
|
||||||
"github.com/bytedance/sonic"
|
"github.com/bytedance/sonic"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
|
|
@ -65,8 +66,9 @@ type App struct {
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
mongoLoggerSvc *zap.Logger
|
mongoLoggerSvc *zap.Logger
|
||||||
analyticsDB *dbgen.Queries
|
analyticsDB *dbgen.Queries
|
||||||
rbacSvc *rbacservice.Service
|
rbacSvc *rbacservice.Service
|
||||||
stopPurgeWorker context.CancelFunc
|
stopPurgeWorker context.CancelFunc
|
||||||
|
stopInactiveSubModuleContentPurge context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp(
|
func NewApp(
|
||||||
|
|
@ -152,6 +154,8 @@ func NewApp(
|
||||||
func (a *App) Run() error {
|
func (a *App) Run() error {
|
||||||
a.startAccountDeletionPurgeWorker()
|
a.startAccountDeletionPurgeWorker()
|
||||||
defer a.stopAccountDeletionPurgeWorker()
|
defer a.stopAccountDeletionPurgeWorker()
|
||||||
|
a.startInactiveSubModuleContentPurgeWorker()
|
||||||
|
defer a.stopInactiveSubModuleContentPurgeWorker()
|
||||||
return a.fiber.Listen(fmt.Sprintf(":%d", a.port))
|
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)
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// GetSubModuleCapstones godoc
|
||||||
// @Summary List capstones under sub-module
|
// @Summary List capstones under sub-module
|
||||||
// @Description Returns active capstones for a sub-module with question-set settings and question counts
|
// @Description Returns active capstones for a sub-module with question-set settings and question counts
|
||||||
|
|
|
||||||
|
|
@ -1066,6 +1066,30 @@ type paginatedQuestionSetItemsRes struct {
|
||||||
Offset int32 `json:"offset"`
|
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
|
// AddQuestionToSet godoc
|
||||||
// @Summary Add question to set
|
// @Summary Add question to set
|
||||||
// @Description Links a question to a question set
|
// @Description Links a question to a question set
|
||||||
|
|
@ -1162,26 +1186,7 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var itemResponses []questionSetItemRes
|
itemResponses := questionSetItemsToRes(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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
return c.JSON(domain.Response{
|
||||||
Message: "Questions retrieved successfully",
|
Message: "Questions retrieved successfully",
|
||||||
|
|
@ -1263,26 +1268,7 @@ func (h *Handler) GetQuestionsByPractice(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
itemResponses := make([]questionSetItemRes, 0, len(items))
|
itemResponses := questionSetItemsToRes(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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
return c.JSON(domain.Response{
|
||||||
Message: "Practice questions retrieved successfully",
|
Message: "Practice questions retrieved successfully",
|
||||||
|
|
|
||||||
|
|
@ -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.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.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/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.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.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)
|
groupV1.Put("/course-management/practices/:practiceId", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdatePractice)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user