Compare commits
14 Commits
f824c16c64
...
7e61e34292
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e61e34292 | |||
| 83db13bed0 | |||
| 12ad59c409 | |||
| 37aef49e28 | |||
| 1136a166f5 | |||
| d28bddace1 | |||
| 4a681265d7 | |||
| 2f73b60122 | |||
| ecad91d89e | |||
| a80db8afd9 | |||
| 52effaa321 | |||
| 062b1f6151 | |||
| 49bcc22d0d | |||
| 1e62510321 |
14
.cursor/rules/git-push-commit-if-dirty.mdc
Normal file
14
.cursor/rules/git-push-commit-if-dirty.mdc
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
description: Commit before push whenever the tree is dirty
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Git push
|
||||
|
||||
When the user asks to push (including phrases like “push”, “push to remote”, or “git push”):
|
||||
|
||||
1. Run `git status` (and if needed `git diff`) to check for unstaged/uncommitted changes.
|
||||
2. If there are changes worth shipping, stage and **commit first**—never omit secrets such as `.env`, credentials files, or private keys. Follow the repo’s commit message conventions.
|
||||
3. Then run `git push` to the tracked upstream.
|
||||
|
||||
If nothing is staged and the working tree is clean, pushing without a commit is fine.
|
||||
|
|
@ -4,6 +4,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|||
-- ======================================================
|
||||
-- Customer/Learner Users (login via /api/v1/auth/customer-login)
|
||||
-- Credentials: email + password@123
|
||||
-- OPEN_LEARNER demo user is seeded by migration 000061_open_learner_role (not here).
|
||||
-- ======================================================
|
||||
INSERT INTO users (
|
||||
id,
|
||||
|
|
|
|||
5
db/migrations/000060_practice_publish_status.down.sql
Normal file
5
db/migrations/000060_practice_publish_status.down.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE lms_practices DROP CONSTRAINT chk_lms_practices_publish_status;
|
||||
ALTER TABLE lms_practices DROP COLUMN publish_status;
|
||||
|
||||
ALTER TABLE exam_prep.lesson_practices DROP CONSTRAINT chk_exam_prep_lesson_practices_publish_status;
|
||||
ALTER TABLE exam_prep.lesson_practices DROP COLUMN publish_status;
|
||||
8
db/migrations/000060_practice_publish_status.up.sql
Normal file
8
db/migrations/000060_practice_publish_status.up.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
-- Draft vs published visibility for LMS and exam-prep practices.
|
||||
ALTER TABLE lms_practices
|
||||
ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED'
|
||||
CONSTRAINT chk_lms_practices_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED'));
|
||||
|
||||
ALTER TABLE exam_prep.lesson_practices
|
||||
ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED'
|
||||
CONSTRAINT chk_exam_prep_lesson_practices_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED'));
|
||||
5
db/migrations/000061_open_learner_role.down.sql
Normal file
5
db/migrations/000061_open_learner_role.down.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
DELETE FROM users WHERE id = 13 AND email = 'openlearner@yimaru.com';
|
||||
|
||||
DELETE FROM role_permissions WHERE role_id = (SELECT id FROM roles WHERE name = 'OPEN_LEARNER');
|
||||
|
||||
DELETE FROM roles WHERE name = 'OPEN_LEARNER';
|
||||
79
db/migrations/000061_open_learner_role.up.sql
Normal file
79
db/migrations/000061_open_learner_role.up.sql
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
-- OPEN_LEARNER: learner role with STUDENT-like RBAC but without LMS sequential prerequisite locks (handled in app code).
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
INSERT INTO roles (name, description, is_system) VALUES
|
||||
(
|
||||
'OPEN_LEARNER',
|
||||
'Learner with full LMS catalog access without sequential prerequisite locking',
|
||||
TRUE
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Demo OPEN_LEARNER (customer-login): openlearner@yimaru.com / password@123
|
||||
INSERT INTO users (
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
gender,
|
||||
birth_day,
|
||||
email,
|
||||
phone_number,
|
||||
role,
|
||||
password,
|
||||
age_group,
|
||||
education_level,
|
||||
country,
|
||||
region,
|
||||
knowledge_level,
|
||||
nick_name,
|
||||
occupation,
|
||||
learning_goal,
|
||||
language_goal,
|
||||
language_challange,
|
||||
favourite_topic,
|
||||
initial_assessment_completed,
|
||||
email_verified,
|
||||
phone_verified,
|
||||
status,
|
||||
last_login,
|
||||
profile_completed,
|
||||
profile_picture_url,
|
||||
preferred_language,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
13,
|
||||
'Demo',
|
||||
'OpenLearner',
|
||||
'Female',
|
||||
'1999-06-01',
|
||||
'openlearner@yimaru.com',
|
||||
NULL,
|
||||
'OPEN_LEARNER',
|
||||
crypt('password@123', gen_salt('bf'))::bytea,
|
||||
'25_34',
|
||||
'Bachelor',
|
||||
'Ethiopia',
|
||||
'Addis Ababa',
|
||||
'BEGINNER',
|
||||
'OpenLearner',
|
||||
'Tester',
|
||||
'Preview LMS content without sequential locks',
|
||||
'English',
|
||||
'Grammar',
|
||||
'Technology',
|
||||
FALSE,
|
||||
TRUE,
|
||||
FALSE,
|
||||
'ACTIVE',
|
||||
NULL,
|
||||
FALSE,
|
||||
NULL,
|
||||
'en',
|
||||
CURRENT_TIMESTAMP,
|
||||
NULL
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
|
@ -6,8 +6,9 @@ INSERT INTO exam_prep.lesson_practices (
|
|||
story_image,
|
||||
persona_id,
|
||||
question_set_id,
|
||||
quick_tips
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
quick_tips,
|
||||
publish_status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ExamPrepGetLessonPracticeByID :one
|
||||
|
|
@ -15,6 +16,13 @@ SELECT *
|
|||
FROM exam_prep.lesson_practices
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ExamPrepGetLessonPracticeByQuestionSetID :one
|
||||
SELECT *
|
||||
FROM exam_prep.lesson_practices
|
||||
WHERE question_set_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: ExamPrepListLessonPracticesByLessonID :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
|
|
@ -26,10 +34,15 @@ SELECT
|
|||
p.persona_id,
|
||||
p.question_set_id,
|
||||
p.quick_tips,
|
||||
p.publish_status,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM exam_prep.lesson_practices p
|
||||
WHERE p.unit_module_lesson_id = $1
|
||||
AND (
|
||||
sqlc.arg('published_only')::boolean = FALSE
|
||||
OR p.publish_status = 'PUBLISHED'::TEXT
|
||||
)
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2
|
||||
OFFSET $3;
|
||||
|
|
@ -43,6 +56,7 @@ SET
|
|||
persona_id = coalesce(sqlc.narg('persona_id')::bigint, persona_id),
|
||||
question_set_id = coalesce(sqlc.narg('question_set_id')::bigint, question_set_id),
|
||||
quick_tips = coalesce(sqlc.narg('quick_tips')::text, quick_tips),
|
||||
publish_status = coalesce(sqlc.narg('publish_status')::varchar, publish_status),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = sqlc.arg('id')
|
||||
RETURNING *;
|
||||
|
|
|
|||
205
db/query/lms_admin_activity.sql
Normal file
205
db/query/lms_admin_activity.sql
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
-- Aggregated LMS learning activity for admin: completed lessons and completed practices
|
||||
-- (rollup tables + lesson/practice completion are the persisted signals in this schema).
|
||||
|
||||
-- name: ListUserLMSFlatLearningActivityByUser :many
|
||||
SELECT
|
||||
x.activity_kind,
|
||||
x.program_id,
|
||||
x.program_name,
|
||||
x.program_sort_order,
|
||||
x.program_completed_at,
|
||||
x.course_id,
|
||||
x.course_name,
|
||||
x.course_sort_order,
|
||||
x.course_completed_at,
|
||||
COALESCE(x.module_id, 0)::BIGINT AS module_id,
|
||||
COALESCE(x.module_name, '')::TEXT AS module_name,
|
||||
COALESCE(x.module_sort_order, 0)::INT AS module_sort_order,
|
||||
x.module_completed_at,
|
||||
COALESCE(x.lesson_id, 0)::BIGINT AS lesson_id,
|
||||
COALESCE(x.lesson_title, '')::TEXT AS lesson_title,
|
||||
COALESCE(x.lesson_sort_order, 0)::INT AS lesson_sort_order,
|
||||
x.lesson_completed_at,
|
||||
COALESCE(x.lms_practice_id, 0)::BIGINT AS lms_practice_id,
|
||||
COALESCE(x.practice_title, '')::TEXT AS practice_title,
|
||||
x.activity_at
|
||||
FROM (
|
||||
SELECT
|
||||
'lesson'::TEXT AS activity_kind,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name,
|
||||
p.sort_order AS program_sort_order,
|
||||
prf.completed_at AS program_completed_at,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
c.sort_order AS course_sort_order,
|
||||
crf.completed_at AS course_completed_at,
|
||||
m.id AS module_id,
|
||||
m.name AS module_name,
|
||||
m.sort_order AS module_sort_order,
|
||||
mrf.completed_at AS module_completed_at,
|
||||
l.id AS lesson_id,
|
||||
l.title AS lesson_title,
|
||||
l.sort_order AS lesson_sort_order,
|
||||
ulp.completed_at AS lesson_completed_at,
|
||||
NULL::BIGINT AS lms_practice_id,
|
||||
NULL::VARCHAR AS practice_title,
|
||||
ulp.completed_at AS activity_at
|
||||
FROM
|
||||
lms_user_lesson_progress ulp
|
||||
INNER JOIN lessons l ON l.id = ulp.lesson_id
|
||||
INNER JOIN modules m ON m.id = l.module_id
|
||||
INNER JOIN courses c ON c.id = m.course_id
|
||||
AND c.program_id = m.program_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
LEFT JOIN lms_user_program_progress prf ON prf.user_id = ulp.user_id
|
||||
AND prf.program_id = p.id
|
||||
LEFT JOIN lms_user_course_progress crf ON crf.user_id = ulp.user_id
|
||||
AND crf.course_id = c.id
|
||||
LEFT JOIN lms_user_module_progress mrf ON mrf.user_id = ulp.user_id
|
||||
AND mrf.module_id = m.id
|
||||
WHERE
|
||||
ulp.user_id = $1
|
||||
UNION ALL
|
||||
SELECT
|
||||
'practice'::TEXT,
|
||||
p.id,
|
||||
p.name,
|
||||
p.sort_order,
|
||||
prf.completed_at,
|
||||
c.id,
|
||||
c.name,
|
||||
c.sort_order,
|
||||
crf.completed_at,
|
||||
m.id,
|
||||
m.name,
|
||||
m.sort_order,
|
||||
mrf.completed_at,
|
||||
l.id,
|
||||
l.title,
|
||||
l.sort_order,
|
||||
lucomp.completed_at,
|
||||
lp.id,
|
||||
lp.title,
|
||||
upp.completed_at
|
||||
FROM
|
||||
user_practice_progress upp
|
||||
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
|
||||
AND lp.lesson_id IS NOT NULL
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
INNER JOIN lessons l ON l.id = lp.lesson_id
|
||||
INNER JOIN modules m ON m.id = l.module_id
|
||||
INNER JOIN courses c ON c.id = m.course_id
|
||||
AND c.program_id = m.program_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
LEFT JOIN lms_user_program_progress prf ON prf.user_id = upp.user_id
|
||||
AND prf.program_id = p.id
|
||||
LEFT JOIN lms_user_course_progress crf ON crf.user_id = upp.user_id
|
||||
AND crf.course_id = c.id
|
||||
LEFT JOIN lms_user_module_progress mrf ON mrf.user_id = upp.user_id
|
||||
AND mrf.module_id = m.id
|
||||
LEFT JOIN lms_user_lesson_progress lucomp ON lucomp.user_id = upp.user_id
|
||||
AND lucomp.lesson_id = l.id
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
'practice'::TEXT,
|
||||
p.id,
|
||||
p.name,
|
||||
p.sort_order,
|
||||
prf.completed_at,
|
||||
c.id,
|
||||
c.name,
|
||||
c.sort_order,
|
||||
crf.completed_at,
|
||||
m.id,
|
||||
m.name,
|
||||
m.sort_order,
|
||||
mrf.completed_at,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
NULL::INT,
|
||||
NULL::TIMESTAMPTZ,
|
||||
lp.id,
|
||||
lp.title,
|
||||
upp.completed_at
|
||||
FROM
|
||||
user_practice_progress upp
|
||||
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
|
||||
AND lp.module_id IS NOT NULL
|
||||
AND lp.lesson_id IS NULL
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
INNER JOIN modules m ON m.id = lp.module_id
|
||||
INNER JOIN courses c ON c.id = m.course_id
|
||||
AND c.program_id = m.program_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
LEFT JOIN lms_user_program_progress prf ON prf.user_id = upp.user_id
|
||||
AND prf.program_id = p.id
|
||||
LEFT JOIN lms_user_course_progress crf ON crf.user_id = upp.user_id
|
||||
AND crf.course_id = c.id
|
||||
LEFT JOIN lms_user_module_progress mrf ON mrf.user_id = upp.user_id
|
||||
AND mrf.module_id = m.id
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
'practice'::TEXT,
|
||||
p.id,
|
||||
p.name,
|
||||
p.sort_order,
|
||||
prf.completed_at,
|
||||
c.id,
|
||||
c.name,
|
||||
c.sort_order,
|
||||
crf.completed_at,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
NULL::INT,
|
||||
NULL::TIMESTAMPTZ,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
NULL::INT,
|
||||
NULL::TIMESTAMPTZ,
|
||||
lp.id,
|
||||
lp.title,
|
||||
upp.completed_at
|
||||
FROM
|
||||
user_practice_progress upp
|
||||
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
|
||||
AND lp.course_id IS NOT NULL
|
||||
AND lp.module_id IS NULL
|
||||
AND lp.lesson_id IS NULL
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
INNER JOIN courses c ON c.id = lp.course_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
LEFT JOIN lms_user_program_progress prf ON prf.user_id = upp.user_id
|
||||
AND prf.program_id = p.id
|
||||
LEFT JOIN lms_user_course_progress crf ON crf.user_id = upp.user_id
|
||||
AND crf.course_id = c.id
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
) AS x
|
||||
ORDER BY
|
||||
x.program_sort_order,
|
||||
x.program_id,
|
||||
x.course_sort_order,
|
||||
x.course_id,
|
||||
x.module_sort_order NULLS LAST,
|
||||
x.module_id NULLS LAST,
|
||||
x.lesson_sort_order NULLS LAST,
|
||||
x.lesson_id NULLS LAST,
|
||||
x.activity_kind,
|
||||
x.activity_at;
|
||||
|
|
@ -1,16 +1,17 @@
|
|||
-- name: CreateCourse :one
|
||||
INSERT INTO courses (program_id, name, description, thumbnail, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
coalesce((
|
||||
sqlc.arg('program_id'),
|
||||
sqlc.arg('name'),
|
||||
sqlc.arg('description'),
|
||||
sqlc.arg('thumbnail'),
|
||||
COALESCE(sqlc.narg('sort_order')::int,
|
||||
COALESCE((
|
||||
SELECT
|
||||
max(c.sort_order)
|
||||
FROM courses c
|
||||
WHERE
|
||||
c.program_id = $1), 0) + 1
|
||||
c.program_id = sqlc.arg('program_id')), 0) + 1)
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ SELECT
|
|||
WHERE p.course_id = c.id
|
||||
AND p.module_id IS NULL
|
||||
AND p.lesson_id IS NULL
|
||||
AND p.publish_status = 'PUBLISHED'
|
||||
) AS has_practice
|
||||
FROM courses
|
||||
c
|
||||
|
|
@ -74,13 +76,15 @@ SELECT
|
|||
WHERE
|
||||
p.course_id = c.id
|
||||
AND p.module_id IS NULL
|
||||
AND p.lesson_id IS NULL) AS practice_count,
|
||||
AND p.lesson_id IS NULL
|
||||
AND p.publish_status = 'PUBLISHED') AS practice_count,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.course_id = c.id
|
||||
AND p.module_id IS NULL
|
||||
AND p.lesson_id IS NULL
|
||||
AND p.publish_status = 'PUBLISHED'
|
||||
) AS has_practice
|
||||
FROM
|
||||
courses c
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ SELECT
|
|||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.lesson_id = l.id
|
||||
AND p.publish_status = 'PUBLISHED'
|
||||
) AS has_practice
|
||||
FROM lessons
|
||||
l
|
||||
|
|
@ -43,6 +44,7 @@ SELECT
|
|||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.lesson_id = l.id
|
||||
AND p.publish_status = 'PUBLISHED'
|
||||
) AS has_practice
|
||||
FROM
|
||||
lessons l
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
-- name: CreateModule :one
|
||||
INSERT INTO modules (program_id, course_id, name, description, icon, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
coalesce((
|
||||
sqlc.arg('program_id'),
|
||||
sqlc.arg('course_id'),
|
||||
sqlc.arg('name'),
|
||||
sqlc.arg('description'),
|
||||
sqlc.arg('icon'),
|
||||
COALESCE(sqlc.narg('sort_order')::int,
|
||||
COALESCE((
|
||||
SELECT
|
||||
max(m.sort_order)
|
||||
FROM modules m
|
||||
WHERE
|
||||
m.course_id = $2), 0) + 1
|
||||
m.course_id = sqlc.arg('course_id')), 0) + 1)
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ SELECT
|
|||
FROM lms_practices p
|
||||
WHERE p.module_id = m.id
|
||||
AND p.lesson_id IS NULL
|
||||
AND p.publish_status = 'PUBLISHED'
|
||||
) AS has_practice
|
||||
FROM modules
|
||||
m
|
||||
|
|
@ -55,6 +57,7 @@ SELECT
|
|||
FROM lms_practices p
|
||||
WHERE p.module_id = m.id
|
||||
AND p.lesson_id IS NULL
|
||||
AND p.publish_status = 'PUBLISHED'
|
||||
) AS has_practice
|
||||
FROM
|
||||
modules m
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
-- name: CreateLmsPractice :one
|
||||
INSERT INTO lms_practices (
|
||||
course_id, module_id, lesson_id,
|
||||
title, story_description, story_image, persona_id, question_set_id, quick_tips
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
title, story_description, story_image, persona_id, question_set_id, quick_tips, publish_status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetLmsPracticeByID :one
|
||||
|
|
@ -10,6 +10,13 @@ SELECT *
|
|||
FROM lms_practices
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetLmsPracticeByQuestionSetID :one
|
||||
SELECT *
|
||||
FROM lms_practices
|
||||
WHERE question_set_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: ListLmsPracticesByCourseID :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
|
|
@ -23,10 +30,15 @@ SELECT
|
|||
p.persona_id,
|
||||
p.question_set_id,
|
||||
p.quick_tips,
|
||||
p.publish_status,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM lms_practices p
|
||||
WHERE p.course_id = $1
|
||||
AND (
|
||||
sqlc.arg('published_only')::boolean = FALSE
|
||||
OR p.publish_status = 'PUBLISHED'::TEXT
|
||||
)
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
|
|
@ -43,10 +55,15 @@ SELECT
|
|||
p.persona_id,
|
||||
p.question_set_id,
|
||||
p.quick_tips,
|
||||
p.publish_status,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM lms_practices p
|
||||
WHERE p.module_id = $1
|
||||
AND (
|
||||
sqlc.arg('published_only')::boolean = FALSE
|
||||
OR p.publish_status = 'PUBLISHED'::TEXT
|
||||
)
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
|
|
@ -63,10 +80,15 @@ SELECT
|
|||
p.persona_id,
|
||||
p.question_set_id,
|
||||
p.quick_tips,
|
||||
p.publish_status,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM lms_practices p
|
||||
WHERE p.lesson_id = $1
|
||||
AND (
|
||||
sqlc.arg('published_only')::boolean = FALSE
|
||||
OR p.publish_status = 'PUBLISHED'::TEXT
|
||||
)
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
|
|
@ -79,6 +101,7 @@ SET
|
|||
persona_id = COALESCE(sqlc.narg('persona_id')::bigint, persona_id),
|
||||
question_set_id = COALESCE(sqlc.narg('question_set_id')::bigint, question_set_id),
|
||||
quick_tips = COALESCE(sqlc.narg('quick_tips')::text, quick_tips),
|
||||
publish_status = COALESCE(sqlc.narg('publish_status')::varchar, publish_status),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = sqlc.arg('id')
|
||||
RETURNING *;
|
||||
|
|
|
|||
|
|
@ -257,7 +257,8 @@ FROM
|
|||
WHERE
|
||||
lp.module_id = $1
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED';
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED';
|
||||
|
||||
-- name: CountUserCompletedPublishedPracticesInModule :one
|
||||
SELECT
|
||||
|
|
@ -271,7 +272,8 @@ WHERE
|
|||
AND upp.user_id = $2
|
||||
AND upp.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED';
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED';
|
||||
|
||||
-- name: CountPublishedPracticesInCourse :one
|
||||
SELECT
|
||||
|
|
@ -282,7 +284,8 @@ FROM
|
|||
WHERE
|
||||
lp.course_id = $1
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED';
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED';
|
||||
|
||||
-- name: CountUserCompletedPublishedPracticesInCourse :one
|
||||
SELECT
|
||||
|
|
@ -308,7 +311,8 @@ FROM
|
|||
WHERE
|
||||
c.program_id = $1
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED';
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED';
|
||||
|
||||
-- name: CountUserCompletedPublishedPracticesInProgram :one
|
||||
SELECT
|
||||
|
|
@ -323,7 +327,8 @@ WHERE
|
|||
AND upp.user_id = $2
|
||||
AND upp.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED';
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED';
|
||||
|
||||
-- name: GetPracticeScopeByQuestionSetID :one
|
||||
SELECT
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
-- name: CreateProgram :one
|
||||
INSERT INTO programs (name, description, thumbnail, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
coalesce((
|
||||
sqlc.arg('name'),
|
||||
sqlc.arg('description'),
|
||||
sqlc.arg('thumbnail'),
|
||||
COALESCE(sqlc.narg('sort_order')::int, COALESCE((
|
||||
SELECT
|
||||
max(p.sort_order)
|
||||
FROM programs AS p), 0) + 1
|
||||
FROM programs AS p), 0) + 1)
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
|
|
|
|||
|
|
@ -61,28 +61,38 @@ FROM user_subscriptions us
|
|||
JOIN subscription_plans sp ON sp.id = us.plan_id
|
||||
WHERE us.id = $1;
|
||||
|
||||
-- name: ListActiveSubscriptionsByUserIDs :many
|
||||
-- One ACTIVE, non-expired row per user (latest expires_at wins), same rules as GetActiveSubscriptionByUserID.
|
||||
SELECT DISTINCT ON (us.user_id)
|
||||
us.user_id,
|
||||
us.id,
|
||||
us.plan_id,
|
||||
us.starts_at,
|
||||
us.expires_at,
|
||||
us.status,
|
||||
us.auto_renew,
|
||||
us.payment_method,
|
||||
sp.name AS plan_name,
|
||||
sp.duration_value,
|
||||
sp.duration_unit,
|
||||
sp.price,
|
||||
sp.currency
|
||||
FROM user_subscriptions us
|
||||
JOIN subscription_plans sp ON sp.id = us.plan_id
|
||||
WHERE us.user_id = ANY($1::bigint[])
|
||||
AND us.status = 'ACTIVE'
|
||||
AND us.expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY us.user_id, us.expires_at DESC;
|
||||
-- Display status for admin user lists: ACTIVE (non-expired), else latest PENDING, else Unsubscribed.
|
||||
-- name: ListSubscriptionDisplayStatusesByUserIDs :many
|
||||
WITH input AS (
|
||||
SELECT unnest($1::bigint[])::bigint AS user_id
|
||||
)
|
||||
SELECT
|
||||
input.user_id,
|
||||
COALESCE(
|
||||
(SELECT us.status::text FROM user_subscriptions us
|
||||
WHERE us.user_id = input.user_id
|
||||
AND us.status = 'ACTIVE' AND us.expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY us.expires_at DESC LIMIT 1),
|
||||
(SELECT us.status::text FROM user_subscriptions us
|
||||
WHERE us.user_id = input.user_id
|
||||
AND us.status = 'PENDING'
|
||||
ORDER BY us.created_at DESC LIMIT 1),
|
||||
'Unsubscribed'
|
||||
)::text AS subscription_status
|
||||
FROM input;
|
||||
|
||||
-- name: GetSubscriptionDisplayStatusByUserID :one
|
||||
SELECT COALESCE(
|
||||
(SELECT us.status::text FROM user_subscriptions us
|
||||
WHERE us.user_id = $1
|
||||
AND us.status = 'ACTIVE' AND us.expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY us.expires_at DESC LIMIT 1),
|
||||
(SELECT us.status::text FROM user_subscriptions us
|
||||
WHERE us.user_id = $1
|
||||
AND us.status = 'PENDING'
|
||||
ORDER BY us.created_at DESC LIMIT 1),
|
||||
'Unsubscribed'
|
||||
)::text AS subscription_status;
|
||||
|
||||
-- name: GetActiveSubscriptionByUserID :one
|
||||
SELECT
|
||||
|
|
|
|||
|
|
@ -140,6 +140,32 @@ WHERE id = $1;
|
|||
DELETE FROM team_members
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: BulkDeactivateTeamMembersByRole :execrows
|
||||
UPDATE team_members
|
||||
SET
|
||||
status = 'inactive',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE
|
||||
team_role = $1
|
||||
AND (
|
||||
sqlc.narg('exclude_team_member_id')::BIGINT IS NULL
|
||||
OR id <> sqlc.narg('exclude_team_member_id')::BIGINT
|
||||
)
|
||||
AND status = 'active';
|
||||
|
||||
-- name: BulkReactivateTeamMembersByRole :execrows
|
||||
UPDATE team_members
|
||||
SET
|
||||
status = 'active',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE
|
||||
team_role = $1
|
||||
AND (
|
||||
sqlc.narg('exclude_team_member_id')::BIGINT IS NULL
|
||||
OR id <> sqlc.narg('exclude_team_member_id')::BIGINT
|
||||
)
|
||||
AND status = 'inactive';
|
||||
|
||||
-- name: CheckTeamMemberEmailExists :one
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM team_members WHERE email = $1
|
||||
|
|
|
|||
|
|
@ -141,6 +141,11 @@ RETURNING
|
|||
updated_at;
|
||||
|
||||
|
||||
-- name: GetUserCreatedAt :one
|
||||
SELECT created_at
|
||||
FROM users
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetUserByID :one
|
||||
SELECT *
|
||||
FROM users
|
||||
|
|
@ -196,6 +201,46 @@ WHERE
|
|||
))
|
||||
AND (sqlc.narg('created_after')::TIMESTAMPTZ IS NULL OR created_at >= sqlc.narg('created_after')::TIMESTAMPTZ)
|
||||
AND (sqlc.narg('created_before')::TIMESTAMPTZ IS NULL OR created_at <= sqlc.narg('created_before')::TIMESTAMPTZ)
|
||||
AND (sqlc.narg('country')::TEXT IS NULL OR LOWER(TRIM(COALESCE(country, ''))) = LOWER(TRIM(sqlc.narg('country')::TEXT)))
|
||||
AND (sqlc.narg('region')::TEXT IS NULL OR LOWER(TRIM(COALESCE(region, ''))) = LOWER(TRIM(sqlc.narg('region')::TEXT)))
|
||||
AND (
|
||||
sqlc.narg('subscription_status')::TEXT IS NULL
|
||||
OR (
|
||||
sqlc.narg('subscription_status')::TEXT = 'ACTIVE'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM user_subscriptions us
|
||||
WHERE us.user_id = users.id
|
||||
AND us.status = 'ACTIVE'
|
||||
AND us.expires_at > CURRENT_TIMESTAMP
|
||||
)
|
||||
)
|
||||
OR (
|
||||
sqlc.narg('subscription_status')::TEXT = 'PENDING'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_subscriptions us
|
||||
WHERE us.user_id = users.id
|
||||
AND us.status = 'ACTIVE'
|
||||
AND us.expires_at > CURRENT_TIMESTAMP
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM user_subscriptions us
|
||||
WHERE us.user_id = users.id AND us.status = 'PENDING'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
sqlc.narg('subscription_status')::TEXT = 'Unsubscribed'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_subscriptions us
|
||||
WHERE us.user_id = users.id
|
||||
AND us.status = 'ACTIVE'
|
||||
AND us.expires_at > CURRENT_TIMESTAMP
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_subscriptions us
|
||||
WHERE us.user_id = users.id AND us.status = 'PENDING'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT sqlc.narg('limit')::INT
|
||||
OFFSET sqlc.narg('offset')::INT;
|
||||
|
|
@ -376,6 +421,26 @@ SET
|
|||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2;
|
||||
|
||||
-- name: BulkDeactivateUsersByRole :execrows
|
||||
UPDATE users
|
||||
SET
|
||||
status = 'DEACTIVATED',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE
|
||||
role = $1
|
||||
AND id <> $2
|
||||
AND status <> 'DEACTIVATED';
|
||||
|
||||
-- name: BulkReactivateUsersByRole :execrows
|
||||
UPDATE users
|
||||
SET
|
||||
status = 'ACTIVE',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE
|
||||
role = $1
|
||||
AND id <> $2
|
||||
AND status = 'DEACTIVATED';
|
||||
|
||||
-- name: GetUserSummary :one
|
||||
SELECT
|
||||
COUNT(*) AS total_users,
|
||||
|
|
|
|||
176
db/query/user_recent_activity.sql
Normal file
176
db/query/user_recent_activity.sql
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
-- Recent activity feed: LMS completion milestones (chronological merge in application code).
|
||||
|
||||
-- name: ListUserLessonCompletionsRecentActivity :many
|
||||
SELECT
|
||||
ulp.completed_at AS occurred_at,
|
||||
l.id AS lesson_id,
|
||||
l.title AS lesson_title,
|
||||
m.id AS module_id,
|
||||
m.name AS module_name,
|
||||
m.sort_order AS module_sort_order,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
lms_user_lesson_progress ulp
|
||||
INNER JOIN lessons l ON l.id = ulp.lesson_id
|
||||
INNER JOIN modules m ON m.id = l.module_id
|
||||
INNER JOIN courses c ON c.id = m.course_id
|
||||
AND c.program_id = m.program_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
WHERE
|
||||
ulp.user_id = $1;
|
||||
|
||||
-- name: ListUserModuleCompletionsRecentActivity :many
|
||||
SELECT
|
||||
mrf.completed_at AS occurred_at,
|
||||
m.id AS module_id,
|
||||
m.name AS module_name,
|
||||
m.sort_order AS module_sort_order,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
lms_user_module_progress mrf
|
||||
INNER JOIN modules m ON m.id = mrf.module_id
|
||||
INNER JOIN courses c ON c.id = m.course_id
|
||||
AND c.program_id = m.program_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
WHERE
|
||||
mrf.user_id = $1;
|
||||
|
||||
-- name: ListUserCourseCompletionsRecentActivity :many
|
||||
SELECT
|
||||
crf.completed_at AS occurred_at,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
lms_user_course_progress crf
|
||||
INNER JOIN courses c ON c.id = crf.course_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
WHERE
|
||||
crf.user_id = $1;
|
||||
|
||||
-- name: ListUserProgramCompletionsRecentActivity :many
|
||||
SELECT
|
||||
prf.completed_at AS occurred_at,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
lms_user_program_progress prf
|
||||
INNER JOIN programs p ON p.id = prf.program_id
|
||||
WHERE
|
||||
prf.user_id = $1;
|
||||
|
||||
-- name: ListUserPracticeCompletionsRecentActivity :many
|
||||
SELECT
|
||||
x.occurred_at,
|
||||
x.scope,
|
||||
x.lms_practice_id,
|
||||
x.practice_title,
|
||||
COALESCE(x.lesson_id, 0)::BIGINT AS lesson_id,
|
||||
COALESCE(x.lesson_title, '')::TEXT AS lesson_title,
|
||||
COALESCE(x.module_id, 0)::BIGINT AS module_id,
|
||||
COALESCE(x.module_name, '')::TEXT AS module_name,
|
||||
COALESCE(x.module_sort_order, 0)::INT AS module_sort_order,
|
||||
x.course_id,
|
||||
x.course_name,
|
||||
x.program_id,
|
||||
x.program_name
|
||||
FROM (
|
||||
SELECT
|
||||
upp.completed_at AS occurred_at,
|
||||
'lesson'::TEXT AS scope,
|
||||
lp.id AS lms_practice_id,
|
||||
lp.title AS practice_title,
|
||||
l.id AS lesson_id,
|
||||
l.title AS lesson_title,
|
||||
m.id AS module_id,
|
||||
m.name AS module_name,
|
||||
m.sort_order AS module_sort_order,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
user_practice_progress upp
|
||||
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
|
||||
AND lp.lesson_id IS NOT NULL
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
INNER JOIN lessons l ON l.id = lp.lesson_id
|
||||
INNER JOIN modules m ON m.id = l.module_id
|
||||
INNER JOIN courses c ON c.id = m.course_id
|
||||
AND c.program_id = m.program_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
upp.completed_at,
|
||||
'module'::TEXT,
|
||||
lp.id,
|
||||
lp.title,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
m.id,
|
||||
m.name,
|
||||
m.sort_order,
|
||||
c.id,
|
||||
c.name,
|
||||
p.id,
|
||||
p.name
|
||||
FROM
|
||||
user_practice_progress upp
|
||||
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
|
||||
AND lp.module_id IS NOT NULL
|
||||
AND lp.lesson_id IS NULL
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
INNER JOIN modules m ON m.id = lp.module_id
|
||||
INNER JOIN courses c ON c.id = m.course_id
|
||||
AND c.program_id = m.program_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
upp.completed_at,
|
||||
'course'::TEXT,
|
||||
lp.id,
|
||||
lp.title,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
NULL::INT,
|
||||
c.id,
|
||||
c.name,
|
||||
p.id,
|
||||
p.name
|
||||
FROM
|
||||
user_practice_progress upp
|
||||
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
|
||||
AND lp.course_id IS NOT NULL
|
||||
AND lp.module_id IS NULL
|
||||
AND lp.lesson_id IS NULL
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
INNER JOIN courses c ON c.id = lp.course_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
) AS x;
|
||||
|
|
@ -415,6 +415,8 @@ This creates the practice record scoped to lesson.
|
|||
|
||||
### Request
|
||||
|
||||
Include `publish_status`: `DRAFT` to hide the practice from subscribed learners until you set it to `PUBLISHED` (via create or `PUT /practices/:id`). Omit the field or send `PUBLISHED` to go live immediately (backward compatible).
|
||||
|
||||
```json
|
||||
{
|
||||
"parent_kind": "LESSON",
|
||||
|
|
@ -423,7 +425,8 @@ This creates the practice record scoped to lesson.
|
|||
"story_description": "A short two-speaker scenario.",
|
||||
"story_image": "https://cdn.example.com/images/story.webp",
|
||||
"question_set_id": 55,
|
||||
"quick_tips": "Listen carefully before answering."
|
||||
"quick_tips": "Listen carefully before answering.",
|
||||
"publish_status": "DRAFT"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
341
docs/docs.go
341
docs/docs.go
|
|
@ -505,6 +505,144 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/roles/{role}/bulk-deactivate": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Sets all platform users with the given users.role to DEACTIVATED (except the caller) and all team_members with the given team_role to inactive. Path :role may be a role key (e.g. INSTRUCTOR, ADMIN) or a decimal RBAC roles.id from GET /api/v1/rbac/roles (resolved to RoleRecord.name uppercased). SUPER_ADMIN cannot be bulk-deactivated. ADMIN platform users must use SUPER_ADMIN to bulk change other platform ADMIN users (team_members with team_role ADMIN under path ADMIN remain allowed). Empty body allowed; optionally pass exclude_team_member_id to skip one team_members row (e.g. yourself).",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "Bulk deactivate accounts by role (SUPER_ADMIN or ADMIN platform users only)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Role key (INSTRUCTOR etc.) or RBAC roles.id (integer string)",
|
||||
"name": "role",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Optional exclusions",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.BulkAccountsByRoleRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"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/admin/roles/{role}/bulk-reactivate": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Sets all platform users with the given role from DEACTIVATED to ACTIVE (except the caller) and all team_members with the given team_role from inactive to active. Path :role may be a role key or decimal RBAC roles.id (see bulk-deactivate). Path role must correspond to valid platform users.role or team_members.team_role (after resolving id → name). SUPER_ADMIN cannot be bulk changed. ADMIN callers cannot bulk change other platform ADMIN users (team_members ADMIN under path ADMIN is allowed). Matches only users currently DEACTIVATED and team rows currently inactive.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "Bulk reactivate accounts by role (SUPER_ADMIN or ADMIN platform users only)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Role key (INSTRUCTOR etc.) or RBAC roles.id (integer string)",
|
||||
"name": "role",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Optional exclusions",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.BulkAccountsByRoleRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"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/admin/users/deletion-requests": {
|
||||
"get": {
|
||||
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination",
|
||||
|
|
@ -602,6 +740,116 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{user_id}/lms-learning-activity": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Returns programs, courses, modules, and lessons with completion details and completed practices. Only persisted completion signals are included (completed lessons, completed published practices, and rollup completion timestamps—not partial or in-progress attempts).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"lms"
|
||||
],
|
||||
"summary": "Get a user's nested LMS learning activity (admin)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Target user ID",
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{user_id}/recent-activity": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Reverse-chronological feed for profile UI: account joined plus LMS completion milestones (lessons/modules/courses/programs). Optional practice completions via include_practices. Does not include \"started learning path\" unless you add persisted engagement events—the schema stores completions only.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"lms"
|
||||
],
|
||||
"summary": "Recent activity timeline for a user (admin)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Target user ID",
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Max items after merge (default 40, max 120)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Include completed LMS practices (more verbose)",
|
||||
"name": "include_practices",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"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/admin/{id}": {
|
||||
"get": {
|
||||
"description": "Get a single admin by id",
|
||||
|
|
@ -3902,7 +4150,7 @@ const docTemplate = `{
|
|||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Create a top-level LMS program",
|
||||
"description": "Create a top-level LMS program. Optional sort_order inserts at that global ordering; omit it to append after the current highest sort_order. Unique constraint applies to sort_order.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -4147,7 +4395,7 @@ const docTemplate = `{
|
|||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Create a course under a program",
|
||||
"description": "Create a course under a program. Optional sort_order assigns position within that program (siblings shifted); omit to append after the current highest sort_order in the program.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -8436,7 +8684,7 @@ const docTemplate = `{
|
|||
},
|
||||
"/api/v1/users": {
|
||||
"get": {
|
||||
"description": "Get users with optional filters. Each user may include active_subscription when they have a current ACTIVE, non-expired plan.",
|
||||
"description": "Get users with optional filters. Each user includes subscription_status: ACTIVE, PENDING, or Unsubscribed.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -8486,9 +8734,27 @@ const docTemplate = `{
|
|||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)",
|
||||
"description": "User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)",
|
||||
"name": "status",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Country filter (case-insensitive match on stored value)",
|
||||
"name": "country",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Region filter (case-insensitive match on stored value)",
|
||||
"name": "region",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Derived subscription filter: ACTIVE, PENDING, or Unsubscribed (matches response subscription_status semantics)",
|
||||
"name": "subscription_status",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
|
@ -10078,6 +10344,14 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"domain.BulkAccountsByRoleRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"exclude_team_member_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.CreateCourseInput": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
@ -10090,6 +10364,11 @@ const docTemplate = `{
|
|||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"description": "SortOrder within the program when set; omit to append after current max within program_id (uniqueness is per-program).",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
@ -10289,6 +10568,11 @@ const docTemplate = `{
|
|||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"description": "SortOrder inserts at this global program order when set; omit to append after current max (sort_order uniqueness is enforced).",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
@ -10779,6 +11063,7 @@ const docTemplate = `{
|
|||
"SUPER_ADMIN",
|
||||
"ADMIN",
|
||||
"STUDENT",
|
||||
"OPEN_LEARNER",
|
||||
"INSTRUCTOR",
|
||||
"SUPPORT"
|
||||
],
|
||||
|
|
@ -10786,6 +11071,7 @@ const docTemplate = `{
|
|||
"RoleSuperAdmin",
|
||||
"RoleAdmin",
|
||||
"RoleStudent",
|
||||
"RoleOpenLearner",
|
||||
"RoleInstructor",
|
||||
"RoleSupport"
|
||||
]
|
||||
|
|
@ -11294,9 +11580,6 @@ const docTemplate = `{
|
|||
"domain.UserProfileResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_subscription": {
|
||||
"$ref": "#/definitions/domain.UserSubscriptionSummary"
|
||||
},
|
||||
"age_group": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -11384,6 +11667,9 @@ const docTemplate = `{
|
|||
"status": {
|
||||
"$ref": "#/definitions/domain.UserStatus"
|
||||
},
|
||||
"subscription_status": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
@ -11404,47 +11690,6 @@ const docTemplate = `{
|
|||
"UserStatusDeactivated"
|
||||
]
|
||||
},
|
||||
"domain.UserSubscriptionSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auto_renew": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"currency": {
|
||||
"type": "string"
|
||||
},
|
||||
"duration_unit": {
|
||||
"type": "string"
|
||||
},
|
||||
"duration_value": {
|
||||
"type": "integer"
|
||||
},
|
||||
"expires_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"payment_method": {
|
||||
"type": "string"
|
||||
},
|
||||
"plan_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"plan_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"price": {
|
||||
"type": "number"
|
||||
},
|
||||
"starts_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.UserSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -497,6 +497,144 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/roles/{role}/bulk-deactivate": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Sets all platform users with the given users.role to DEACTIVATED (except the caller) and all team_members with the given team_role to inactive. Path :role may be a role key (e.g. INSTRUCTOR, ADMIN) or a decimal RBAC roles.id from GET /api/v1/rbac/roles (resolved to RoleRecord.name uppercased). SUPER_ADMIN cannot be bulk-deactivated. ADMIN platform users must use SUPER_ADMIN to bulk change other platform ADMIN users (team_members with team_role ADMIN under path ADMIN remain allowed). Empty body allowed; optionally pass exclude_team_member_id to skip one team_members row (e.g. yourself).",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "Bulk deactivate accounts by role (SUPER_ADMIN or ADMIN platform users only)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Role key (INSTRUCTOR etc.) or RBAC roles.id (integer string)",
|
||||
"name": "role",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Optional exclusions",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.BulkAccountsByRoleRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"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/admin/roles/{role}/bulk-reactivate": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Sets all platform users with the given role from DEACTIVATED to ACTIVE (except the caller) and all team_members with the given team_role from inactive to active. Path :role may be a role key or decimal RBAC roles.id (see bulk-deactivate). Path role must correspond to valid platform users.role or team_members.team_role (after resolving id → name). SUPER_ADMIN cannot be bulk changed. ADMIN callers cannot bulk change other platform ADMIN users (team_members ADMIN under path ADMIN is allowed). Matches only users currently DEACTIVATED and team rows currently inactive.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "Bulk reactivate accounts by role (SUPER_ADMIN or ADMIN platform users only)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Role key (INSTRUCTOR etc.) or RBAC roles.id (integer string)",
|
||||
"name": "role",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Optional exclusions",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.BulkAccountsByRoleRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"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/admin/users/deletion-requests": {
|
||||
"get": {
|
||||
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination",
|
||||
|
|
@ -594,6 +732,116 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{user_id}/lms-learning-activity": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Returns programs, courses, modules, and lessons with completion details and completed practices. Only persisted completion signals are included (completed lessons, completed published practices, and rollup completion timestamps—not partial or in-progress attempts).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"lms"
|
||||
],
|
||||
"summary": "Get a user's nested LMS learning activity (admin)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Target user ID",
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{user_id}/recent-activity": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Reverse-chronological feed for profile UI: account joined plus LMS completion milestones (lessons/modules/courses/programs). Optional practice completions via include_practices. Does not include \"started learning path\" unless you add persisted engagement events—the schema stores completions only.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"lms"
|
||||
],
|
||||
"summary": "Recent activity timeline for a user (admin)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Target user ID",
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Max items after merge (default 40, max 120)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Include completed LMS practices (more verbose)",
|
||||
"name": "include_practices",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"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/admin/{id}": {
|
||||
"get": {
|
||||
"description": "Get a single admin by id",
|
||||
|
|
@ -3894,7 +4142,7 @@
|
|||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Create a top-level LMS program",
|
||||
"description": "Create a top-level LMS program. Optional sort_order inserts at that global ordering; omit it to append after the current highest sort_order. Unique constraint applies to sort_order.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -4139,7 +4387,7 @@
|
|||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Create a course under a program",
|
||||
"description": "Create a course under a program. Optional sort_order assigns position within that program (siblings shifted); omit to append after the current highest sort_order in the program.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -8428,7 +8676,7 @@
|
|||
},
|
||||
"/api/v1/users": {
|
||||
"get": {
|
||||
"description": "Get users with optional filters. Each user may include active_subscription when they have a current ACTIVE, non-expired plan.",
|
||||
"description": "Get users with optional filters. Each user includes subscription_status: ACTIVE, PENDING, or Unsubscribed.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -8478,9 +8726,27 @@
|
|||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)",
|
||||
"description": "User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)",
|
||||
"name": "status",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Country filter (case-insensitive match on stored value)",
|
||||
"name": "country",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Region filter (case-insensitive match on stored value)",
|
||||
"name": "region",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Derived subscription filter: ACTIVE, PENDING, or Unsubscribed (matches response subscription_status semantics)",
|
||||
"name": "subscription_status",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
|
@ -10070,6 +10336,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"domain.BulkAccountsByRoleRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"exclude_team_member_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.CreateCourseInput": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
@ -10082,6 +10356,11 @@
|
|||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"description": "SortOrder within the program when set; omit to append after current max within program_id (uniqueness is per-program).",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
@ -10281,6 +10560,11 @@
|
|||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"description": "SortOrder inserts at this global program order when set; omit to append after current max (sort_order uniqueness is enforced).",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
@ -10771,6 +11055,7 @@
|
|||
"SUPER_ADMIN",
|
||||
"ADMIN",
|
||||
"STUDENT",
|
||||
"OPEN_LEARNER",
|
||||
"INSTRUCTOR",
|
||||
"SUPPORT"
|
||||
],
|
||||
|
|
@ -10778,6 +11063,7 @@
|
|||
"RoleSuperAdmin",
|
||||
"RoleAdmin",
|
||||
"RoleStudent",
|
||||
"RoleOpenLearner",
|
||||
"RoleInstructor",
|
||||
"RoleSupport"
|
||||
]
|
||||
|
|
@ -11286,9 +11572,6 @@
|
|||
"domain.UserProfileResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_subscription": {
|
||||
"$ref": "#/definitions/domain.UserSubscriptionSummary"
|
||||
},
|
||||
"age_group": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -11376,6 +11659,9 @@
|
|||
"status": {
|
||||
"$ref": "#/definitions/domain.UserStatus"
|
||||
},
|
||||
"subscription_status": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
@ -11396,47 +11682,6 @@
|
|||
"UserStatusDeactivated"
|
||||
]
|
||||
},
|
||||
"domain.UserSubscriptionSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auto_renew": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"currency": {
|
||||
"type": "string"
|
||||
},
|
||||
"duration_unit": {
|
||||
"type": "string"
|
||||
},
|
||||
"duration_value": {
|
||||
"type": "integer"
|
||||
},
|
||||
"expires_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"payment_method": {
|
||||
"type": "string"
|
||||
},
|
||||
"plan_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"plan_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"price": {
|
||||
"type": "number"
|
||||
},
|
||||
"starts_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.UserSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -327,12 +327,22 @@ definitions:
|
|||
total_users:
|
||||
type: integer
|
||||
type: object
|
||||
domain.BulkAccountsByRoleRequest:
|
||||
properties:
|
||||
exclude_team_member_id:
|
||||
type: integer
|
||||
type: object
|
||||
domain.CreateCourseInput:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
sort_order:
|
||||
description: SortOrder within the program when set; omit to append after current
|
||||
max within program_id (uniqueness is per-program).
|
||||
minimum: 0
|
||||
type: integer
|
||||
thumbnail:
|
||||
type: string
|
||||
required:
|
||||
|
|
@ -463,6 +473,11 @@ definitions:
|
|||
type: string
|
||||
name:
|
||||
type: string
|
||||
sort_order:
|
||||
description: SortOrder inserts at this global program order when set; omit
|
||||
to append after current max (sort_order uniqueness is enforced).
|
||||
minimum: 0
|
||||
type: integer
|
||||
thumbnail:
|
||||
type: string
|
||||
required:
|
||||
|
|
@ -798,6 +813,7 @@ definitions:
|
|||
- SUPER_ADMIN
|
||||
- ADMIN
|
||||
- STUDENT
|
||||
- OPEN_LEARNER
|
||||
- INSTRUCTOR
|
||||
- SUPPORT
|
||||
type: string
|
||||
|
|
@ -805,6 +821,7 @@ definitions:
|
|||
- RoleSuperAdmin
|
||||
- RoleAdmin
|
||||
- RoleStudent
|
||||
- RoleOpenLearner
|
||||
- RoleInstructor
|
||||
- RoleSupport
|
||||
domain.RoleRecord:
|
||||
|
|
@ -1145,8 +1162,6 @@ definitions:
|
|||
type: object
|
||||
domain.UserProfileResponse:
|
||||
properties:
|
||||
active_subscription:
|
||||
$ref: '#/definitions/domain.UserSubscriptionSummary'
|
||||
age_group:
|
||||
type: string
|
||||
birth_day:
|
||||
|
|
@ -1206,6 +1221,8 @@ definitions:
|
|||
$ref: '#/definitions/domain.Role'
|
||||
status:
|
||||
$ref: '#/definitions/domain.UserStatus'
|
||||
subscription_status:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
|
|
@ -1221,33 +1238,6 @@ definitions:
|
|||
- UserStatusActive
|
||||
- UserStatusSuspended
|
||||
- UserStatusDeactivated
|
||||
domain.UserSubscriptionSummary:
|
||||
properties:
|
||||
auto_renew:
|
||||
type: boolean
|
||||
currency:
|
||||
type: string
|
||||
duration_unit:
|
||||
type: string
|
||||
duration_value:
|
||||
type: integer
|
||||
expires_at:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
payment_method:
|
||||
type: string
|
||||
plan_id:
|
||||
type: integer
|
||||
plan_name:
|
||||
type: string
|
||||
price:
|
||||
type: number
|
||||
starts_at:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
domain.UserSummary:
|
||||
properties:
|
||||
active_users:
|
||||
|
|
@ -2895,6 +2885,186 @@ paths:
|
|||
summary: Update FAQ
|
||||
tags:
|
||||
- faqs
|
||||
/api/v1/admin/roles/{role}/bulk-deactivate:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Sets all platform users with the given users.role to DEACTIVATED
|
||||
(except the caller) and all team_members with the given team_role to inactive.
|
||||
Path :role may be a role key (e.g. INSTRUCTOR, ADMIN) or a decimal RBAC roles.id
|
||||
from GET /api/v1/rbac/roles (resolved to RoleRecord.name uppercased). SUPER_ADMIN
|
||||
cannot be bulk-deactivated. ADMIN platform users must use SUPER_ADMIN to bulk
|
||||
change other platform ADMIN users (team_members with team_role ADMIN under
|
||||
path ADMIN remain allowed). Empty body allowed; optionally pass exclude_team_member_id
|
||||
to skip one team_members row (e.g. yourself).
|
||||
parameters:
|
||||
- description: Role key (INSTRUCTOR etc.) or RBAC roles.id (integer string)
|
||||
in: path
|
||||
name: role
|
||||
required: true
|
||||
type: string
|
||||
- description: Optional exclusions
|
||||
in: body
|
||||
name: body
|
||||
schema:
|
||||
$ref: '#/definitions/domain.BulkAccountsByRoleRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/domain.Response'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Bulk deactivate accounts by role (SUPER_ADMIN or ADMIN platform users
|
||||
only)
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/roles/{role}/bulk-reactivate:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Sets all platform users with the given role from DEACTIVATED to
|
||||
ACTIVE (except the caller) and all team_members with the given team_role from
|
||||
inactive to active. Path :role may be a role key or decimal RBAC roles.id
|
||||
(see bulk-deactivate). Path role must correspond to valid platform users.role
|
||||
or team_members.team_role (after resolving id → name). SUPER_ADMIN cannot
|
||||
be bulk changed. ADMIN callers cannot bulk change other platform ADMIN users
|
||||
(team_members ADMIN under path ADMIN is allowed). Matches only users currently
|
||||
DEACTIVATED and team rows currently inactive.
|
||||
parameters:
|
||||
- description: Role key (INSTRUCTOR etc.) or RBAC roles.id (integer string)
|
||||
in: path
|
||||
name: role
|
||||
required: true
|
||||
type: string
|
||||
- description: Optional exclusions
|
||||
in: body
|
||||
name: body
|
||||
schema:
|
||||
$ref: '#/definitions/domain.BulkAccountsByRoleRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/domain.Response'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Bulk reactivate accounts by role (SUPER_ADMIN or ADMIN platform users
|
||||
only)
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/users/{user_id}/lms-learning-activity:
|
||||
get:
|
||||
description: Returns programs, courses, modules, and lessons with completion
|
||||
details and completed practices. Only persisted completion signals are included
|
||||
(completed lessons, completed published practices, and rollup completion timestamps—not
|
||||
partial or in-progress attempts).
|
||||
parameters:
|
||||
- description: Target user ID
|
||||
in: path
|
||||
name: user_id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/domain.Response'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Get a user's nested LMS learning activity (admin)
|
||||
tags:
|
||||
- lms
|
||||
/api/v1/admin/users/{user_id}/recent-activity:
|
||||
get:
|
||||
description: 'Reverse-chronological feed for profile UI: account joined plus
|
||||
LMS completion milestones (lessons/modules/courses/programs). Optional practice
|
||||
completions via include_practices. Does not include "started learning path"
|
||||
unless you add persisted engagement events—the schema stores completions only.'
|
||||
parameters:
|
||||
- description: Target user ID
|
||||
in: path
|
||||
name: user_id
|
||||
required: true
|
||||
type: integer
|
||||
- description: Max items after merge (default 40, max 120)
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
- description: Include completed LMS practices (more verbose)
|
||||
in: query
|
||||
name: include_practices
|
||||
type: boolean
|
||||
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'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Recent activity timeline for a user (admin)
|
||||
tags:
|
||||
- lms
|
||||
/api/v1/admin/users/deletion-requests:
|
||||
get:
|
||||
consumes:
|
||||
|
|
@ -5072,7 +5242,9 @@ paths:
|
|||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Create a top-level LMS program
|
||||
description: Create a top-level LMS program. Optional sort_order inserts at
|
||||
that global ordering; omit it to append after the current highest sort_order.
|
||||
Unique constraint applies to sort_order.
|
||||
parameters:
|
||||
- description: Program
|
||||
in: body
|
||||
|
|
@ -5207,7 +5379,9 @@ paths:
|
|||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Create a course under a program
|
||||
description: Create a course under a program. Optional sort_order assigns position
|
||||
within that program (siblings shifted); omit to append after the current highest
|
||||
sort_order in the program.
|
||||
parameters:
|
||||
- description: Program ID
|
||||
in: path
|
||||
|
|
@ -8017,8 +8191,8 @@ paths:
|
|||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Get users with optional filters. Each user may include active_subscription
|
||||
when they have a current ACTIVE, non-expired plan.
|
||||
description: 'Get users with optional filters. Each user includes subscription_status:
|
||||
ACTIVE, PENDING, or Unsubscribed.'
|
||||
parameters:
|
||||
- description: Role filter
|
||||
in: query
|
||||
|
|
@ -8044,10 +8218,23 @@ paths:
|
|||
in: query
|
||||
name: created_after
|
||||
type: string
|
||||
- description: Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)
|
||||
- description: User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)
|
||||
in: query
|
||||
name: status
|
||||
type: string
|
||||
- description: Country filter (case-insensitive match on stored value)
|
||||
in: query
|
||||
name: country
|
||||
type: string
|
||||
- description: Region filter (case-insensitive match on stored value)
|
||||
in: query
|
||||
name: region
|
||||
type: string
|
||||
- description: 'Derived subscription filter: ACTIVE, PENDING, or Unsubscribed
|
||||
(matches response subscription_status semantics)'
|
||||
in: query
|
||||
name: subscription_status
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ INSERT INTO exam_prep.lesson_practices (
|
|||
story_image,
|
||||
persona_id,
|
||||
question_set_id,
|
||||
quick_tips
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
|
||||
quick_tips,
|
||||
publish_status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status
|
||||
`
|
||||
|
||||
type ExamPrepCreateLessonPracticeParams struct {
|
||||
|
|
@ -32,6 +33,7 @@ type ExamPrepCreateLessonPracticeParams struct {
|
|||
PersonaID pgtype.Int8 `json:"persona_id"`
|
||||
QuestionSetID int64 `json:"question_set_id"`
|
||||
QuickTips pgtype.Text `json:"quick_tips"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepCreateLessonPractice(ctx context.Context, arg ExamPrepCreateLessonPracticeParams) (ExamPrepLessonPractice, error) {
|
||||
|
|
@ -43,6 +45,7 @@ func (q *Queries) ExamPrepCreateLessonPractice(ctx context.Context, arg ExamPrep
|
|||
arg.PersonaID,
|
||||
arg.QuestionSetID,
|
||||
arg.QuickTips,
|
||||
arg.PublishStatus,
|
||||
)
|
||||
var i ExamPrepLessonPractice
|
||||
err := row.Scan(
|
||||
|
|
@ -56,6 +59,7 @@ func (q *Queries) ExamPrepCreateLessonPractice(ctx context.Context, arg ExamPrep
|
|||
&i.QuickTips,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PublishStatus,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -71,7 +75,7 @@ func (q *Queries) ExamPrepDeleteLessonPractice(ctx context.Context, id int64) er
|
|||
}
|
||||
|
||||
const ExamPrepGetLessonPracticeByID = `-- name: ExamPrepGetLessonPracticeByID :one
|
||||
SELECT id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
|
||||
SELECT id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status
|
||||
FROM exam_prep.lesson_practices
|
||||
WHERE id = $1
|
||||
`
|
||||
|
|
@ -90,6 +94,34 @@ func (q *Queries) ExamPrepGetLessonPracticeByID(ctx context.Context, id int64) (
|
|||
&i.QuickTips,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PublishStatus,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ExamPrepGetLessonPracticeByQuestionSetID = `-- name: ExamPrepGetLessonPracticeByQuestionSetID :one
|
||||
SELECT id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status
|
||||
FROM exam_prep.lesson_practices
|
||||
WHERE question_set_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepGetLessonPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (ExamPrepLessonPractice, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepGetLessonPracticeByQuestionSetID, questionSetID)
|
||||
var i ExamPrepLessonPractice
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UnitModuleLessonID,
|
||||
&i.Title,
|
||||
&i.StoryDescription,
|
||||
&i.StoryImage,
|
||||
&i.PersonaID,
|
||||
&i.QuestionSetID,
|
||||
&i.QuickTips,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PublishStatus,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -105,10 +137,15 @@ SELECT
|
|||
p.persona_id,
|
||||
p.question_set_id,
|
||||
p.quick_tips,
|
||||
p.publish_status,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM exam_prep.lesson_practices p
|
||||
WHERE p.unit_module_lesson_id = $1
|
||||
AND (
|
||||
$4::boolean = FALSE
|
||||
OR p.publish_status = 'PUBLISHED'::TEXT
|
||||
)
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2
|
||||
OFFSET $3
|
||||
|
|
@ -118,6 +155,7 @@ type ExamPrepListLessonPracticesByLessonIDParams struct {
|
|||
UnitModuleLessonID int64 `json:"unit_module_lesson_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
PublishedOnly bool `json:"published_only"`
|
||||
}
|
||||
|
||||
type ExamPrepListLessonPracticesByLessonIDRow struct {
|
||||
|
|
@ -130,12 +168,18 @@ type ExamPrepListLessonPracticesByLessonIDRow struct {
|
|||
PersonaID pgtype.Int8 `json:"persona_id"`
|
||||
QuestionSetID int64 `json:"question_set_id"`
|
||||
QuickTips pgtype.Text `json:"quick_tips"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepListLessonPracticesByLessonID(ctx context.Context, arg ExamPrepListLessonPracticesByLessonIDParams) ([]ExamPrepListLessonPracticesByLessonIDRow, error) {
|
||||
rows, err := q.db.Query(ctx, ExamPrepListLessonPracticesByLessonID, arg.UnitModuleLessonID, arg.Limit, arg.Offset)
|
||||
rows, err := q.db.Query(ctx, ExamPrepListLessonPracticesByLessonID,
|
||||
arg.UnitModuleLessonID,
|
||||
arg.Limit,
|
||||
arg.Offset,
|
||||
arg.PublishedOnly,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -153,6 +197,7 @@ func (q *Queries) ExamPrepListLessonPracticesByLessonID(ctx context.Context, arg
|
|||
&i.PersonaID,
|
||||
&i.QuestionSetID,
|
||||
&i.QuickTips,
|
||||
&i.PublishStatus,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
|
|
@ -175,9 +220,10 @@ SET
|
|||
persona_id = coalesce($4::bigint, persona_id),
|
||||
question_set_id = coalesce($5::bigint, question_set_id),
|
||||
quick_tips = coalesce($6::text, quick_tips),
|
||||
publish_status = coalesce($7::varchar, publish_status),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $7
|
||||
RETURNING id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
|
||||
WHERE id = $8
|
||||
RETURNING id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status
|
||||
`
|
||||
|
||||
type ExamPrepUpdateLessonPracticeParams struct {
|
||||
|
|
@ -187,6 +233,7 @@ type ExamPrepUpdateLessonPracticeParams struct {
|
|||
PersonaID pgtype.Int8 `json:"persona_id"`
|
||||
QuestionSetID pgtype.Int8 `json:"question_set_id"`
|
||||
QuickTips pgtype.Text `json:"quick_tips"`
|
||||
PublishStatus pgtype.Text `json:"publish_status"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
|
|
@ -198,6 +245,7 @@ func (q *Queries) ExamPrepUpdateLessonPractice(ctx context.Context, arg ExamPrep
|
|||
arg.PersonaID,
|
||||
arg.QuestionSetID,
|
||||
arg.QuickTips,
|
||||
arg.PublishStatus,
|
||||
arg.ID,
|
||||
)
|
||||
var i ExamPrepLessonPractice
|
||||
|
|
@ -212,6 +260,7 @@ func (q *Queries) ExamPrepUpdateLessonPractice(ctx context.Context, arg ExamPrep
|
|||
&i.QuickTips,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PublishStatus,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
283
gen/db/lms_admin_activity.sql.go
Normal file
283
gen/db/lms_admin_activity.sql.go
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: lms_admin_activity.sql
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const ListUserLMSFlatLearningActivityByUser = `-- name: ListUserLMSFlatLearningActivityByUser :many
|
||||
|
||||
SELECT
|
||||
x.activity_kind,
|
||||
x.program_id,
|
||||
x.program_name,
|
||||
x.program_sort_order,
|
||||
x.program_completed_at,
|
||||
x.course_id,
|
||||
x.course_name,
|
||||
x.course_sort_order,
|
||||
x.course_completed_at,
|
||||
COALESCE(x.module_id, 0)::BIGINT AS module_id,
|
||||
COALESCE(x.module_name, '')::TEXT AS module_name,
|
||||
COALESCE(x.module_sort_order, 0)::INT AS module_sort_order,
|
||||
x.module_completed_at,
|
||||
COALESCE(x.lesson_id, 0)::BIGINT AS lesson_id,
|
||||
COALESCE(x.lesson_title, '')::TEXT AS lesson_title,
|
||||
COALESCE(x.lesson_sort_order, 0)::INT AS lesson_sort_order,
|
||||
x.lesson_completed_at,
|
||||
COALESCE(x.lms_practice_id, 0)::BIGINT AS lms_practice_id,
|
||||
COALESCE(x.practice_title, '')::TEXT AS practice_title,
|
||||
x.activity_at
|
||||
FROM (
|
||||
SELECT
|
||||
'lesson'::TEXT AS activity_kind,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name,
|
||||
p.sort_order AS program_sort_order,
|
||||
prf.completed_at AS program_completed_at,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
c.sort_order AS course_sort_order,
|
||||
crf.completed_at AS course_completed_at,
|
||||
m.id AS module_id,
|
||||
m.name AS module_name,
|
||||
m.sort_order AS module_sort_order,
|
||||
mrf.completed_at AS module_completed_at,
|
||||
l.id AS lesson_id,
|
||||
l.title AS lesson_title,
|
||||
l.sort_order AS lesson_sort_order,
|
||||
ulp.completed_at AS lesson_completed_at,
|
||||
NULL::BIGINT AS lms_practice_id,
|
||||
NULL::VARCHAR AS practice_title,
|
||||
ulp.completed_at AS activity_at
|
||||
FROM
|
||||
lms_user_lesson_progress ulp
|
||||
INNER JOIN lessons l ON l.id = ulp.lesson_id
|
||||
INNER JOIN modules m ON m.id = l.module_id
|
||||
INNER JOIN courses c ON c.id = m.course_id
|
||||
AND c.program_id = m.program_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
LEFT JOIN lms_user_program_progress prf ON prf.user_id = ulp.user_id
|
||||
AND prf.program_id = p.id
|
||||
LEFT JOIN lms_user_course_progress crf ON crf.user_id = ulp.user_id
|
||||
AND crf.course_id = c.id
|
||||
LEFT JOIN lms_user_module_progress mrf ON mrf.user_id = ulp.user_id
|
||||
AND mrf.module_id = m.id
|
||||
WHERE
|
||||
ulp.user_id = $1
|
||||
UNION ALL
|
||||
SELECT
|
||||
'practice'::TEXT,
|
||||
p.id,
|
||||
p.name,
|
||||
p.sort_order,
|
||||
prf.completed_at,
|
||||
c.id,
|
||||
c.name,
|
||||
c.sort_order,
|
||||
crf.completed_at,
|
||||
m.id,
|
||||
m.name,
|
||||
m.sort_order,
|
||||
mrf.completed_at,
|
||||
l.id,
|
||||
l.title,
|
||||
l.sort_order,
|
||||
lucomp.completed_at,
|
||||
lp.id,
|
||||
lp.title,
|
||||
upp.completed_at
|
||||
FROM
|
||||
user_practice_progress upp
|
||||
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
|
||||
AND lp.lesson_id IS NOT NULL
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
INNER JOIN lessons l ON l.id = lp.lesson_id
|
||||
INNER JOIN modules m ON m.id = l.module_id
|
||||
INNER JOIN courses c ON c.id = m.course_id
|
||||
AND c.program_id = m.program_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
LEFT JOIN lms_user_program_progress prf ON prf.user_id = upp.user_id
|
||||
AND prf.program_id = p.id
|
||||
LEFT JOIN lms_user_course_progress crf ON crf.user_id = upp.user_id
|
||||
AND crf.course_id = c.id
|
||||
LEFT JOIN lms_user_module_progress mrf ON mrf.user_id = upp.user_id
|
||||
AND mrf.module_id = m.id
|
||||
LEFT JOIN lms_user_lesson_progress lucomp ON lucomp.user_id = upp.user_id
|
||||
AND lucomp.lesson_id = l.id
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
'practice'::TEXT,
|
||||
p.id,
|
||||
p.name,
|
||||
p.sort_order,
|
||||
prf.completed_at,
|
||||
c.id,
|
||||
c.name,
|
||||
c.sort_order,
|
||||
crf.completed_at,
|
||||
m.id,
|
||||
m.name,
|
||||
m.sort_order,
|
||||
mrf.completed_at,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
NULL::INT,
|
||||
NULL::TIMESTAMPTZ,
|
||||
lp.id,
|
||||
lp.title,
|
||||
upp.completed_at
|
||||
FROM
|
||||
user_practice_progress upp
|
||||
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
|
||||
AND lp.module_id IS NOT NULL
|
||||
AND lp.lesson_id IS NULL
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
INNER JOIN modules m ON m.id = lp.module_id
|
||||
INNER JOIN courses c ON c.id = m.course_id
|
||||
AND c.program_id = m.program_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
LEFT JOIN lms_user_program_progress prf ON prf.user_id = upp.user_id
|
||||
AND prf.program_id = p.id
|
||||
LEFT JOIN lms_user_course_progress crf ON crf.user_id = upp.user_id
|
||||
AND crf.course_id = c.id
|
||||
LEFT JOIN lms_user_module_progress mrf ON mrf.user_id = upp.user_id
|
||||
AND mrf.module_id = m.id
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
'practice'::TEXT,
|
||||
p.id,
|
||||
p.name,
|
||||
p.sort_order,
|
||||
prf.completed_at,
|
||||
c.id,
|
||||
c.name,
|
||||
c.sort_order,
|
||||
crf.completed_at,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
NULL::INT,
|
||||
NULL::TIMESTAMPTZ,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
NULL::INT,
|
||||
NULL::TIMESTAMPTZ,
|
||||
lp.id,
|
||||
lp.title,
|
||||
upp.completed_at
|
||||
FROM
|
||||
user_practice_progress upp
|
||||
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
|
||||
AND lp.course_id IS NOT NULL
|
||||
AND lp.module_id IS NULL
|
||||
AND lp.lesson_id IS NULL
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
INNER JOIN courses c ON c.id = lp.course_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
LEFT JOIN lms_user_program_progress prf ON prf.user_id = upp.user_id
|
||||
AND prf.program_id = p.id
|
||||
LEFT JOIN lms_user_course_progress crf ON crf.user_id = upp.user_id
|
||||
AND crf.course_id = c.id
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
) AS x
|
||||
ORDER BY
|
||||
x.program_sort_order,
|
||||
x.program_id,
|
||||
x.course_sort_order,
|
||||
x.course_id,
|
||||
x.module_sort_order NULLS LAST,
|
||||
x.module_id NULLS LAST,
|
||||
x.lesson_sort_order NULLS LAST,
|
||||
x.lesson_id NULLS LAST,
|
||||
x.activity_kind,
|
||||
x.activity_at
|
||||
`
|
||||
|
||||
type ListUserLMSFlatLearningActivityByUserRow struct {
|
||||
ActivityKind string `json:"activity_kind"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
ProgramName string `json:"program_name"`
|
||||
ProgramSortOrder int32 `json:"program_sort_order"`
|
||||
ProgramCompletedAt pgtype.Timestamptz `json:"program_completed_at"`
|
||||
CourseID int64 `json:"course_id"`
|
||||
CourseName string `json:"course_name"`
|
||||
CourseSortOrder int32 `json:"course_sort_order"`
|
||||
CourseCompletedAt pgtype.Timestamptz `json:"course_completed_at"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
ModuleName string `json:"module_name"`
|
||||
ModuleSortOrder int32 `json:"module_sort_order"`
|
||||
ModuleCompletedAt pgtype.Timestamptz `json:"module_completed_at"`
|
||||
LessonID int64 `json:"lesson_id"`
|
||||
LessonTitle string `json:"lesson_title"`
|
||||
LessonSortOrder int32 `json:"lesson_sort_order"`
|
||||
LessonCompletedAt pgtype.Timestamptz `json:"lesson_completed_at"`
|
||||
LmsPracticeID int64 `json:"lms_practice_id"`
|
||||
PracticeTitle string `json:"practice_title"`
|
||||
ActivityAt pgtype.Timestamptz `json:"activity_at"`
|
||||
}
|
||||
|
||||
// Aggregated LMS learning activity for admin: completed lessons and completed practices
|
||||
// (rollup tables + lesson/practice completion are the persisted signals in this schema).
|
||||
func (q *Queries) ListUserLMSFlatLearningActivityByUser(ctx context.Context, userID int64) ([]ListUserLMSFlatLearningActivityByUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListUserLMSFlatLearningActivityByUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUserLMSFlatLearningActivityByUserRow
|
||||
for rows.Next() {
|
||||
var i ListUserLMSFlatLearningActivityByUserRow
|
||||
if err := rows.Scan(
|
||||
&i.ActivityKind,
|
||||
&i.ProgramID,
|
||||
&i.ProgramName,
|
||||
&i.ProgramSortOrder,
|
||||
&i.ProgramCompletedAt,
|
||||
&i.CourseID,
|
||||
&i.CourseName,
|
||||
&i.CourseSortOrder,
|
||||
&i.CourseCompletedAt,
|
||||
&i.ModuleID,
|
||||
&i.ModuleName,
|
||||
&i.ModuleSortOrder,
|
||||
&i.ModuleCompletedAt,
|
||||
&i.LessonID,
|
||||
&i.LessonTitle,
|
||||
&i.LessonSortOrder,
|
||||
&i.LessonCompletedAt,
|
||||
&i.LmsPracticeID,
|
||||
&i.PracticeTitle,
|
||||
&i.ActivityAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
|
@ -18,12 +18,13 @@ SELECT
|
|||
$2,
|
||||
$3,
|
||||
$4,
|
||||
coalesce((
|
||||
COALESCE($5::int,
|
||||
COALESCE((
|
||||
SELECT
|
||||
max(c.sort_order)
|
||||
FROM courses c
|
||||
WHERE
|
||||
c.program_id = $1), 0) + 1
|
||||
c.program_id = $1), 0) + 1)
|
||||
RETURNING
|
||||
id, program_id, name, description, thumbnail, created_at, updated_at, sort_order
|
||||
`
|
||||
|
|
@ -33,6 +34,7 @@ type CreateCourseParams struct {
|
|||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Course, error) {
|
||||
|
|
@ -41,6 +43,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou
|
|||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Thumbnail,
|
||||
arg.SortOrder,
|
||||
)
|
||||
var i Course
|
||||
err := row.Scan(
|
||||
|
|
@ -75,6 +78,7 @@ SELECT
|
|||
WHERE p.course_id = c.id
|
||||
AND p.module_id IS NULL
|
||||
AND p.lesson_id IS NULL
|
||||
AND p.publish_status = 'PUBLISHED'
|
||||
) AS has_practice
|
||||
FROM courses
|
||||
c
|
||||
|
|
@ -177,13 +181,15 @@ SELECT
|
|||
WHERE
|
||||
p.course_id = c.id
|
||||
AND p.module_id IS NULL
|
||||
AND p.lesson_id IS NULL) AS practice_count,
|
||||
AND p.lesson_id IS NULL
|
||||
AND p.publish_status = 'PUBLISHED') AS practice_count,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.course_id = c.id
|
||||
AND p.module_id IS NULL
|
||||
AND p.lesson_id IS NULL
|
||||
AND p.publish_status = 'PUBLISHED'
|
||||
) AS has_practice
|
||||
FROM
|
||||
courses c
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ SELECT
|
|||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.lesson_id = l.id
|
||||
AND p.publish_status = 'PUBLISHED'
|
||||
) AS has_practice
|
||||
FROM lessons
|
||||
l
|
||||
|
|
@ -130,6 +131,7 @@ SELECT
|
|||
SELECT 1
|
||||
FROM lms_practices p
|
||||
WHERE p.lesson_id = l.id
|
||||
AND p.publish_status = 'PUBLISHED'
|
||||
) AS has_practice
|
||||
FROM
|
||||
lessons l
|
||||
|
|
|
|||
|
|
@ -19,12 +19,13 @@ SELECT
|
|||
$3,
|
||||
$4,
|
||||
$5,
|
||||
coalesce((
|
||||
COALESCE($6::int,
|
||||
COALESCE((
|
||||
SELECT
|
||||
max(m.sort_order)
|
||||
FROM modules m
|
||||
WHERE
|
||||
m.course_id = $2), 0) + 1
|
||||
m.course_id = $2), 0) + 1)
|
||||
RETURNING
|
||||
id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order
|
||||
`
|
||||
|
|
@ -35,6 +36,7 @@ type CreateModuleParams struct {
|
|||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Icon pgtype.Text `json:"icon"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Module, error) {
|
||||
|
|
@ -44,6 +46,7 @@ func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Mod
|
|||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Icon,
|
||||
arg.SortOrder,
|
||||
)
|
||||
var i Module
|
||||
err := row.Scan(
|
||||
|
|
@ -78,6 +81,7 @@ SELECT
|
|||
FROM lms_practices p
|
||||
WHERE p.module_id = m.id
|
||||
AND p.lesson_id IS NULL
|
||||
AND p.publish_status = 'PUBLISHED'
|
||||
) AS has_practice
|
||||
FROM modules
|
||||
m
|
||||
|
|
@ -163,6 +167,7 @@ SELECT
|
|||
FROM lms_practices p
|
||||
WHERE p.module_id = m.id
|
||||
AND p.lesson_id IS NULL
|
||||
AND p.publish_status = 'PUBLISHED'
|
||||
) AS has_practice
|
||||
FROM
|
||||
modules m
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ import (
|
|||
const CreateLmsPractice = `-- name: CreateLmsPractice :one
|
||||
INSERT INTO lms_practices (
|
||||
course_id, module_id, lesson_id,
|
||||
title, story_description, story_image, persona_id, question_set_id, quick_tips
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
|
||||
title, story_description, story_image, persona_id, question_set_id, quick_tips, publish_status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status
|
||||
`
|
||||
|
||||
type CreateLmsPracticeParams struct {
|
||||
|
|
@ -29,6 +29,7 @@ type CreateLmsPracticeParams struct {
|
|||
PersonaID pgtype.Int8 `json:"persona_id"`
|
||||
QuestionSetID int64 `json:"question_set_id"`
|
||||
QuickTips pgtype.Text `json:"quick_tips"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateLmsPractice(ctx context.Context, arg CreateLmsPracticeParams) (LmsPractice, error) {
|
||||
|
|
@ -42,6 +43,7 @@ func (q *Queries) CreateLmsPractice(ctx context.Context, arg CreateLmsPracticePa
|
|||
arg.PersonaID,
|
||||
arg.QuestionSetID,
|
||||
arg.QuickTips,
|
||||
arg.PublishStatus,
|
||||
)
|
||||
var i LmsPractice
|
||||
err := row.Scan(
|
||||
|
|
@ -57,6 +59,7 @@ func (q *Queries) CreateLmsPractice(ctx context.Context, arg CreateLmsPracticePa
|
|||
&i.QuickTips,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PublishStatus,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -72,7 +75,7 @@ func (q *Queries) DeleteLmsPractice(ctx context.Context, id int64) error {
|
|||
}
|
||||
|
||||
const GetLmsPracticeByID = `-- name: GetLmsPracticeByID :one
|
||||
SELECT id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
|
||||
SELECT id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status
|
||||
FROM lms_practices
|
||||
WHERE id = $1
|
||||
`
|
||||
|
|
@ -93,6 +96,36 @@ func (q *Queries) GetLmsPracticeByID(ctx context.Context, id int64) (LmsPractice
|
|||
&i.QuickTips,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PublishStatus,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetLmsPracticeByQuestionSetID = `-- name: GetLmsPracticeByQuestionSetID :one
|
||||
SELECT id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status
|
||||
FROM lms_practices
|
||||
WHERE question_set_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetLmsPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (LmsPractice, error) {
|
||||
row := q.db.QueryRow(ctx, GetLmsPracticeByQuestionSetID, questionSetID)
|
||||
var i LmsPractice
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.CourseID,
|
||||
&i.ModuleID,
|
||||
&i.LessonID,
|
||||
&i.Title,
|
||||
&i.StoryDescription,
|
||||
&i.StoryImage,
|
||||
&i.PersonaID,
|
||||
&i.QuestionSetID,
|
||||
&i.QuickTips,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PublishStatus,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -110,10 +143,15 @@ SELECT
|
|||
p.persona_id,
|
||||
p.question_set_id,
|
||||
p.quick_tips,
|
||||
p.publish_status,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM lms_practices p
|
||||
WHERE p.course_id = $1
|
||||
AND (
|
||||
$4::boolean = FALSE
|
||||
OR p.publish_status = 'PUBLISHED'::TEXT
|
||||
)
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
|
@ -122,6 +160,7 @@ type ListLmsPracticesByCourseIDParams struct {
|
|||
CourseID pgtype.Int8 `json:"course_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
PublishedOnly bool `json:"published_only"`
|
||||
}
|
||||
|
||||
type ListLmsPracticesByCourseIDRow struct {
|
||||
|
|
@ -136,12 +175,18 @@ type ListLmsPracticesByCourseIDRow struct {
|
|||
PersonaID pgtype.Int8 `json:"persona_id"`
|
||||
QuestionSetID int64 `json:"question_set_id"`
|
||||
QuickTips pgtype.Text `json:"quick_tips"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListLmsPracticesByCourseID(ctx context.Context, arg ListLmsPracticesByCourseIDParams) ([]ListLmsPracticesByCourseIDRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListLmsPracticesByCourseID, arg.CourseID, arg.Limit, arg.Offset)
|
||||
rows, err := q.db.Query(ctx, ListLmsPracticesByCourseID,
|
||||
arg.CourseID,
|
||||
arg.Limit,
|
||||
arg.Offset,
|
||||
arg.PublishedOnly,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -161,6 +206,7 @@ func (q *Queries) ListLmsPracticesByCourseID(ctx context.Context, arg ListLmsPra
|
|||
&i.PersonaID,
|
||||
&i.QuestionSetID,
|
||||
&i.QuickTips,
|
||||
&i.PublishStatus,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
|
|
@ -187,10 +233,15 @@ SELECT
|
|||
p.persona_id,
|
||||
p.question_set_id,
|
||||
p.quick_tips,
|
||||
p.publish_status,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM lms_practices p
|
||||
WHERE p.lesson_id = $1
|
||||
AND (
|
||||
$4::boolean = FALSE
|
||||
OR p.publish_status = 'PUBLISHED'::TEXT
|
||||
)
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
|
@ -199,6 +250,7 @@ type ListLmsPracticesByLessonIDParams struct {
|
|||
LessonID pgtype.Int8 `json:"lesson_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
PublishedOnly bool `json:"published_only"`
|
||||
}
|
||||
|
||||
type ListLmsPracticesByLessonIDRow struct {
|
||||
|
|
@ -213,12 +265,18 @@ type ListLmsPracticesByLessonIDRow struct {
|
|||
PersonaID pgtype.Int8 `json:"persona_id"`
|
||||
QuestionSetID int64 `json:"question_set_id"`
|
||||
QuickTips pgtype.Text `json:"quick_tips"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListLmsPracticesByLessonID(ctx context.Context, arg ListLmsPracticesByLessonIDParams) ([]ListLmsPracticesByLessonIDRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListLmsPracticesByLessonID, arg.LessonID, arg.Limit, arg.Offset)
|
||||
rows, err := q.db.Query(ctx, ListLmsPracticesByLessonID,
|
||||
arg.LessonID,
|
||||
arg.Limit,
|
||||
arg.Offset,
|
||||
arg.PublishedOnly,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -238,6 +296,7 @@ func (q *Queries) ListLmsPracticesByLessonID(ctx context.Context, arg ListLmsPra
|
|||
&i.PersonaID,
|
||||
&i.QuestionSetID,
|
||||
&i.QuickTips,
|
||||
&i.PublishStatus,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
|
|
@ -264,10 +323,15 @@ SELECT
|
|||
p.persona_id,
|
||||
p.question_set_id,
|
||||
p.quick_tips,
|
||||
p.publish_status,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM lms_practices p
|
||||
WHERE p.module_id = $1
|
||||
AND (
|
||||
$4::boolean = FALSE
|
||||
OR p.publish_status = 'PUBLISHED'::TEXT
|
||||
)
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
|
@ -276,6 +340,7 @@ type ListLmsPracticesByModuleIDParams struct {
|
|||
ModuleID pgtype.Int8 `json:"module_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
PublishedOnly bool `json:"published_only"`
|
||||
}
|
||||
|
||||
type ListLmsPracticesByModuleIDRow struct {
|
||||
|
|
@ -290,12 +355,18 @@ type ListLmsPracticesByModuleIDRow struct {
|
|||
PersonaID pgtype.Int8 `json:"persona_id"`
|
||||
QuestionSetID int64 `json:"question_set_id"`
|
||||
QuickTips pgtype.Text `json:"quick_tips"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListLmsPracticesByModuleID(ctx context.Context, arg ListLmsPracticesByModuleIDParams) ([]ListLmsPracticesByModuleIDRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListLmsPracticesByModuleID, arg.ModuleID, arg.Limit, arg.Offset)
|
||||
rows, err := q.db.Query(ctx, ListLmsPracticesByModuleID,
|
||||
arg.ModuleID,
|
||||
arg.Limit,
|
||||
arg.Offset,
|
||||
arg.PublishedOnly,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -315,6 +386,7 @@ func (q *Queries) ListLmsPracticesByModuleID(ctx context.Context, arg ListLmsPra
|
|||
&i.PersonaID,
|
||||
&i.QuestionSetID,
|
||||
&i.QuickTips,
|
||||
&i.PublishStatus,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
|
|
@ -337,9 +409,10 @@ SET
|
|||
persona_id = COALESCE($4::bigint, persona_id),
|
||||
question_set_id = COALESCE($5::bigint, question_set_id),
|
||||
quick_tips = COALESCE($6::text, quick_tips),
|
||||
publish_status = COALESCE($7::varchar, publish_status),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $7
|
||||
RETURNING id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
|
||||
WHERE id = $8
|
||||
RETURNING id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status
|
||||
`
|
||||
|
||||
type UpdateLmsPracticeParams struct {
|
||||
|
|
@ -349,6 +422,7 @@ type UpdateLmsPracticeParams struct {
|
|||
PersonaID pgtype.Int8 `json:"persona_id"`
|
||||
QuestionSetID pgtype.Int8 `json:"question_set_id"`
|
||||
QuickTips pgtype.Text `json:"quick_tips"`
|
||||
PublishStatus pgtype.Text `json:"publish_status"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
|
|
@ -360,6 +434,7 @@ func (q *Queries) UpdateLmsPractice(ctx context.Context, arg UpdateLmsPracticePa
|
|||
arg.PersonaID,
|
||||
arg.QuestionSetID,
|
||||
arg.QuickTips,
|
||||
arg.PublishStatus,
|
||||
arg.ID,
|
||||
)
|
||||
var i LmsPractice
|
||||
|
|
@ -376,6 +451,7 @@ func (q *Queries) UpdateLmsPractice(ctx context.Context, arg UpdateLmsPracticePa
|
|||
&i.QuickTips,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PublishStatus,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ WHERE
|
|||
lp.course_id = $1
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
func (q *Queries) CountPublishedPracticesInCourse(ctx context.Context, courseID pgtype.Int8) (int32, error) {
|
||||
|
|
@ -125,6 +126,7 @@ WHERE
|
|||
lp.module_id = $1
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
// Published practices in a module (module-level and lesson-level practices should carry module_id).
|
||||
|
|
@ -146,6 +148,7 @@ WHERE
|
|||
c.program_id = $1
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
func (q *Queries) CountPublishedPracticesInProgram(ctx context.Context, programID int64) (int32, error) {
|
||||
|
|
@ -313,6 +316,7 @@ WHERE
|
|||
AND upp.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
type CountUserCompletedPublishedPracticesInModuleParams struct {
|
||||
|
|
@ -341,6 +345,7 @@ WHERE
|
|||
AND upp.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
type CountUserCompletedPublishedPracticesInProgramParams struct {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ type ExamPrepLessonPractice struct {
|
|||
QuickTips pgtype.Text `json:"quick_tips"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
}
|
||||
|
||||
type ExamPrepUnit struct {
|
||||
|
|
@ -149,6 +150,7 @@ type LmsPractice struct {
|
|||
QuickTips pgtype.Text `json:"quick_tips"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
}
|
||||
|
||||
type LmsUserCourseProgress struct {
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ SELECT
|
|||
$1,
|
||||
$2,
|
||||
$3,
|
||||
coalesce((
|
||||
COALESCE($4::int, COALESCE((
|
||||
SELECT
|
||||
max(p.sort_order)
|
||||
FROM programs AS p), 0) + 1
|
||||
FROM programs AS p), 0) + 1)
|
||||
RETURNING
|
||||
id, name, description, thumbnail, created_at, updated_at, sort_order
|
||||
`
|
||||
|
|
@ -29,10 +29,16 @@ type CreateProgramParams struct {
|
|||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (Program, error) {
|
||||
row := q.db.QueryRow(ctx, CreateProgram, arg.Name, arg.Description, arg.Thumbnail)
|
||||
row := q.db.QueryRow(ctx, CreateProgram,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Thumbnail,
|
||||
arg.SortOrder,
|
||||
)
|
||||
var i Program
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
|
|
|
|||
|
|
@ -365,6 +365,27 @@ func (q *Queries) GetExpiringSubscriptions(ctx context.Context) ([]GetExpiringSu
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const GetSubscriptionDisplayStatusByUserID = `-- name: GetSubscriptionDisplayStatusByUserID :one
|
||||
SELECT COALESCE(
|
||||
(SELECT us.status::text FROM user_subscriptions us
|
||||
WHERE us.user_id = $1
|
||||
AND us.status = 'ACTIVE' AND us.expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY us.expires_at DESC LIMIT 1),
|
||||
(SELECT us.status::text FROM user_subscriptions us
|
||||
WHERE us.user_id = $1
|
||||
AND us.status = 'PENDING'
|
||||
ORDER BY us.created_at DESC LIMIT 1),
|
||||
'Unsubscribed'
|
||||
)::text AS subscription_status
|
||||
`
|
||||
|
||||
func (q *Queries) GetSubscriptionDisplayStatusByUserID(ctx context.Context, userID int64) (string, error) {
|
||||
row := q.db.QueryRow(ctx, GetSubscriptionDisplayStatusByUserID, userID)
|
||||
var subscription_status string
|
||||
err := row.Scan(&subscription_status)
|
||||
return subscription_status, err
|
||||
}
|
||||
|
||||
const GetSubscriptionPlanByID = `-- name: GetSubscriptionPlanByID :one
|
||||
SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at FROM subscription_plans WHERE id = $1
|
||||
`
|
||||
|
|
@ -578,70 +599,42 @@ func (q *Queries) ListActiveSubscriptionPlans(ctx context.Context) ([]Subscripti
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const ListActiveSubscriptionsByUserIDs = `-- name: ListActiveSubscriptionsByUserIDs :many
|
||||
SELECT DISTINCT ON (us.user_id)
|
||||
us.user_id,
|
||||
us.id,
|
||||
us.plan_id,
|
||||
us.starts_at,
|
||||
us.expires_at,
|
||||
us.status,
|
||||
us.auto_renew,
|
||||
us.payment_method,
|
||||
sp.name AS plan_name,
|
||||
sp.duration_value,
|
||||
sp.duration_unit,
|
||||
sp.price,
|
||||
sp.currency
|
||||
FROM user_subscriptions us
|
||||
JOIN subscription_plans sp ON sp.id = us.plan_id
|
||||
WHERE us.user_id = ANY($1::bigint[])
|
||||
AND us.status = 'ACTIVE'
|
||||
AND us.expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY us.user_id, us.expires_at DESC
|
||||
const ListSubscriptionDisplayStatusesByUserIDs = `-- name: ListSubscriptionDisplayStatusesByUserIDs :many
|
||||
WITH input AS (
|
||||
SELECT unnest($1::bigint[])::bigint AS user_id
|
||||
)
|
||||
SELECT
|
||||
input.user_id,
|
||||
COALESCE(
|
||||
(SELECT us.status::text FROM user_subscriptions us
|
||||
WHERE us.user_id = input.user_id
|
||||
AND us.status = 'ACTIVE' AND us.expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY us.expires_at DESC LIMIT 1),
|
||||
(SELECT us.status::text FROM user_subscriptions us
|
||||
WHERE us.user_id = input.user_id
|
||||
AND us.status = 'PENDING'
|
||||
ORDER BY us.created_at DESC LIMIT 1),
|
||||
'Unsubscribed'
|
||||
)::text AS subscription_status
|
||||
FROM input
|
||||
`
|
||||
|
||||
type ListActiveSubscriptionsByUserIDsRow struct {
|
||||
type ListSubscriptionDisplayStatusesByUserIDsRow struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
ID int64 `json:"id"`
|
||||
PlanID int64 `json:"plan_id"`
|
||||
StartsAt pgtype.Timestamptz `json:"starts_at"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
Status string `json:"status"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
PaymentMethod pgtype.Text `json:"payment_method"`
|
||||
PlanName string `json:"plan_name"`
|
||||
DurationValue int32 `json:"duration_value"`
|
||||
DurationUnit string `json:"duration_unit"`
|
||||
Price pgtype.Numeric `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
SubscriptionStatus string `json:"subscription_status"`
|
||||
}
|
||||
|
||||
// One ACTIVE, non-expired row per user (latest expires_at wins), same rules as GetActiveSubscriptionByUserID.
|
||||
func (q *Queries) ListActiveSubscriptionsByUserIDs(ctx context.Context, dollar_1 []int64) ([]ListActiveSubscriptionsByUserIDsRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListActiveSubscriptionsByUserIDs, dollar_1)
|
||||
// Display status for admin user lists: ACTIVE (non-expired), else latest PENDING, else Unsubscribed.
|
||||
func (q *Queries) ListSubscriptionDisplayStatusesByUserIDs(ctx context.Context, dollar_1 []int64) ([]ListSubscriptionDisplayStatusesByUserIDsRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListSubscriptionDisplayStatusesByUserIDs, dollar_1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListActiveSubscriptionsByUserIDsRow
|
||||
var items []ListSubscriptionDisplayStatusesByUserIDsRow
|
||||
for rows.Next() {
|
||||
var i ListActiveSubscriptionsByUserIDsRow
|
||||
if err := rows.Scan(
|
||||
&i.UserID,
|
||||
&i.ID,
|
||||
&i.PlanID,
|
||||
&i.StartsAt,
|
||||
&i.ExpiresAt,
|
||||
&i.Status,
|
||||
&i.AutoRenew,
|
||||
&i.PaymentMethod,
|
||||
&i.PlanName,
|
||||
&i.DurationValue,
|
||||
&i.DurationUnit,
|
||||
&i.Price,
|
||||
&i.Currency,
|
||||
); err != nil {
|
||||
var i ListSubscriptionDisplayStatusesByUserIDsRow
|
||||
if err := rows.Scan(&i.UserID, &i.SubscriptionStatus); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,60 @@ import (
|
|||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const BulkDeactivateTeamMembersByRole = `-- name: BulkDeactivateTeamMembersByRole :execrows
|
||||
UPDATE team_members
|
||||
SET
|
||||
status = 'inactive',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE
|
||||
team_role = $1
|
||||
AND (
|
||||
$2::BIGINT IS NULL
|
||||
OR id <> $2::BIGINT
|
||||
)
|
||||
AND status = 'active'
|
||||
`
|
||||
|
||||
type BulkDeactivateTeamMembersByRoleParams struct {
|
||||
TeamRole string `json:"team_role"`
|
||||
ExcludeTeamMemberID pgtype.Int8 `json:"exclude_team_member_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) BulkDeactivateTeamMembersByRole(ctx context.Context, arg BulkDeactivateTeamMembersByRoleParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, BulkDeactivateTeamMembersByRole, arg.TeamRole, arg.ExcludeTeamMemberID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const BulkReactivateTeamMembersByRole = `-- name: BulkReactivateTeamMembersByRole :execrows
|
||||
UPDATE team_members
|
||||
SET
|
||||
status = 'active',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE
|
||||
team_role = $1
|
||||
AND (
|
||||
$2::BIGINT IS NULL
|
||||
OR id <> $2::BIGINT
|
||||
)
|
||||
AND status = 'inactive'
|
||||
`
|
||||
|
||||
type BulkReactivateTeamMembersByRoleParams struct {
|
||||
TeamRole string `json:"team_role"`
|
||||
ExcludeTeamMemberID pgtype.Int8 `json:"exclude_team_member_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) BulkReactivateTeamMembersByRole(ctx context.Context, arg BulkReactivateTeamMembersByRoleParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, BulkReactivateTeamMembersByRole, arg.TeamRole, arg.ExcludeTeamMemberID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const CheckTeamMemberEmailExists = `-- name: CheckTeamMemberEmailExists :one
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM team_members WHERE email = $1
|
||||
|
|
|
|||
|
|
@ -11,6 +11,54 @@ import (
|
|||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const BulkDeactivateUsersByRole = `-- name: BulkDeactivateUsersByRole :execrows
|
||||
UPDATE users
|
||||
SET
|
||||
status = 'DEACTIVATED',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE
|
||||
role = $1
|
||||
AND id <> $2
|
||||
AND status <> 'DEACTIVATED'
|
||||
`
|
||||
|
||||
type BulkDeactivateUsersByRoleParams struct {
|
||||
Role string `json:"role"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) BulkDeactivateUsersByRole(ctx context.Context, arg BulkDeactivateUsersByRoleParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, BulkDeactivateUsersByRole, arg.Role, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const BulkReactivateUsersByRole = `-- name: BulkReactivateUsersByRole :execrows
|
||||
UPDATE users
|
||||
SET
|
||||
status = 'ACTIVE',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE
|
||||
role = $1
|
||||
AND id <> $2
|
||||
AND status = 'DEACTIVATED'
|
||||
`
|
||||
|
||||
type BulkReactivateUsersByRoleParams struct {
|
||||
Role string `json:"role"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) BulkReactivateUsersByRole(ctx context.Context, arg BulkReactivateUsersByRoleParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, BulkReactivateUsersByRole, arg.Role, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const CheckPhoneEmailExist = `-- name: CheckPhoneEmailExist :one
|
||||
SELECT
|
||||
EXISTS (
|
||||
|
|
@ -386,9 +434,49 @@ WHERE
|
|||
))
|
||||
AND ($4::TIMESTAMPTZ IS NULL OR created_at >= $4::TIMESTAMPTZ)
|
||||
AND ($5::TIMESTAMPTZ IS NULL OR created_at <= $5::TIMESTAMPTZ)
|
||||
AND ($6::TEXT IS NULL OR LOWER(TRIM(COALESCE(country, ''))) = LOWER(TRIM($6::TEXT)))
|
||||
AND ($7::TEXT IS NULL OR LOWER(TRIM(COALESCE(region, ''))) = LOWER(TRIM($7::TEXT)))
|
||||
AND (
|
||||
$8::TEXT IS NULL
|
||||
OR (
|
||||
$8::TEXT = 'ACTIVE'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM user_subscriptions us
|
||||
WHERE us.user_id = users.id
|
||||
AND us.status = 'ACTIVE'
|
||||
AND us.expires_at > CURRENT_TIMESTAMP
|
||||
)
|
||||
)
|
||||
OR (
|
||||
$8::TEXT = 'PENDING'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_subscriptions us
|
||||
WHERE us.user_id = users.id
|
||||
AND us.status = 'ACTIVE'
|
||||
AND us.expires_at > CURRENT_TIMESTAMP
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM user_subscriptions us
|
||||
WHERE us.user_id = users.id AND us.status = 'PENDING'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
$8::TEXT = 'Unsubscribed'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_subscriptions us
|
||||
WHERE us.user_id = users.id
|
||||
AND us.status = 'ACTIVE'
|
||||
AND us.expires_at > CURRENT_TIMESTAMP
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_subscriptions us
|
||||
WHERE us.user_id = users.id AND us.status = 'PENDING'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $7::INT
|
||||
OFFSET $6::INT
|
||||
LIMIT $10::INT
|
||||
OFFSET $9::INT
|
||||
`
|
||||
|
||||
type GetAllUsersParams struct {
|
||||
|
|
@ -397,6 +485,9 @@ type GetAllUsersParams struct {
|
|||
Query pgtype.Text `json:"query"`
|
||||
CreatedAfter pgtype.Timestamptz `json:"created_after"`
|
||||
CreatedBefore pgtype.Timestamptz `json:"created_before"`
|
||||
Country pgtype.Text `json:"country"`
|
||||
Region pgtype.Text `json:"region"`
|
||||
SubscriptionStatus pgtype.Text `json:"subscription_status"`
|
||||
Offset pgtype.Int4 `json:"offset"`
|
||||
Limit pgtype.Int4 `json:"limit"`
|
||||
}
|
||||
|
|
@ -441,6 +532,9 @@ func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]Get
|
|||
arg.Query,
|
||||
arg.CreatedAfter,
|
||||
arg.CreatedBefore,
|
||||
arg.Country,
|
||||
arg.Region,
|
||||
arg.SubscriptionStatus,
|
||||
arg.Offset,
|
||||
arg.Limit,
|
||||
)
|
||||
|
|
@ -773,6 +867,19 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
|||
return i, err
|
||||
}
|
||||
|
||||
const GetUserCreatedAt = `-- name: GetUserCreatedAt :one
|
||||
SELECT created_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserCreatedAt(ctx context.Context, id int64) (pgtype.Timestamptz, error) {
|
||||
row := q.db.QueryRow(ctx, GetUserCreatedAt, id)
|
||||
var created_at pgtype.Timestamptz
|
||||
err := row.Scan(&created_at)
|
||||
return created_at, err
|
||||
}
|
||||
|
||||
const GetUserSummary = `-- name: GetUserSummary :one
|
||||
SELECT
|
||||
COUNT(*) AS total_users,
|
||||
|
|
|
|||
388
gen/db/user_recent_activity.sql.go
Normal file
388
gen/db/user_recent_activity.sql.go
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: user_recent_activity.sql
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const ListUserCourseCompletionsRecentActivity = `-- name: ListUserCourseCompletionsRecentActivity :many
|
||||
SELECT
|
||||
crf.completed_at AS occurred_at,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
lms_user_course_progress crf
|
||||
INNER JOIN courses c ON c.id = crf.course_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
WHERE
|
||||
crf.user_id = $1
|
||||
`
|
||||
|
||||
type ListUserCourseCompletionsRecentActivityRow struct {
|
||||
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
|
||||
CourseID int64 `json:"course_id"`
|
||||
CourseName string `json:"course_name"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
ProgramName string `json:"program_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListUserCourseCompletionsRecentActivity(ctx context.Context, userID int64) ([]ListUserCourseCompletionsRecentActivityRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListUserCourseCompletionsRecentActivity, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUserCourseCompletionsRecentActivityRow
|
||||
for rows.Next() {
|
||||
var i ListUserCourseCompletionsRecentActivityRow
|
||||
if err := rows.Scan(
|
||||
&i.OccurredAt,
|
||||
&i.CourseID,
|
||||
&i.CourseName,
|
||||
&i.ProgramID,
|
||||
&i.ProgramName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListUserLessonCompletionsRecentActivity = `-- name: ListUserLessonCompletionsRecentActivity :many
|
||||
|
||||
SELECT
|
||||
ulp.completed_at AS occurred_at,
|
||||
l.id AS lesson_id,
|
||||
l.title AS lesson_title,
|
||||
m.id AS module_id,
|
||||
m.name AS module_name,
|
||||
m.sort_order AS module_sort_order,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
lms_user_lesson_progress ulp
|
||||
INNER JOIN lessons l ON l.id = ulp.lesson_id
|
||||
INNER JOIN modules m ON m.id = l.module_id
|
||||
INNER JOIN courses c ON c.id = m.course_id
|
||||
AND c.program_id = m.program_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
WHERE
|
||||
ulp.user_id = $1
|
||||
`
|
||||
|
||||
type ListUserLessonCompletionsRecentActivityRow struct {
|
||||
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
|
||||
LessonID int64 `json:"lesson_id"`
|
||||
LessonTitle string `json:"lesson_title"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
ModuleName string `json:"module_name"`
|
||||
ModuleSortOrder int32 `json:"module_sort_order"`
|
||||
CourseID int64 `json:"course_id"`
|
||||
CourseName string `json:"course_name"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
ProgramName string `json:"program_name"`
|
||||
}
|
||||
|
||||
// Recent activity feed: LMS completion milestones (chronological merge in application code).
|
||||
func (q *Queries) ListUserLessonCompletionsRecentActivity(ctx context.Context, userID int64) ([]ListUserLessonCompletionsRecentActivityRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListUserLessonCompletionsRecentActivity, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUserLessonCompletionsRecentActivityRow
|
||||
for rows.Next() {
|
||||
var i ListUserLessonCompletionsRecentActivityRow
|
||||
if err := rows.Scan(
|
||||
&i.OccurredAt,
|
||||
&i.LessonID,
|
||||
&i.LessonTitle,
|
||||
&i.ModuleID,
|
||||
&i.ModuleName,
|
||||
&i.ModuleSortOrder,
|
||||
&i.CourseID,
|
||||
&i.CourseName,
|
||||
&i.ProgramID,
|
||||
&i.ProgramName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListUserModuleCompletionsRecentActivity = `-- name: ListUserModuleCompletionsRecentActivity :many
|
||||
SELECT
|
||||
mrf.completed_at AS occurred_at,
|
||||
m.id AS module_id,
|
||||
m.name AS module_name,
|
||||
m.sort_order AS module_sort_order,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
lms_user_module_progress mrf
|
||||
INNER JOIN modules m ON m.id = mrf.module_id
|
||||
INNER JOIN courses c ON c.id = m.course_id
|
||||
AND c.program_id = m.program_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
WHERE
|
||||
mrf.user_id = $1
|
||||
`
|
||||
|
||||
type ListUserModuleCompletionsRecentActivityRow struct {
|
||||
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
ModuleName string `json:"module_name"`
|
||||
ModuleSortOrder int32 `json:"module_sort_order"`
|
||||
CourseID int64 `json:"course_id"`
|
||||
CourseName string `json:"course_name"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
ProgramName string `json:"program_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListUserModuleCompletionsRecentActivity(ctx context.Context, userID int64) ([]ListUserModuleCompletionsRecentActivityRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListUserModuleCompletionsRecentActivity, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUserModuleCompletionsRecentActivityRow
|
||||
for rows.Next() {
|
||||
var i ListUserModuleCompletionsRecentActivityRow
|
||||
if err := rows.Scan(
|
||||
&i.OccurredAt,
|
||||
&i.ModuleID,
|
||||
&i.ModuleName,
|
||||
&i.ModuleSortOrder,
|
||||
&i.CourseID,
|
||||
&i.CourseName,
|
||||
&i.ProgramID,
|
||||
&i.ProgramName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListUserPracticeCompletionsRecentActivity = `-- name: ListUserPracticeCompletionsRecentActivity :many
|
||||
SELECT
|
||||
x.occurred_at,
|
||||
x.scope,
|
||||
x.lms_practice_id,
|
||||
x.practice_title,
|
||||
COALESCE(x.lesson_id, 0)::BIGINT AS lesson_id,
|
||||
COALESCE(x.lesson_title, '')::TEXT AS lesson_title,
|
||||
COALESCE(x.module_id, 0)::BIGINT AS module_id,
|
||||
COALESCE(x.module_name, '')::TEXT AS module_name,
|
||||
COALESCE(x.module_sort_order, 0)::INT AS module_sort_order,
|
||||
x.course_id,
|
||||
x.course_name,
|
||||
x.program_id,
|
||||
x.program_name
|
||||
FROM (
|
||||
SELECT
|
||||
upp.completed_at AS occurred_at,
|
||||
'lesson'::TEXT AS scope,
|
||||
lp.id AS lms_practice_id,
|
||||
lp.title AS practice_title,
|
||||
l.id AS lesson_id,
|
||||
l.title AS lesson_title,
|
||||
m.id AS module_id,
|
||||
m.name AS module_name,
|
||||
m.sort_order AS module_sort_order,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
user_practice_progress upp
|
||||
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
|
||||
AND lp.lesson_id IS NOT NULL
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
INNER JOIN lessons l ON l.id = lp.lesson_id
|
||||
INNER JOIN modules m ON m.id = l.module_id
|
||||
INNER JOIN courses c ON c.id = m.course_id
|
||||
AND c.program_id = m.program_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
upp.completed_at,
|
||||
'module'::TEXT,
|
||||
lp.id,
|
||||
lp.title,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
m.id,
|
||||
m.name,
|
||||
m.sort_order,
|
||||
c.id,
|
||||
c.name,
|
||||
p.id,
|
||||
p.name
|
||||
FROM
|
||||
user_practice_progress upp
|
||||
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
|
||||
AND lp.module_id IS NOT NULL
|
||||
AND lp.lesson_id IS NULL
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
INNER JOIN modules m ON m.id = lp.module_id
|
||||
INNER JOIN courses c ON c.id = m.course_id
|
||||
AND c.program_id = m.program_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
upp.completed_at,
|
||||
'course'::TEXT,
|
||||
lp.id,
|
||||
lp.title,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
NULL::INT,
|
||||
c.id,
|
||||
c.name,
|
||||
p.id,
|
||||
p.name
|
||||
FROM
|
||||
user_practice_progress upp
|
||||
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
|
||||
AND lp.course_id IS NOT NULL
|
||||
AND lp.module_id IS NULL
|
||||
AND lp.lesson_id IS NULL
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
INNER JOIN courses c ON c.id = lp.course_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
) AS x
|
||||
`
|
||||
|
||||
type ListUserPracticeCompletionsRecentActivityRow struct {
|
||||
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
|
||||
Scope string `json:"scope"`
|
||||
LmsPracticeID int64 `json:"lms_practice_id"`
|
||||
PracticeTitle string `json:"practice_title"`
|
||||
LessonID int64 `json:"lesson_id"`
|
||||
LessonTitle string `json:"lesson_title"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
ModuleName string `json:"module_name"`
|
||||
ModuleSortOrder int32 `json:"module_sort_order"`
|
||||
CourseID int64 `json:"course_id"`
|
||||
CourseName string `json:"course_name"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
ProgramName string `json:"program_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListUserPracticeCompletionsRecentActivity(ctx context.Context, userID int64) ([]ListUserPracticeCompletionsRecentActivityRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListUserPracticeCompletionsRecentActivity, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUserPracticeCompletionsRecentActivityRow
|
||||
for rows.Next() {
|
||||
var i ListUserPracticeCompletionsRecentActivityRow
|
||||
if err := rows.Scan(
|
||||
&i.OccurredAt,
|
||||
&i.Scope,
|
||||
&i.LmsPracticeID,
|
||||
&i.PracticeTitle,
|
||||
&i.LessonID,
|
||||
&i.LessonTitle,
|
||||
&i.ModuleID,
|
||||
&i.ModuleName,
|
||||
&i.ModuleSortOrder,
|
||||
&i.CourseID,
|
||||
&i.CourseName,
|
||||
&i.ProgramID,
|
||||
&i.ProgramName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListUserProgramCompletionsRecentActivity = `-- name: ListUserProgramCompletionsRecentActivity :many
|
||||
SELECT
|
||||
prf.completed_at AS occurred_at,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
lms_user_program_progress prf
|
||||
INNER JOIN programs p ON p.id = prf.program_id
|
||||
WHERE
|
||||
prf.user_id = $1
|
||||
`
|
||||
|
||||
type ListUserProgramCompletionsRecentActivityRow struct {
|
||||
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
ProgramName string `json:"program_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListUserProgramCompletionsRecentActivity(ctx context.Context, userID int64) ([]ListUserProgramCompletionsRecentActivityRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListUserProgramCompletionsRecentActivity, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUserProgramCompletionsRecentActivityRow
|
||||
for rows.Next() {
|
||||
var i ListUserProgramCompletionsRecentActivityRow
|
||||
if err := rows.Scan(&i.OccurredAt, &i.ProgramID, &i.ProgramName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
28
internal/domain/bulk_deactivate_accounts.go
Normal file
28
internal/domain/bulk_deactivate_accounts.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package domain
|
||||
|
||||
// BulkAccountsByRoleRequest is optional JSON for POST /admin/roles/:role/bulk-{deactivate,reactivate}.
|
||||
// Optional exclusions apply to team_members bulk updates only.
|
||||
// Path :role is a platform/team role key or a numeric RBAC roles.id string (decimal digits resolve via GET /api/v1/rbac/roles ids).
|
||||
type BulkAccountsByRoleRequest struct {
|
||||
ExcludeTeamMemberID *int64 `json:"exclude_team_member_id,omitempty"`
|
||||
}
|
||||
|
||||
// BulkDeactivateAccountsByRoleRequest aliases the shared shape (backward compatible name for Swagger).
|
||||
type BulkDeactivateAccountsByRoleRequest = BulkAccountsByRoleRequest
|
||||
|
||||
// BulkReactivateAccountsByRoleRequest aliases the shared shape.
|
||||
type BulkReactivateAccountsByRoleRequest = BulkAccountsByRoleRequest
|
||||
|
||||
// BulkDeactivateAccountsByRoleResult reports how many rows were updated per table.
|
||||
type BulkDeactivateAccountsByRoleResult struct {
|
||||
Role string `json:"role"`
|
||||
UsersDeactivated int64 `json:"users_deactivated"`
|
||||
TeamMembersDeactivated int64 `json:"team_members_deactivated"`
|
||||
}
|
||||
|
||||
// BulkReactivateAccountsByRoleResult reports how many rows were reactivated per table.
|
||||
type BulkReactivateAccountsByRoleResult struct {
|
||||
Role string `json:"role"`
|
||||
UsersReactivated int64 `json:"users_reactivated"`
|
||||
TeamMembersReactivated int64 `json:"team_members_reactivated"`
|
||||
}
|
||||
|
|
@ -36,6 +36,8 @@ type CreateCourseInput struct {
|
|||
Name string `json:"name" validate:"required"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
// SortOrder within the program when set; omit to append after current max within program_id (uniqueness is per-program).
|
||||
SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"`
|
||||
}
|
||||
|
||||
type UpdateCourseInput struct {
|
||||
|
|
|
|||
|
|
@ -11,11 +11,17 @@ type ExamPrepPractice struct {
|
|||
StoryImage *string `json:"story_image,omitempty"`
|
||||
PersonaID *int64 `json:"persona_id,omitempty"`
|
||||
QuestionSetID int64 `json:"question_set_id"`
|
||||
PublishStatus PracticePublishStatus `json:"publish_status"`
|
||||
QuickTips *string `json:"quick_tips,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// VisibleToLearners mirrors LMS practice visibility rules for subscribers.
|
||||
func (p ExamPrepPractice) VisibleToLearners() bool {
|
||||
return p.PublishStatus == PracticePublishPublished
|
||||
}
|
||||
|
||||
// CreateExamPrepPracticeInput is the body for POST .../exam-prep/lessons/{lessonId}/practices (lesson from path).
|
||||
type CreateExamPrepPracticeInput struct {
|
||||
Title string `json:"title" validate:"required"`
|
||||
|
|
@ -24,6 +30,7 @@ type CreateExamPrepPracticeInput struct {
|
|||
PersonaID *int64 `json:"persona_id,omitempty"`
|
||||
QuestionSetID int64 `json:"question_set_id" validate:"required,gt=0"`
|
||||
QuickTips *string `json:"quick_tips,omitempty"`
|
||||
PublishStatus string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
|
||||
}
|
||||
|
||||
type UpdateExamPrepPracticeInput struct {
|
||||
|
|
@ -33,4 +40,5 @@ type UpdateExamPrepPracticeInput struct {
|
|||
PersonaID *int64 `json:"persona_id,omitempty"`
|
||||
QuestionSetID *int64 `json:"question_set_id,omitempty"`
|
||||
QuickTips *string `json:"quick_tips,omitempty"`
|
||||
PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
|
||||
}
|
||||
|
|
|
|||
56
internal/domain/lms_admin_learning.go
Normal file
56
internal/domain/lms_admin_learning.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// AdminLMSUserLearningActivityTree is a nested LMS view for admins: programs → courses → modules,
|
||||
// plus lessons/practices where the learner has recorded completion (see API description for schema limits).
|
||||
type AdminLMSUserLearningActivityTree struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Programs []AdminLMSProgramLearningEntry `json:"programs"`
|
||||
}
|
||||
|
||||
// AdminLMSProgramLearningEntry aggregates activity under one program (sequential LMS track).
|
||||
type AdminLMSProgramLearningEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
RollupFullyCompletedAt *time.Time `json:"rollup_completed_at,omitempty"`
|
||||
Courses []AdminLMSCourseLearningEntry `json:"courses"`
|
||||
}
|
||||
|
||||
// AdminLMSCourseLearningEntry aggregates activity under one course inside a program.
|
||||
type AdminLMSCourseLearningEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
RollupFullyCompletedAt *time.Time `json:"rollup_completed_at,omitempty"`
|
||||
Modules []AdminLMSModuleLearningEntry `json:"modules"`
|
||||
CourseLevelPractices []AdminLMSPracticeLearningEntry `json:"course_level_practices,omitempty"`
|
||||
}
|
||||
|
||||
// AdminLMSModuleLearningEntry aggregates activity under one module inside a course.
|
||||
type AdminLMSModuleLearningEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
RollupFullyCompletedAt *time.Time `json:"rollup_completed_at,omitempty"`
|
||||
Lessons []AdminLMSLessonLearningEntry `json:"lessons,omitempty"`
|
||||
ModuleScopedPractices []AdminLMSPracticeLearningEntry `json:"module_practices,omitempty"`
|
||||
}
|
||||
|
||||
// AdminLMSLessonLearningEntry is lesson-scoped LMS activity (lesson marked complete and/or lesson practices completed).
|
||||
type AdminLMSLessonLearningEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
LessonScopedPractices []AdminLMSPracticeLearningEntry `json:"lesson_practices,omitempty"`
|
||||
}
|
||||
|
||||
// AdminLMSPracticeLearningEntry is an LMS practice completion (lesson-, module-, or course-scoped).
|
||||
type AdminLMSPracticeLearningEntry struct {
|
||||
LMSPracticeID int64 `json:"lms_practice_id"`
|
||||
Title string `json:"title"`
|
||||
Scope string `json:"scope"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ type CreateModuleInput struct {
|
|||
Name string `json:"name" validate:"required"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
// SortOrder within the course when set; omit to append after current max within course_id (uniqueness is per-course).
|
||||
SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"`
|
||||
}
|
||||
|
||||
type UpdateModuleInput struct {
|
||||
|
|
|
|||
|
|
@ -137,6 +137,8 @@ func ReceiverFromRole(role Role) NotificationRecieverSide {
|
|||
return NotificationRecieverSideAdmin
|
||||
case RoleStudent:
|
||||
return NotificationRecieverSideCustomer
|
||||
case RoleOpenLearner:
|
||||
return NotificationRecieverSideCustomer
|
||||
case RoleInstructor:
|
||||
return NotificationRecieverSideCustomer
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ParentKind identifies which hierarchy entity owns a practice (exactly one).
|
||||
type ParentKind string
|
||||
|
|
@ -11,6 +14,31 @@ const (
|
|||
ParentKindLesson ParentKind = "LESSON"
|
||||
)
|
||||
|
||||
// PracticePublishStatus controls learner visibility for a practice shell (independent of question_set.status).
|
||||
type PracticePublishStatus string
|
||||
|
||||
const (
|
||||
PracticePublishDraft PracticePublishStatus = "DRAFT"
|
||||
PracticePublishPublished PracticePublishStatus = "PUBLISHED"
|
||||
)
|
||||
|
||||
// ParsePracticePublishStatusInput maps API input. Empty or unknown values default to PUBLISHED for backward compatibility.
|
||||
func ParsePracticePublishStatusInput(raw string) PracticePublishStatus {
|
||||
switch strings.TrimSpace(strings.ToUpper(raw)) {
|
||||
case string(PracticePublishDraft):
|
||||
return PracticePublishDraft
|
||||
case string(PracticePublishPublished):
|
||||
return PracticePublishPublished
|
||||
default:
|
||||
return PracticePublishPublished
|
||||
}
|
||||
}
|
||||
|
||||
// PracticePublishStatusFromDB maps persisted values into the domain type.
|
||||
func PracticePublishStatusFromDB(raw string) PracticePublishStatus {
|
||||
return ParsePracticePublishStatusInput(raw)
|
||||
}
|
||||
|
||||
// Practice is question-set content (story, persona, tips) scoped to a course, module, or lesson.
|
||||
type Practice struct {
|
||||
ID int64 `json:"id"`
|
||||
|
|
@ -21,11 +49,17 @@ type Practice struct {
|
|||
StoryImage *string `json:"story_image,omitempty"`
|
||||
PersonaID *int64 `json:"persona_id,omitempty"`
|
||||
QuestionSetID int64 `json:"question_set_id"`
|
||||
PublishStatus PracticePublishStatus `json:"publish_status"`
|
||||
QuickTips *string `json:"quick_tips,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// VisibleToLearners is true when the practice shell should appear in subscribed learner catalogs and progression.
|
||||
func (p Practice) VisibleToLearners() bool {
|
||||
return p.PublishStatus == PracticePublishPublished
|
||||
}
|
||||
|
||||
type CreatePracticeInput struct {
|
||||
ParentKind ParentKind `json:"parent_kind" validate:"required,oneof=COURSE MODULE LESSON"`
|
||||
ParentID int64 `json:"parent_id" validate:"required,gt=0"`
|
||||
|
|
@ -35,6 +69,8 @@ type CreatePracticeInput struct {
|
|||
PersonaID *int64 `json:"persona_id,omitempty"`
|
||||
QuestionSetID int64 `json:"question_set_id" validate:"required,gt=0"`
|
||||
QuickTips *string `json:"quick_tips,omitempty"`
|
||||
// Omit or empty for backward compatibility defaults to PUBLISHED; set DRAFT to save hidden from learners until published.
|
||||
PublishStatus string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
|
||||
}
|
||||
|
||||
type UpdatePracticeInput struct {
|
||||
|
|
@ -44,4 +80,5 @@ type UpdatePracticeInput struct {
|
|||
PersonaID *int64 `json:"persona_id,omitempty"`
|
||||
QuestionSetID *int64 `json:"question_set_id,omitempty"`
|
||||
QuickTips *string `json:"quick_tips,omitempty"`
|
||||
PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ type CreateProgramInput struct {
|
|||
Name string `json:"name" validate:"required"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
// SortOrder inserts at this global program order when set; omit to append after current max (sort_order uniqueness is enforced).
|
||||
SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"`
|
||||
}
|
||||
|
||||
type UpdateProgramInput struct {
|
||||
|
|
|
|||
|
|
@ -6,19 +6,31 @@ const (
|
|||
RoleSuperAdmin Role = "SUPER_ADMIN"
|
||||
RoleAdmin Role = "ADMIN"
|
||||
RoleStudent Role = "STUDENT"
|
||||
// RoleOpenLearner can consume LMS content like a learner but without sequential prerequisite locking (step-by-step gates).
|
||||
RoleOpenLearner Role = "OPEN_LEARNER"
|
||||
RoleInstructor Role = "INSTRUCTOR"
|
||||
RoleSupport Role = "SUPPORT"
|
||||
)
|
||||
|
||||
func (r Role) IsValid() bool {
|
||||
switch r {
|
||||
case RoleSuperAdmin, RoleAdmin, RoleStudent, RoleInstructor, RoleSupport:
|
||||
case RoleSuperAdmin, RoleAdmin, RoleStudent, RoleOpenLearner, RoleInstructor, RoleSupport:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UsesLMSSequentialGating is true when LMS APIs apply sequential prerequisite locks (403 when blocked).
|
||||
func (r Role) UsesLMSSequentialGating() bool {
|
||||
return r == RoleStudent
|
||||
}
|
||||
|
||||
// IsCustomerLearnerRole is true for platform roles that sign in as customers and consume learner-facing LMS APIs.
|
||||
func (r Role) IsCustomerLearnerRole() bool {
|
||||
return r == RoleStudent || r == RoleOpenLearner
|
||||
}
|
||||
|
||||
func (r Role) Value() string {
|
||||
return string(r)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,54 +56,6 @@ type UserSubscription struct {
|
|||
Currency *string
|
||||
}
|
||||
|
||||
// UserSubscriptionSummary is the active subscription attached to admin user list responses (GET /users).
|
||||
type UserSubscriptionSummary struct {
|
||||
ID int64 `json:"id"`
|
||||
PlanID int64 `json:"plan_id"`
|
||||
PlanName string `json:"plan_name"`
|
||||
Status string `json:"status"`
|
||||
StartsAt time.Time `json:"starts_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
PaymentMethod *string `json:"payment_method,omitempty"`
|
||||
DurationValue int32 `json:"duration_value"`
|
||||
DurationUnit string `json:"duration_unit"`
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// Summary returns a copy safe for JSON embedding; nil if receiver is nil.
|
||||
func (us *UserSubscription) Summary() *UserSubscriptionSummary {
|
||||
if us == nil {
|
||||
return nil
|
||||
}
|
||||
s := &UserSubscriptionSummary{
|
||||
ID: us.ID,
|
||||
PlanID: us.PlanID,
|
||||
Status: us.Status,
|
||||
StartsAt: us.StartsAt,
|
||||
ExpiresAt: us.ExpiresAt,
|
||||
AutoRenew: us.AutoRenew,
|
||||
PaymentMethod: us.PaymentMethod,
|
||||
}
|
||||
if us.PlanName != nil {
|
||||
s.PlanName = *us.PlanName
|
||||
}
|
||||
if us.DurationValue != nil {
|
||||
s.DurationValue = *us.DurationValue
|
||||
}
|
||||
if us.DurationUnit != nil {
|
||||
s.DurationUnit = *us.DurationUnit
|
||||
}
|
||||
if us.Price != nil {
|
||||
s.Price = *us.Price
|
||||
}
|
||||
if us.Currency != nil {
|
||||
s.Currency = *us.Currency
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type CreateSubscriptionPlanInput struct {
|
||||
Name string
|
||||
Description *string
|
||||
|
|
|
|||
|
|
@ -121,12 +121,15 @@ type UserProfileResponse struct {
|
|||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
|
||||
ActiveSubscription *UserSubscriptionSummary `json:"active_subscription,omitempty"`
|
||||
SubscriptionStatus string `json:"subscription_status"`
|
||||
}
|
||||
|
||||
type UserFilter struct {
|
||||
Role string
|
||||
Status string
|
||||
Country string
|
||||
Region string
|
||||
SubscriptionStatus string // display filter: ACTIVE, PENDING, Unsubscribed (same as API subscription_status values)
|
||||
|
||||
Page int64
|
||||
PageSize int64
|
||||
|
|
|
|||
67
internal/domain/user_recent_activity.go
Normal file
67
internal/domain/user_recent_activity.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// User-recent-activity feed kinds (for timeline UI: icon + copy).
|
||||
const (
|
||||
UserRecentActivityJoined = "joined"
|
||||
UserRecentActivityLessonCompleted = "lesson_completed"
|
||||
UserRecentActivityModuleCompleted = "module_completed"
|
||||
UserRecentActivityCourseCompleted = "course_completed"
|
||||
UserRecentActivityProgramCompleted = "program_completed"
|
||||
UserRecentActivityPracticeCompleted = "practice_completed"
|
||||
)
|
||||
|
||||
// UserRecentActivityFeed is a reverse-chronological list of notable user events (account + LMS completions).
|
||||
type UserRecentActivityFeed struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Items []UserRecentActivityItem `json:"items"`
|
||||
}
|
||||
|
||||
// UserRecentActivityItem is one row in a profile or admin "Recent activity" timeline.
|
||||
type UserRecentActivityItem struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
// Kind drives default icon treatment on the client (e.g. joined vs completion).
|
||||
Kind string `json:"kind"`
|
||||
|
||||
// OccurredAt is when the platform recorded the event (typically completion time).
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
|
||||
// Headline is optional server-rendered primary line (client may ignore and build from refs).
|
||||
Headline string `json:"headline"`
|
||||
|
||||
Program *RecentActivityProgramRef `json:"program,omitempty"`
|
||||
Course *RecentActivityCourseRef `json:"course,omitempty"`
|
||||
Module *RecentActivityModuleRef `json:"module,omitempty"`
|
||||
Lesson *RecentActivityLessonRef `json:"lesson,omitempty"`
|
||||
Practice *RecentActivityPracticeRef `json:"practice,omitempty"`
|
||||
}
|
||||
|
||||
type RecentActivityProgramRef struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type RecentActivityCourseRef struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type RecentActivityModuleRef struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type RecentActivityLessonRef struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
SortOrder int32 `json:"sort_order,omitempty"`
|
||||
}
|
||||
|
||||
type RecentActivityPracticeRef struct {
|
||||
LMSPracticeID int64 `json:"lms_practice_id"`
|
||||
Title string `json:"title"`
|
||||
Scope string `json:"scope"` // lesson | module | course
|
||||
}
|
||||
|
|
@ -9,7 +9,8 @@ import (
|
|||
type ExamPrepPracticeStore interface {
|
||||
CreateExamPrepLessonPractice(ctx context.Context, lessonID int64, in domain.CreateExamPrepPracticeInput) (domain.ExamPrepPractice, error)
|
||||
GetExamPrepLessonPracticeByID(ctx context.Context, id int64) (domain.ExamPrepPractice, error)
|
||||
ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.ExamPrepPractice, int64, error)
|
||||
TryGetExamPrepLessonPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (domain.ExamPrepPractice, bool, error)
|
||||
ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepPractice, int64, error)
|
||||
UpdateExamPrepLessonPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error)
|
||||
DeleteExamPrepLessonPractice(ctx context.Context, id int64) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ type LmsPracticeStore interface {
|
|||
courseID, moduleID, lessonID *int64,
|
||||
) (domain.Practice, error)
|
||||
GetLmsPracticeByID(ctx context.Context, id int64) (domain.Practice, error)
|
||||
ListLmsPracticesByCourseID(ctx context.Context, courseID int64, limit, offset int32) ([]domain.Practice, int64, error)
|
||||
ListLmsPracticesByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Practice, int64, error)
|
||||
ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.Practice, int64, error)
|
||||
// TryGetLmsPracticeByQuestionSetID returns false when no LMS practice row references the question set.
|
||||
TryGetLmsPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (domain.Practice, bool, error)
|
||||
ListLmsPracticesByCourseID(ctx context.Context, courseID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error)
|
||||
ListLmsPracticesByModuleID(ctx context.Context, moduleID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error)
|
||||
ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error)
|
||||
UpdateLmsPractice(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error)
|
||||
DeleteLmsPractice(ctx context.Context, id int64) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ type SubscriptionStore interface {
|
|||
CreateUserSubscription(ctx context.Context, input domain.CreateUserSubscriptionInput) (*domain.UserSubscription, error)
|
||||
GetUserSubscriptionByID(ctx context.Context, id int64) (*domain.UserSubscription, error)
|
||||
GetActiveSubscriptionByUserID(ctx context.Context, userID int64) (*domain.UserSubscription, error)
|
||||
ListActiveSubscriptionsByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*domain.UserSubscription, error)
|
||||
ListSubscriptionDisplayStatusesByUserIDs(ctx context.Context, userIDs []int64) (map[int64]string, error)
|
||||
GetSubscriptionDisplayStatusByUserID(ctx context.Context, userID int64) (string, error)
|
||||
GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error)
|
||||
HasActiveSubscription(ctx context.Context, userID int64) (bool, error)
|
||||
CancelUserSubscription(ctx context.Context, id int64) error
|
||||
|
|
|
|||
|
|
@ -36,4 +36,6 @@ type TeamStore interface {
|
|||
CreateTeamRefreshToken(ctx context.Context, memberID int64, token string, expiresAt, createdAt time.Time) error
|
||||
GetTeamRefreshTokenByToken(ctx context.Context, token string) (domain.TeamRefreshToken, error)
|
||||
RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error
|
||||
BulkDeactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error)
|
||||
BulkReactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ type UserStore interface {
|
|||
status *string,
|
||||
query *string,
|
||||
createdBefore, createdAfter *time.Time,
|
||||
country *string,
|
||||
region *string,
|
||||
subscriptionStatus *string,
|
||||
limit, offset int32,
|
||||
) ([]domain.User, int64, error)
|
||||
ListAccountDeletionRequests(ctx context.Context, filter domain.AccountDeletionRequestFilter) ([]domain.AccountDeletionRequest, int64, error)
|
||||
|
|
@ -82,6 +85,8 @@ type UserStore interface {
|
|||
GetUserDeviceTokens(ctx context.Context, userID int64) ([]string, error)
|
||||
DeactivateDevice(ctx context.Context, userID int64, deviceToken string) error
|
||||
DeactivateAllUserDevices(ctx context.Context, userID int64) error
|
||||
BulkDeactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error)
|
||||
BulkReactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error)
|
||||
}
|
||||
type SmsGateway interface {
|
||||
SendSMSOTP(ctx context.Context, phoneNumber, otp string) error
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ func examPrepPracticeFromListRow(r dbgen.ExamPrepListLessonPracticesByLessonIDRo
|
|||
PersonaID: r.PersonaID,
|
||||
QuestionSetID: r.QuestionSetID,
|
||||
QuickTips: r.QuickTips,
|
||||
PublishStatus: r.PublishStatus,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
})
|
||||
|
|
@ -32,6 +33,7 @@ func examPrepPracticeToDomain(p dbgen.ExamPrepLessonPractice) domain.ExamPrepPra
|
|||
LessonID: p.UnitModuleLessonID,
|
||||
Title: p.Title,
|
||||
QuestionSetID: p.QuestionSetID,
|
||||
PublishStatus: domain.PracticePublishStatusFromDB(p.PublishStatus),
|
||||
}
|
||||
out.StoryDescription = fromPgText(p.StoryDescription)
|
||||
out.StoryImage = fromPgText(p.StoryImage)
|
||||
|
|
@ -46,6 +48,7 @@ func examPrepPracticeToDomain(p dbgen.ExamPrepLessonPractice) domain.ExamPrepPra
|
|||
}
|
||||
|
||||
func (s *Store) CreateExamPrepLessonPractice(ctx context.Context, lessonID int64, in domain.CreateExamPrepPracticeInput) (domain.ExamPrepPractice, error) {
|
||||
ps := domain.ParsePracticePublishStatusInput(in.PublishStatus)
|
||||
p, err := s.queries.ExamPrepCreateLessonPractice(ctx, dbgen.ExamPrepCreateLessonPracticeParams{
|
||||
UnitModuleLessonID: lessonID,
|
||||
Title: in.Title,
|
||||
|
|
@ -54,6 +57,7 @@ func (s *Store) CreateExamPrepLessonPractice(ctx context.Context, lessonID int64
|
|||
PersonaID: int64PtrToPg8(in.PersonaID),
|
||||
QuestionSetID: in.QuestionSetID,
|
||||
QuickTips: toPgText(in.QuickTips),
|
||||
PublishStatus: string(ps),
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ExamPrepPractice{}, err
|
||||
|
|
@ -72,9 +76,22 @@ func (s *Store) GetExamPrepLessonPracticeByID(ctx context.Context, id int64) (do
|
|||
return examPrepPracticeToDomain(p), nil
|
||||
}
|
||||
|
||||
func (s *Store) ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) {
|
||||
// TryGetExamPrepLessonPracticeByQuestionSetID returns false when no row exists.
|
||||
func (s *Store) TryGetExamPrepLessonPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (domain.ExamPrepPractice, bool, error) {
|
||||
p, err := s.queries.ExamPrepGetLessonPracticeByQuestionSetID(ctx, questionSetID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepPractice{}, false, nil
|
||||
}
|
||||
return domain.ExamPrepPractice{}, false, err
|
||||
}
|
||||
return examPrepPracticeToDomain(p), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) {
|
||||
rows, err := s.queries.ExamPrepListLessonPracticesByLessonID(ctx, dbgen.ExamPrepListLessonPracticesByLessonIDParams{
|
||||
UnitModuleLessonID: lessonID,
|
||||
PublishedOnly: publishedOnly,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
|
|
@ -111,6 +128,7 @@ func (s *Store) UpdateExamPrepLessonPractice(ctx context.Context, id int64, inpu
|
|||
PersonaID: optionalInt8UpdateID(input.PersonaID),
|
||||
QuestionSetID: qs,
|
||||
QuickTips: optionalTextUpdate(input.QuickTips),
|
||||
PublishStatus: optionalPublishStatusUpdate(input.PublishStatus),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
|
|
|||
|
|
@ -29,11 +29,41 @@ func courseToDomain(c dbgen.Course) domain.Course {
|
|||
}
|
||||
|
||||
func (s *Store) CreateCourse(ctx context.Context, programID int64, input domain.CreateCourseInput) (domain.Course, error) {
|
||||
if input.SortOrder != nil {
|
||||
q, tx, err := s.BeginTx(ctx)
|
||||
if err != nil {
|
||||
return domain.Course{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
target := int32(*input.SortOrder)
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE courses SET sort_order = sort_order + 1 WHERE program_id = $1 AND sort_order >= $2`,
|
||||
programID, target,
|
||||
); err != nil {
|
||||
return domain.Course{}, err
|
||||
}
|
||||
c, err := q.CreateCourse(ctx, dbgen.CreateCourseParams{
|
||||
ProgramID: programID,
|
||||
Name: input.Name,
|
||||
Description: toPgText(input.Description),
|
||||
Thumbnail: toPgText(input.Thumbnail),
|
||||
SortOrder: pgtype.Int4{Int32: target, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Course{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Course{}, err
|
||||
}
|
||||
return courseToDomain(c), nil
|
||||
}
|
||||
|
||||
c, err := s.queries.CreateCourse(ctx, dbgen.CreateCourseParams{
|
||||
ProgramID: programID,
|
||||
Name: input.Name,
|
||||
Description: toPgText(input.Description),
|
||||
Thumbnail: toPgText(input.Thumbnail),
|
||||
SortOrder: pgtype.Int4{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Course{}, err
|
||||
|
|
@ -105,18 +135,56 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, lim
|
|||
}
|
||||
|
||||
func (s *Store) UpdateCourse(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error) {
|
||||
sortParam := optionalInt4Update(input.SortOrder)
|
||||
var nameText pgtype.Text
|
||||
if input.Name != nil {
|
||||
nameText = pgtype.Text{String: *input.Name, Valid: true}
|
||||
} else {
|
||||
nameText = pgtype.Text{Valid: false}
|
||||
}
|
||||
|
||||
if input.SortOrder != nil {
|
||||
cur, err := s.GetCourseByID(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Course{}, err
|
||||
}
|
||||
oldPos := int32(cur.SortOrder)
|
||||
newPos := int32(*input.SortOrder)
|
||||
if oldPos != newPos {
|
||||
q, tx, err := s.BeginTx(ctx)
|
||||
if err != nil {
|
||||
return domain.Course{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err := repositionCourseSortOrder(ctx, tx, cur.ProgramID, id, oldPos, newPos); err != nil {
|
||||
return domain.Course{}, err
|
||||
}
|
||||
c, err := q.UpdateCourse(ctx, dbgen.UpdateCourseParams{
|
||||
ID: id,
|
||||
Name: nameText,
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
SortOrder: pgtype.Int4{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Course{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Course{}, err
|
||||
}
|
||||
out := courseToDomain(c)
|
||||
out.HasPractice = cur.HasPractice
|
||||
return out, nil
|
||||
}
|
||||
sortParam = pgtype.Int4{Valid: false}
|
||||
}
|
||||
|
||||
c, err := s.queries.UpdateCourse(ctx, dbgen.UpdateCourseParams{
|
||||
ID: id,
|
||||
Name: nameText,
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
SortOrder: optionalInt4Update(input.SortOrder),
|
||||
SortOrder: sortParam,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
|
|
|||
|
|
@ -102,19 +102,58 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit
|
|||
}
|
||||
|
||||
func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) {
|
||||
sortParam := optionalInt4Update(input.SortOrder)
|
||||
var titleText pgtype.Text
|
||||
if input.Title != nil {
|
||||
titleText = pgtype.Text{String: *input.Title, Valid: true}
|
||||
} else {
|
||||
titleText = pgtype.Text{Valid: false}
|
||||
}
|
||||
|
||||
if input.SortOrder != nil {
|
||||
cur, err := s.GetLessonByID(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Lesson{}, err
|
||||
}
|
||||
oldPos := int32(cur.SortOrder)
|
||||
newPos := int32(*input.SortOrder)
|
||||
if oldPos != newPos {
|
||||
q, tx, err := s.BeginTx(ctx)
|
||||
if err != nil {
|
||||
return domain.Lesson{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err := repositionLessonSortOrder(ctx, tx, cur.ModuleID, id, oldPos, newPos); err != nil {
|
||||
return domain.Lesson{}, err
|
||||
}
|
||||
l, err := q.UpdateLesson(ctx, dbgen.UpdateLessonParams{
|
||||
ID: id,
|
||||
Title: titleText,
|
||||
VideoUrl: optionalTextUpdate(input.VideoURL),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
SortOrder: pgtype.Int4{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Lesson{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Lesson{}, err
|
||||
}
|
||||
out := lessonToDomain(l)
|
||||
out.HasPractice = cur.HasPractice
|
||||
return out, nil
|
||||
}
|
||||
sortParam = pgtype.Int4{Valid: false}
|
||||
}
|
||||
|
||||
l, err := s.queries.UpdateLesson(ctx, dbgen.UpdateLessonParams{
|
||||
ID: id,
|
||||
Title: titleText,
|
||||
VideoUrl: optionalTextUpdate(input.VideoURL),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
SortOrder: optionalInt4Update(input.SortOrder),
|
||||
SortOrder: sortParam,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
|
|
|||
|
|
@ -30,12 +30,43 @@ func moduleToDomain(m dbgen.Module) domain.Module {
|
|||
}
|
||||
|
||||
func (s *Store) CreateModule(ctx context.Context, programID, courseID int64, input domain.CreateModuleInput) (domain.Module, error) {
|
||||
if input.SortOrder != nil {
|
||||
q, tx, err := s.BeginTx(ctx)
|
||||
if err != nil {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
target := int32(*input.SortOrder)
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE modules SET sort_order = sort_order + 1 WHERE course_id = $1 AND sort_order >= $2`,
|
||||
courseID, target,
|
||||
); err != nil {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
m, err := q.CreateModule(ctx, dbgen.CreateModuleParams{
|
||||
ProgramID: programID,
|
||||
CourseID: courseID,
|
||||
Name: input.Name,
|
||||
Description: toPgText(input.Description),
|
||||
Icon: toPgText(input.Icon),
|
||||
SortOrder: pgtype.Int4{Int32: target, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
return moduleToDomain(m), nil
|
||||
}
|
||||
|
||||
m, err := s.queries.CreateModule(ctx, dbgen.CreateModuleParams{
|
||||
ProgramID: programID,
|
||||
CourseID: courseID,
|
||||
Name: input.Name,
|
||||
Description: toPgText(input.Description),
|
||||
Icon: toPgText(input.Icon),
|
||||
SortOrder: pgtype.Int4{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Module{}, err
|
||||
|
|
@ -107,69 +138,27 @@ func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, co
|
|||
}
|
||||
|
||||
func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error) {
|
||||
cur, err := s.GetModuleByID(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
|
||||
q, tx, err := s.BeginTx(ctx)
|
||||
if err != nil {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var current dbgen.Module
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, program_id, course_id, name, description, icon, sort_order, created_at, updated_at
|
||||
FROM modules
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, id).Scan(
|
||||
¤t.ID,
|
||||
¤t.ProgramID,
|
||||
¤t.CourseID,
|
||||
¤t.Name,
|
||||
¤t.Description,
|
||||
¤t.Icon,
|
||||
¤t.SortOrder,
|
||||
¤t.CreatedAt,
|
||||
¤t.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Module{}, pgx.ErrNoRows
|
||||
}
|
||||
return domain.Module{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
sortParam := optionalInt4Update(input.SortOrder)
|
||||
if input.SortOrder != nil {
|
||||
targetSort := int32(*input.SortOrder)
|
||||
if targetSort != current.SortOrder {
|
||||
var conflictID int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id
|
||||
FROM modules
|
||||
WHERE course_id = $1
|
||||
AND sort_order = $2
|
||||
AND id <> $3
|
||||
FOR UPDATE
|
||||
`, current.CourseID, targetSort, id).Scan(&conflictID)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
if err == nil {
|
||||
var tempSort int32
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(MIN(sort_order), 0) - 1
|
||||
FROM modules
|
||||
WHERE course_id = $1
|
||||
`, current.CourseID).Scan(&tempSort); err != nil {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order = $1 WHERE id = $2`, tempSort, id); err != nil {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order = $1 WHERE id = $2`, current.SortOrder, conflictID); err != nil {
|
||||
oldPos := int32(cur.SortOrder)
|
||||
newPos := int32(*input.SortOrder)
|
||||
if oldPos != newPos {
|
||||
if err := repositionModuleSortOrder(ctx, tx, cur.CourseID, id, oldPos, newPos); err != nil {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
sortParam = pgtype.Int4{Valid: false}
|
||||
}
|
||||
|
||||
var nameText pgtype.Text
|
||||
|
|
@ -183,7 +172,7 @@ WHERE course_id = $1
|
|||
Name: nameText,
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
Icon: optionalTextUpdate(input.Icon),
|
||||
SortOrder: optionalInt4Update(input.SortOrder),
|
||||
SortOrder: sortParam,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
|
@ -195,7 +184,9 @@ WHERE course_id = $1
|
|||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
return moduleToDomain(m), nil
|
||||
out := moduleToDomain(m)
|
||||
out.HasPractice = cur.HasPractice
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteModule(ctx context.Context, id int64) error {
|
||||
|
|
|
|||
|
|
@ -26,11 +26,19 @@ func fromPgInt8ID(c pgtype.Int8) *int64 {
|
|||
return &v
|
||||
}
|
||||
|
||||
func optionalInt8UpdateID(val *int64) pgtype.Int8 {
|
||||
if val == nil {
|
||||
return pgtype.Int8{Valid: false}
|
||||
}
|
||||
return pgtype.Int8{Int64: *val, Valid: true}
|
||||
}
|
||||
|
||||
func lmsPracticeToDomain(p dbgen.LmsPractice) domain.Practice {
|
||||
out := domain.Practice{
|
||||
ID: p.ID,
|
||||
Title: p.Title,
|
||||
QuestionSetID: p.QuestionSetID,
|
||||
PublishStatus: domain.PracticePublishStatusFromDB(p.PublishStatus),
|
||||
}
|
||||
if p.CourseID.Valid {
|
||||
out.ParentKind = domain.ParentKindCourse
|
||||
|
|
@ -55,7 +63,9 @@ func lmsPracticeToDomain(p dbgen.LmsPractice) domain.Practice {
|
|||
}
|
||||
|
||||
func lmsFromListRow(
|
||||
id, qid int64, title string,
|
||||
id, qid int64,
|
||||
publishStatus string,
|
||||
title string,
|
||||
cid, mid, lid pgtype.Int8,
|
||||
sd, si, qt pgtype.Text, pid pgtype.Int8,
|
||||
ca, ua pgtype.Timestamptz,
|
||||
|
|
@ -71,6 +81,7 @@ func lmsFromListRow(
|
|||
PersonaID: pid,
|
||||
QuestionSetID: qid,
|
||||
QuickTips: qt,
|
||||
PublishStatus: publishStatus,
|
||||
CreatedAt: ca,
|
||||
UpdatedAt: ua,
|
||||
})
|
||||
|
|
@ -82,6 +93,7 @@ func (s *Store) CreateLmsPractice(
|
|||
in domain.CreatePracticeInput,
|
||||
courseID, moduleID, lessonID *int64,
|
||||
) (domain.Practice, error) {
|
||||
ps := domain.ParsePracticePublishStatusInput(in.PublishStatus)
|
||||
p, err := s.queries.CreateLmsPractice(ctx, dbgen.CreateLmsPracticeParams{
|
||||
CourseID: int64PtrToPg8(courseID),
|
||||
ModuleID: int64PtrToPg8(moduleID),
|
||||
|
|
@ -92,6 +104,7 @@ func (s *Store) CreateLmsPractice(
|
|||
PersonaID: int64PtrToPg8(in.PersonaID),
|
||||
QuestionSetID: in.QuestionSetID,
|
||||
QuickTips: toPgText(in.QuickTips),
|
||||
PublishStatus: string(ps),
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Practice{}, err
|
||||
|
|
@ -110,9 +123,22 @@ func (s *Store) GetLmsPracticeByID(ctx context.Context, id int64) (domain.Practi
|
|||
return lmsPracticeToDomain(p), nil
|
||||
}
|
||||
|
||||
func (s *Store) ListLmsPracticesByCourseID(ctx context.Context, courseID int64, limit, offset int32) ([]domain.Practice, int64, error) {
|
||||
// TryGetLmsPracticeByQuestionSetID returns false when no row exists.
|
||||
func (s *Store) TryGetLmsPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (domain.Practice, bool, error) {
|
||||
p, err := s.queries.GetLmsPracticeByQuestionSetID(ctx, questionSetID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Practice{}, false, nil
|
||||
}
|
||||
return domain.Practice{}, false, err
|
||||
}
|
||||
return lmsPracticeToDomain(p), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListLmsPracticesByCourseID(ctx context.Context, courseID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) {
|
||||
rows, err := s.queries.ListLmsPracticesByCourseID(ctx, dbgen.ListLmsPracticesByCourseIDParams{
|
||||
CourseID: pgtype.Int8{Int64: courseID, Valid: true},
|
||||
PublishedOnly: publishedOnly,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
|
|
@ -129,7 +155,7 @@ func (s *Store) ListLmsPracticesByCourseID(ctx context.Context, courseID int64,
|
|||
total = r.TotalCount
|
||||
}
|
||||
out = append(out, lmsFromListRow(
|
||||
r.ID, r.QuestionSetID, r.Title,
|
||||
r.ID, r.QuestionSetID, r.PublishStatus, r.Title,
|
||||
r.CourseID, r.ModuleID, r.LessonID,
|
||||
r.StoryDescription, r.StoryImage, r.QuickTips,
|
||||
r.PersonaID, r.CreatedAt, r.UpdatedAt,
|
||||
|
|
@ -138,9 +164,10 @@ func (s *Store) ListLmsPracticesByCourseID(ctx context.Context, courseID int64,
|
|||
return out, total, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListLmsPracticesByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Practice, int64, error) {
|
||||
func (s *Store) ListLmsPracticesByModuleID(ctx context.Context, moduleID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) {
|
||||
rows, err := s.queries.ListLmsPracticesByModuleID(ctx, dbgen.ListLmsPracticesByModuleIDParams{
|
||||
ModuleID: pgtype.Int8{Int64: moduleID, Valid: true},
|
||||
PublishedOnly: publishedOnly,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
|
|
@ -157,7 +184,7 @@ func (s *Store) ListLmsPracticesByModuleID(ctx context.Context, moduleID int64,
|
|||
total = r.TotalCount
|
||||
}
|
||||
out = append(out, lmsFromListRow(
|
||||
r.ID, r.QuestionSetID, r.Title,
|
||||
r.ID, r.QuestionSetID, r.PublishStatus, r.Title,
|
||||
r.CourseID, r.ModuleID, r.LessonID,
|
||||
r.StoryDescription, r.StoryImage, r.QuickTips,
|
||||
r.PersonaID, r.CreatedAt, r.UpdatedAt,
|
||||
|
|
@ -166,9 +193,10 @@ func (s *Store) ListLmsPracticesByModuleID(ctx context.Context, moduleID int64,
|
|||
return out, total, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.Practice, int64, error) {
|
||||
func (s *Store) ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) {
|
||||
rows, err := s.queries.ListLmsPracticesByLessonID(ctx, dbgen.ListLmsPracticesByLessonIDParams{
|
||||
LessonID: pgtype.Int8{Int64: lessonID, Valid: true},
|
||||
PublishedOnly: publishedOnly,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
|
|
@ -185,7 +213,7 @@ func (s *Store) ListLmsPracticesByLessonID(ctx context.Context, lessonID int64,
|
|||
total = r.TotalCount
|
||||
}
|
||||
out = append(out, lmsFromListRow(
|
||||
r.ID, r.QuestionSetID, r.Title,
|
||||
r.ID, r.QuestionSetID, r.PublishStatus, r.Title,
|
||||
r.CourseID, r.ModuleID, r.LessonID,
|
||||
r.StoryDescription, r.StoryImage, r.QuickTips,
|
||||
r.PersonaID, r.CreatedAt, r.UpdatedAt,
|
||||
|
|
@ -194,13 +222,6 @@ func (s *Store) ListLmsPracticesByLessonID(ctx context.Context, lessonID int64,
|
|||
return out, total, nil
|
||||
}
|
||||
|
||||
func optionalInt8UpdateID(val *int64) pgtype.Int8 {
|
||||
if val == nil {
|
||||
return pgtype.Int8{Valid: false}
|
||||
}
|
||||
return pgtype.Int8{Int64: *val, Valid: true}
|
||||
}
|
||||
|
||||
func (s *Store) UpdateLmsPractice(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error) {
|
||||
var titleText pgtype.Text
|
||||
if input.Title != nil {
|
||||
|
|
@ -217,6 +238,7 @@ func (s *Store) UpdateLmsPractice(ctx context.Context, id int64, input domain.Up
|
|||
PersonaID: optionalInt8UpdateID(input.PersonaID),
|
||||
QuestionSetID: qs,
|
||||
QuickTips: optionalTextUpdate(input.QuickTips),
|
||||
PublishStatus: optionalPublishStatusUpdate(input.PublishStatus),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
|
|
|||
106
internal/repository/lms_sort_order_shift.go
Normal file
106
internal/repository/lms_sort_order_shift.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Sequential siblings (programs global, courses per program, modules per course, lessons per module) use unique sort_order.
|
||||
// These helpers move id from oldPos to newPos without collisions by temporarily assigning sort_order = -id then shifting intermediates.
|
||||
|
||||
func repositionProgramSortOrder(ctx context.Context, tx pgx.Tx, id int64, oldPos, newPos int32) error {
|
||||
if oldPos == newPos {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE programs SET sort_order = -id WHERE id = $1`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if newPos < oldPos {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE programs SET sort_order = sort_order + 1
|
||||
WHERE sort_order >= $1 AND sort_order < $2 AND id <> $3`, newPos, oldPos, id); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE programs SET sort_order = sort_order - 1
|
||||
WHERE sort_order > $1 AND sort_order <= $2 AND id <> $3`, oldPos, newPos, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := tx.Exec(ctx, `UPDATE programs SET sort_order = $1 WHERE id = $2`, newPos, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func repositionCourseSortOrder(ctx context.Context, tx pgx.Tx, programID, id int64, oldPos, newPos int32) error {
|
||||
if oldPos == newPos {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE courses SET sort_order = -id WHERE id = $1`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if newPos < oldPos {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE courses SET sort_order = sort_order + 1
|
||||
WHERE program_id = $4 AND sort_order >= $1 AND sort_order < $2 AND id <> $3`, newPos, oldPos, id, programID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE courses SET sort_order = sort_order - 1
|
||||
WHERE program_id = $4 AND sort_order > $1 AND sort_order <= $2 AND id <> $3`, oldPos, newPos, id, programID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := tx.Exec(ctx, `UPDATE courses SET sort_order = $1 WHERE id = $2`, newPos, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func repositionModuleSortOrder(ctx context.Context, tx pgx.Tx, courseID, id int64, oldPos, newPos int32) error {
|
||||
if oldPos == newPos {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order = -id WHERE id = $1`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if newPos < oldPos {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE modules SET sort_order = sort_order + 1
|
||||
WHERE course_id = $4 AND sort_order >= $1 AND sort_order < $2 AND id <> $3`, newPos, oldPos, id, courseID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE modules SET sort_order = sort_order - 1
|
||||
WHERE course_id = $4 AND sort_order > $1 AND sort_order <= $2 AND id <> $3`, oldPos, newPos, id, courseID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := tx.Exec(ctx, `UPDATE modules SET sort_order = $1 WHERE id = $2`, newPos, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func repositionLessonSortOrder(ctx context.Context, tx pgx.Tx, moduleID, id int64, oldPos, newPos int32) error {
|
||||
if oldPos == newPos {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE lessons SET sort_order = -id WHERE id = $1`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if newPos < oldPos {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE lessons SET sort_order = sort_order + 1
|
||||
WHERE module_id = $4 AND sort_order >= $1 AND sort_order < $2 AND id <> $3`, newPos, oldPos, id, moduleID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE lessons SET sort_order = sort_order - 1
|
||||
WHERE module_id = $4 AND sort_order > $1 AND sort_order <= $2 AND id <> $3`, oldPos, newPos, id, moduleID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := tx.Exec(ctx, `UPDATE lessons SET sort_order = $1 WHERE id = $2`, newPos, id)
|
||||
return err
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package repository
|
|||
import (
|
||||
"context"
|
||||
|
||||
dbgen "Yimaru-Backend/gen/db"
|
||||
"Yimaru-Backend/internal/domain"
|
||||
)
|
||||
|
||||
|
|
@ -31,3 +32,8 @@ func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (d
|
|||
ProgramIDs: programs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListUserLMSFlatLearningActivity returns flattened LMS activity rows for admin reporting (lesson + practice completions).
|
||||
func (s *Store) ListUserLMSFlatLearningActivity(ctx context.Context, userID int64) ([]dbgen.ListUserLMSFlatLearningActivityByUserRow, error) {
|
||||
return s.queries.ListUserLMSFlatLearningActivityByUser(ctx, userID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package repository
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
dbgen "Yimaru-Backend/gen/db"
|
||||
"Yimaru-Backend/internal/domain"
|
||||
|
|
@ -28,10 +29,36 @@ func programToDomain(p dbgen.Program) domain.Program {
|
|||
}
|
||||
|
||||
func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error) {
|
||||
if input.SortOrder != nil {
|
||||
q, tx, err := s.BeginTx(ctx)
|
||||
if err != nil {
|
||||
return domain.Program{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
target := int32(*input.SortOrder)
|
||||
if _, err := tx.Exec(ctx, `UPDATE programs SET sort_order = sort_order + 1 WHERE sort_order >= $1`, target); err != nil {
|
||||
return domain.Program{}, err
|
||||
}
|
||||
p, err := q.CreateProgram(ctx, dbgen.CreateProgramParams{
|
||||
Name: input.Name,
|
||||
Description: toPgText(input.Description),
|
||||
Thumbnail: toPgText(input.Thumbnail),
|
||||
SortOrder: pgtype.Int4{Int32: target, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Program{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Program{}, err
|
||||
}
|
||||
return programToDomain(p), nil
|
||||
}
|
||||
|
||||
p, err := s.queries.CreateProgram(ctx, dbgen.CreateProgramParams{
|
||||
Name: input.Name,
|
||||
Description: toPgText(input.Description),
|
||||
Thumbnail: toPgText(input.Thumbnail),
|
||||
SortOrder: pgtype.Int4{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Program{}, err
|
||||
|
|
@ -91,6 +118,19 @@ func optionalTextUpdate(val *string) pgtype.Text {
|
|||
return pgtype.Text{String: *val, Valid: true}
|
||||
}
|
||||
|
||||
func optionalPublishStatusUpdate(val *string) pgtype.Text {
|
||||
if val == nil {
|
||||
return pgtype.Text{Valid: false}
|
||||
}
|
||||
s := strings.TrimSpace(strings.ToUpper(*val))
|
||||
switch s {
|
||||
case string(domain.PracticePublishDraft), string(domain.PracticePublishPublished):
|
||||
return pgtype.Text{String: s, Valid: true}
|
||||
default:
|
||||
return pgtype.Text{Valid: false}
|
||||
}
|
||||
}
|
||||
|
||||
func optionalInt4Update(v *int) pgtype.Int4 {
|
||||
if v == nil {
|
||||
return pgtype.Int4{Valid: false}
|
||||
|
|
@ -99,6 +139,47 @@ func optionalInt4Update(v *int) pgtype.Int4 {
|
|||
}
|
||||
|
||||
func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) {
|
||||
sortParam := optionalInt4Update(input.SortOrder)
|
||||
if input.SortOrder != nil {
|
||||
cur, err := s.GetProgramByID(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Program{}, err
|
||||
}
|
||||
oldPos := int32(cur.SortOrder)
|
||||
newPos := int32(*input.SortOrder)
|
||||
if oldPos != newPos {
|
||||
q, tx, err := s.BeginTx(ctx)
|
||||
if err != nil {
|
||||
return domain.Program{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err := repositionProgramSortOrder(ctx, tx, id, oldPos, newPos); err != nil {
|
||||
return domain.Program{}, err
|
||||
}
|
||||
var nameText pgtype.Text
|
||||
if input.Name != nil {
|
||||
nameText = pgtype.Text{String: *input.Name, Valid: true}
|
||||
} else {
|
||||
nameText = pgtype.Text{Valid: false}
|
||||
}
|
||||
p, err := q.UpdateProgram(ctx, dbgen.UpdateProgramParams{
|
||||
ID: id,
|
||||
Name: nameText,
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
SortOrder: pgtype.Int4{Valid: false},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Program{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Program{}, err
|
||||
}
|
||||
return programToDomain(p), nil
|
||||
}
|
||||
sortParam = pgtype.Int4{Valid: false}
|
||||
}
|
||||
|
||||
var nameText pgtype.Text
|
||||
if input.Name != nil {
|
||||
nameText = pgtype.Text{String: *input.Name, Valid: true}
|
||||
|
|
@ -110,7 +191,7 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update
|
|||
Name: nameText,
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
SortOrder: optionalInt4Update(input.SortOrder),
|
||||
SortOrder: sortParam,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
|
|
|||
|
|
@ -157,39 +157,25 @@ func (s *Store) GetActiveSubscriptionByUserID(ctx context.Context, userID int64)
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListActiveSubscriptionsByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*domain.UserSubscription, error) {
|
||||
func (s *Store) ListSubscriptionDisplayStatusesByUserIDs(ctx context.Context, userIDs []int64) (map[int64]string, error) {
|
||||
if len(userIDs) == 0 {
|
||||
return map[int64]*domain.UserSubscription{}, nil
|
||||
return map[int64]string{}, nil
|
||||
}
|
||||
rows, err := s.queries.ListActiveSubscriptionsByUserIDs(ctx, userIDs)
|
||||
rows, err := s.queries.ListSubscriptionDisplayStatusesByUserIDs(ctx, userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[int64]*domain.UserSubscription, len(rows))
|
||||
out := make(map[int64]string, len(rows))
|
||||
for _, r := range rows {
|
||||
dv := r.DurationValue
|
||||
du := r.DurationUnit
|
||||
pn := r.PlanName
|
||||
cur := r.Currency
|
||||
out[r.UserID] = &domain.UserSubscription{
|
||||
ID: r.ID,
|
||||
UserID: r.UserID,
|
||||
PlanID: r.PlanID,
|
||||
StartsAt: r.StartsAt.Time,
|
||||
ExpiresAt: r.ExpiresAt.Time,
|
||||
Status: r.Status,
|
||||
AutoRenew: r.AutoRenew,
|
||||
PaymentMethod: fromPgText(r.PaymentMethod),
|
||||
PlanName: &pn,
|
||||
DurationValue: &dv,
|
||||
DurationUnit: &du,
|
||||
Price: float64Ptr(fromPgNumeric(r.Price)),
|
||||
Currency: &cur,
|
||||
}
|
||||
out[r.UserID] = r.SubscriptionStatus
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetSubscriptionDisplayStatusByUserID(ctx context.Context, userID int64) (string, error) {
|
||||
return s.queries.GetSubscriptionDisplayStatusByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Store) GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) {
|
||||
subs, err := s.queries.GetUserSubscriptionHistory(ctx, dbgen.GetUserSubscriptionHistoryParams{
|
||||
UserID: userID,
|
||||
|
|
|
|||
|
|
@ -219,6 +219,28 @@ func (s *Store) DeleteTeamMember(ctx context.Context, memberID int64) error {
|
|||
return s.queries.DeleteTeamMember(ctx, memberID)
|
||||
}
|
||||
|
||||
func (s *Store) BulkDeactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) {
|
||||
var ex pgtype.Int8
|
||||
if excludeTeamMemberID != nil {
|
||||
ex = pgtype.Int8{Int64: *excludeTeamMemberID, Valid: true}
|
||||
}
|
||||
return s.queries.BulkDeactivateTeamMembersByRole(ctx, dbgen.BulkDeactivateTeamMembersByRoleParams{
|
||||
TeamRole: teamRole,
|
||||
ExcludeTeamMemberID: ex,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) BulkReactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) {
|
||||
var ex pgtype.Int8
|
||||
if excludeTeamMemberID != nil {
|
||||
ex = pgtype.Int8{Int64: *excludeTeamMemberID, Valid: true}
|
||||
}
|
||||
return s.queries.BulkReactivateTeamMembersByRole(ctx, dbgen.BulkReactivateTeamMembersByRoleParams{
|
||||
TeamRole: teamRole,
|
||||
ExcludeTeamMemberID: ex,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) CheckTeamMemberEmailExists(ctx context.Context, email string) (bool, error) {
|
||||
return s.queries.CheckTeamMemberEmailExists(ctx, email)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,20 @@ func (s *Store) UpdateUserStatus(ctx context.Context, req domain.UpdateUserStatu
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Store) BulkDeactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error) {
|
||||
return s.queries.BulkDeactivateUsersByRole(ctx, dbgen.BulkDeactivateUsersByRoleParams{
|
||||
Role: role,
|
||||
ID: excludeUserID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) BulkReactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error) {
|
||||
return s.queries.BulkReactivateUsersByRole(ctx, dbgen.BulkReactivateUsersByRoleParams{
|
||||
Role: role,
|
||||
ID: excludeUserID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) CreateUserWithoutOtp(
|
||||
ctx context.Context,
|
||||
user domain.User,
|
||||
|
|
@ -347,6 +361,18 @@ func (s *Store) GetUserByID(
|
|||
}, nil
|
||||
}
|
||||
|
||||
// GetUserCreatedAt returns account created_at (used for timeline "joined" events).
|
||||
func (s *Store) GetUserCreatedAt(ctx context.Context, userID int64) (time.Time, error) {
|
||||
ts, err := s.queries.GetUserCreatedAt(ctx, userID)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if !ts.Valid {
|
||||
return time.Time{}, pgx.ErrNoRows
|
||||
}
|
||||
return ts.Time, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByGoogleID(
|
||||
ctx context.Context,
|
||||
googleId string,
|
||||
|
|
@ -414,6 +440,9 @@ func (s *Store) GetAllUsers(
|
|||
status *string,
|
||||
query *string,
|
||||
createdBefore, createdAfter *time.Time,
|
||||
country *string,
|
||||
region *string,
|
||||
subscriptionStatus *string,
|
||||
limit, offset int32,
|
||||
) ([]domain.User, int64, error) {
|
||||
|
||||
|
|
@ -442,12 +471,30 @@ func (s *Store) GetAllUsers(
|
|||
createdBeforeParam = pgtype.Timestamptz{Time: *createdBefore, Valid: true}
|
||||
}
|
||||
|
||||
var countryParam pgtype.Text
|
||||
if country != nil && *country != "" {
|
||||
countryParam = pgtype.Text{String: *country, Valid: true}
|
||||
}
|
||||
|
||||
var regionParam pgtype.Text
|
||||
if region != nil && *region != "" {
|
||||
regionParam = pgtype.Text{String: *region, Valid: true}
|
||||
}
|
||||
|
||||
var subscriptionStatusParam pgtype.Text
|
||||
if subscriptionStatus != nil && *subscriptionStatus != "" {
|
||||
subscriptionStatusParam = pgtype.Text{String: *subscriptionStatus, Valid: true}
|
||||
}
|
||||
|
||||
params := dbgen.GetAllUsersParams{
|
||||
Role: roleParam,
|
||||
Status: statusParam,
|
||||
Query: queryParam,
|
||||
CreatedAfter: createdAfterParam,
|
||||
CreatedBefore: createdBeforeParam,
|
||||
Country: countryParam,
|
||||
Region: regionParam,
|
||||
SubscriptionStatus: subscriptionStatusParam,
|
||||
Limit: pgtype.Int4{
|
||||
Int32: limit,
|
||||
Valid: true,
|
||||
|
|
|
|||
27
internal/repository/user_recent_activity.go
Normal file
27
internal/repository/user_recent_activity.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
dbgen "Yimaru-Backend/gen/db"
|
||||
)
|
||||
|
||||
func (s *Store) ListUserLessonCompletionsRecentActivity(ctx context.Context, userID int64) ([]dbgen.ListUserLessonCompletionsRecentActivityRow, error) {
|
||||
return s.queries.ListUserLessonCompletionsRecentActivity(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Store) ListUserModuleCompletionsRecentActivity(ctx context.Context, userID int64) ([]dbgen.ListUserModuleCompletionsRecentActivityRow, error) {
|
||||
return s.queries.ListUserModuleCompletionsRecentActivity(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Store) ListUserCourseCompletionsRecentActivity(ctx context.Context, userID int64) ([]dbgen.ListUserCourseCompletionsRecentActivityRow, error) {
|
||||
return s.queries.ListUserCourseCompletionsRecentActivity(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Store) ListUserProgramCompletionsRecentActivity(ctx context.Context, userID int64) ([]dbgen.ListUserProgramCompletionsRecentActivityRow, error) {
|
||||
return s.queries.ListUserProgramCompletionsRecentActivity(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Store) ListUserPracticeCompletionsRecentActivity(ctx context.Context, userID int64) ([]dbgen.ListUserPracticeCompletionsRecentActivityRow, error) {
|
||||
return s.queries.ListUserPracticeCompletionsRecentActivity(ctx, userID)
|
||||
}
|
||||
|
|
@ -358,7 +358,7 @@ func (s *Service) CreateExamPrepPractice(ctx context.Context, lessonID int64, in
|
|||
return s.store.CreateExamPrepLessonPractice(ctx, lessonID, input)
|
||||
}
|
||||
|
||||
func (s *Service) ListExamPrepPracticesByLesson(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) {
|
||||
func (s *Service) ListExamPrepPracticesByLesson(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) {
|
||||
if err := s.ensureLesson(ctx, lessonID); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
|
@ -371,7 +371,7 @@ func (s *Service) ListExamPrepPracticesByLesson(ctx context.Context, lessonID in
|
|||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return s.store.ListExamPrepLessonPracticesByLessonID(ctx, lessonID, limit, offset)
|
||||
return s.store.ListExamPrepLessonPracticesByLessonID(ctx, lessonID, publishedOnly, limit, offset)
|
||||
}
|
||||
|
||||
func (s *Service) GetExamPrepPracticeByID(ctx context.Context, id int64) (domain.ExamPrepPractice, error) {
|
||||
|
|
@ -385,6 +385,10 @@ func (s *Service) GetExamPrepPracticeByID(ctx context.Context, id int64) (domain
|
|||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) TryGetExamPrepPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (domain.ExamPrepPractice, bool, error) {
|
||||
return s.store.TryGetExamPrepLessonPracticeByQuestionSetID(ctx, questionSetID)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateExamPrepPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error) {
|
||||
p, err := s.store.UpdateExamPrepLessonPractice(ctx, id, input)
|
||||
if err != nil {
|
||||
|
|
|
|||
351
internal/services/lmsprogress/admin_learning_activity.go
Normal file
351
internal/services/lmsprogress/admin_learning_activity.go
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
package lmsprogress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
dbgen "Yimaru-Backend/gen/db"
|
||||
"Yimaru-Backend/internal/domain"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const (
|
||||
flatActivityLesson = "lesson"
|
||||
flatActivityPractice = "practice"
|
||||
practiceScopeLesson = "lesson"
|
||||
practiceScopeModule = "module"
|
||||
practiceScopeCourse = "course"
|
||||
)
|
||||
|
||||
// AdminUserLearningActivityTree returns nested program → course → module → lesson/practice completions for a learner.
|
||||
// The schema persists completion timestamps only (lesson completion, practice completion, rollup rows); partially started items do not appear.
|
||||
func (s *Service) AdminUserLearningActivityTree(ctx context.Context, userID int64) (domain.AdminLMSUserLearningActivityTree, error) {
|
||||
rows, err := s.store.ListUserLMSFlatLearningActivity(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.AdminLMSUserLearningActivityTree{}, err
|
||||
}
|
||||
return buildAdminLearningActivityTree(userID, rows), nil
|
||||
}
|
||||
|
||||
type lessonAccum struct {
|
||||
id int64
|
||||
title string
|
||||
sortOrder int32
|
||||
completedAt *time.Time
|
||||
practices []domain.AdminLMSPracticeLearningEntry
|
||||
practiceDed map[int64]struct{}
|
||||
}
|
||||
|
||||
type moduleAccum struct {
|
||||
id int64
|
||||
name string
|
||||
sortOrder int32
|
||||
rollup *time.Time
|
||||
lessons map[int64]*lessonAccum
|
||||
lessonOrder []int64
|
||||
modulePractices []domain.AdminLMSPracticeLearningEntry
|
||||
modulePracticeSeen map[int64]struct{}
|
||||
}
|
||||
|
||||
type courseAccum struct {
|
||||
id int64
|
||||
name string
|
||||
sortOrder int32
|
||||
rollup *time.Time
|
||||
modules map[int64]*moduleAccum
|
||||
moduleOrder []int64
|
||||
coursePractices []domain.AdminLMSPracticeLearningEntry
|
||||
coursePracticeSeen map[int64]struct{}
|
||||
}
|
||||
|
||||
type programAccum struct {
|
||||
id int64
|
||||
name string
|
||||
sortOrder int32
|
||||
rollup *time.Time
|
||||
courses map[int64]*courseAccum
|
||||
courseOrder []int64
|
||||
}
|
||||
|
||||
type adminActivityTreeBuilder struct {
|
||||
programs map[int64]*programAccum
|
||||
programOrder []int64
|
||||
}
|
||||
|
||||
func newAdminActivityTreeBuilder() *adminActivityTreeBuilder {
|
||||
return &adminActivityTreeBuilder{
|
||||
programs: make(map[int64]*programAccum),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *adminActivityTreeBuilder) ensureProgram(row dbgen.ListUserLMSFlatLearningActivityByUserRow) *programAccum {
|
||||
pa, ok := b.programs[row.ProgramID]
|
||||
if !ok {
|
||||
pa = &programAccum{
|
||||
id: row.ProgramID,
|
||||
courses: make(map[int64]*courseAccum),
|
||||
}
|
||||
b.programs[row.ProgramID] = pa
|
||||
b.programOrder = append(b.programOrder, row.ProgramID)
|
||||
}
|
||||
pa.name = row.ProgramName
|
||||
pa.sortOrder = row.ProgramSortOrder
|
||||
if t := pgTimestamptzPtr(row.ProgramCompletedAt); t != nil {
|
||||
pa.rollup = t
|
||||
}
|
||||
return pa
|
||||
}
|
||||
|
||||
func (pa *programAccum) ensureCourse(row dbgen.ListUserLMSFlatLearningActivityByUserRow) *courseAccum {
|
||||
ca, ok := pa.courses[row.CourseID]
|
||||
if !ok {
|
||||
ca = &courseAccum{
|
||||
id: row.CourseID,
|
||||
modules: make(map[int64]*moduleAccum),
|
||||
coursePracticeSeen: make(map[int64]struct{}),
|
||||
}
|
||||
pa.courses[row.CourseID] = ca
|
||||
pa.courseOrder = append(pa.courseOrder, row.CourseID)
|
||||
}
|
||||
ca.name = row.CourseName
|
||||
ca.sortOrder = row.CourseSortOrder
|
||||
if t := pgTimestamptzPtr(row.CourseCompletedAt); t != nil {
|
||||
ca.rollup = t
|
||||
}
|
||||
return ca
|
||||
}
|
||||
|
||||
func (ca *courseAccum) ensureModule(row dbgen.ListUserLMSFlatLearningActivityByUserRow) *moduleAccum {
|
||||
ma, ok := ca.modules[row.ModuleID]
|
||||
if !ok {
|
||||
ma = &moduleAccum{
|
||||
id: row.ModuleID,
|
||||
lessons: make(map[int64]*lessonAccum),
|
||||
modulePracticeSeen: make(map[int64]struct{}),
|
||||
}
|
||||
ca.modules[row.ModuleID] = ma
|
||||
ca.moduleOrder = append(ca.moduleOrder, row.ModuleID)
|
||||
}
|
||||
ma.name = row.ModuleName
|
||||
ma.sortOrder = row.ModuleSortOrder
|
||||
if t := pgTimestamptzPtr(row.ModuleCompletedAt); t != nil {
|
||||
ma.rollup = t
|
||||
}
|
||||
return ma
|
||||
}
|
||||
|
||||
func (ma *moduleAccum) ensureLesson(id int64, title string, sortOrder int32) *lessonAccum {
|
||||
la, ok := ma.lessons[id]
|
||||
if !ok {
|
||||
la = &lessonAccum{
|
||||
id: id,
|
||||
title: title,
|
||||
sortOrder: sortOrder,
|
||||
practiceDed: make(map[int64]struct{}),
|
||||
}
|
||||
ma.lessons[id] = la
|
||||
ma.lessonOrder = append(ma.lessonOrder, id)
|
||||
}
|
||||
if title != "" {
|
||||
la.title = title
|
||||
}
|
||||
return la
|
||||
}
|
||||
|
||||
func (la *lessonAccum) addPractice(p domain.AdminLMSPracticeLearningEntry) {
|
||||
if _, dup := la.practiceDed[p.LMSPracticeID]; dup {
|
||||
return
|
||||
}
|
||||
la.practiceDed[p.LMSPracticeID] = struct{}{}
|
||||
la.practices = append(la.practices, p)
|
||||
}
|
||||
|
||||
func (ma *moduleAccum) addModulePractice(p domain.AdminLMSPracticeLearningEntry) {
|
||||
if _, dup := ma.modulePracticeSeen[p.LMSPracticeID]; dup {
|
||||
return
|
||||
}
|
||||
ma.modulePracticeSeen[p.LMSPracticeID] = struct{}{}
|
||||
ma.modulePractices = append(ma.modulePractices, p)
|
||||
}
|
||||
|
||||
func (ca *courseAccum) addCoursePractice(p domain.AdminLMSPracticeLearningEntry) {
|
||||
if _, dup := ca.coursePracticeSeen[p.LMSPracticeID]; dup {
|
||||
return
|
||||
}
|
||||
ca.coursePracticeSeen[p.LMSPracticeID] = struct{}{}
|
||||
ca.coursePractices = append(ca.coursePractices, p)
|
||||
}
|
||||
|
||||
func (b *adminActivityTreeBuilder) ingest(row dbgen.ListUserLMSFlatLearningActivityByUserRow) {
|
||||
switch row.ActivityKind {
|
||||
case flatActivityLesson:
|
||||
if row.LessonID == 0 {
|
||||
return
|
||||
}
|
||||
p := b.ensureProgram(row)
|
||||
c := p.ensureCourse(row)
|
||||
m := c.ensureModule(row)
|
||||
l := m.ensureLesson(row.LessonID, row.LessonTitle, row.LessonSortOrder)
|
||||
if t := pgTimestamptzPtr(row.LessonCompletedAt); t != nil {
|
||||
l.completedAt = t
|
||||
}
|
||||
case flatActivityPractice:
|
||||
if row.LmsPracticeID == 0 {
|
||||
return
|
||||
}
|
||||
at, ok := pgTimestamptzTime(row.ActivityAt)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pr := domain.AdminLMSPracticeLearningEntry{
|
||||
LMSPracticeID: row.LmsPracticeID,
|
||||
Title: row.PracticeTitle,
|
||||
CompletedAt: at,
|
||||
}
|
||||
p := b.ensureProgram(row)
|
||||
c := p.ensureCourse(row)
|
||||
switch {
|
||||
case row.LessonID != 0:
|
||||
pr.Scope = practiceScopeLesson
|
||||
m := c.ensureModule(row)
|
||||
l := m.ensureLesson(row.LessonID, row.LessonTitle, row.LessonSortOrder)
|
||||
l.addPractice(pr)
|
||||
case row.ModuleID != 0:
|
||||
pr.Scope = practiceScopeModule
|
||||
m := c.ensureModule(row)
|
||||
m.addModulePractice(pr)
|
||||
default:
|
||||
pr.Scope = practiceScopeCourse
|
||||
c.addCoursePractice(pr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sortPracticeSlice(ps []domain.AdminLMSPracticeLearningEntry) {
|
||||
sort.Slice(ps, func(i, j int) bool {
|
||||
if !ps[i].CompletedAt.Equal(ps[j].CompletedAt) {
|
||||
return ps[i].CompletedAt.Before(ps[j].CompletedAt)
|
||||
}
|
||||
return ps[i].LMSPracticeID < ps[j].LMSPracticeID
|
||||
})
|
||||
}
|
||||
|
||||
func buildAdminLearningActivityTree(userID int64, rows []dbgen.ListUserLMSFlatLearningActivityByUserRow) domain.AdminLMSUserLearningActivityTree {
|
||||
b := newAdminActivityTreeBuilder()
|
||||
for i := range rows {
|
||||
b.ingest(rows[i])
|
||||
}
|
||||
|
||||
sort.Slice(b.programOrder, func(i, j int) bool {
|
||||
a := b.programs[b.programOrder[i]]
|
||||
cc := b.programs[b.programOrder[j]]
|
||||
if a.sortOrder != cc.sortOrder {
|
||||
return a.sortOrder < cc.sortOrder
|
||||
}
|
||||
return a.id < cc.id
|
||||
})
|
||||
|
||||
outPrograms := make([]domain.AdminLMSProgramLearningEntry, 0, len(b.programOrder))
|
||||
for _, pid := range b.programOrder {
|
||||
pa := b.programs[pid]
|
||||
sort.Slice(pa.courseOrder, func(i, j int) bool {
|
||||
a := pa.courses[pa.courseOrder[i]]
|
||||
c := pa.courses[pa.courseOrder[j]]
|
||||
if a.sortOrder != c.sortOrder {
|
||||
return a.sortOrder < c.sortOrder
|
||||
}
|
||||
return a.id < c.id
|
||||
})
|
||||
courses := make([]domain.AdminLMSCourseLearningEntry, 0, len(pa.courseOrder))
|
||||
for _, cid := range pa.courseOrder {
|
||||
ca := pa.courses[cid]
|
||||
sort.Slice(ca.moduleOrder, func(i, j int) bool {
|
||||
a := ca.modules[ca.moduleOrder[i]]
|
||||
c := ca.modules[ca.moduleOrder[j]]
|
||||
if a.sortOrder != c.sortOrder {
|
||||
return a.sortOrder < c.sortOrder
|
||||
}
|
||||
return a.id < c.id
|
||||
})
|
||||
modules := make([]domain.AdminLMSModuleLearningEntry, 0, len(ca.moduleOrder))
|
||||
for _, mid := range ca.moduleOrder {
|
||||
ma := ca.modules[mid]
|
||||
sort.Slice(ma.lessonOrder, func(i, j int) bool {
|
||||
a := ma.lessons[ma.lessonOrder[i]]
|
||||
c := ma.lessons[ma.lessonOrder[j]]
|
||||
if a.sortOrder != c.sortOrder {
|
||||
return a.sortOrder < c.sortOrder
|
||||
}
|
||||
return a.id < c.id
|
||||
})
|
||||
lessons := make([]domain.AdminLMSLessonLearningEntry, 0, len(ma.lessonOrder))
|
||||
for _, lid := range ma.lessonOrder {
|
||||
la := ma.lessons[lid]
|
||||
sortPracticeSlice(la.practices)
|
||||
entry := domain.AdminLMSLessonLearningEntry{
|
||||
ID: la.id,
|
||||
Title: la.title,
|
||||
SortOrder: la.sortOrder,
|
||||
CompletedAt: la.completedAt,
|
||||
LessonScopedPractices: la.practices,
|
||||
}
|
||||
lessons = append(lessons, entry)
|
||||
}
|
||||
mod := domain.AdminLMSModuleLearningEntry{
|
||||
ID: ma.id,
|
||||
Name: ma.name,
|
||||
SortOrder: ma.sortOrder,
|
||||
RollupFullyCompletedAt: ma.rollup,
|
||||
}
|
||||
if len(lessons) > 0 {
|
||||
mod.Lessons = lessons
|
||||
}
|
||||
if len(ma.modulePractices) > 0 {
|
||||
sortPracticeSlice(ma.modulePractices)
|
||||
mod.ModuleScopedPractices = ma.modulePractices
|
||||
}
|
||||
modules = append(modules, mod)
|
||||
}
|
||||
cr := domain.AdminLMSCourseLearningEntry{
|
||||
ID: ca.id,
|
||||
Name: ca.name,
|
||||
SortOrder: ca.sortOrder,
|
||||
RollupFullyCompletedAt: ca.rollup,
|
||||
Modules: modules,
|
||||
}
|
||||
if len(ca.coursePractices) > 0 {
|
||||
sortPracticeSlice(ca.coursePractices)
|
||||
cr.CourseLevelPractices = ca.coursePractices
|
||||
}
|
||||
courses = append(courses, cr)
|
||||
}
|
||||
outPrograms = append(outPrograms, domain.AdminLMSProgramLearningEntry{
|
||||
ID: pa.id,
|
||||
Name: pa.name,
|
||||
SortOrder: pa.sortOrder,
|
||||
RollupFullyCompletedAt: pa.rollup,
|
||||
Courses: courses,
|
||||
})
|
||||
}
|
||||
return domain.AdminLMSUserLearningActivityTree{
|
||||
UserID: userID,
|
||||
Programs: outPrograms,
|
||||
}
|
||||
}
|
||||
|
||||
func pgTimestamptzPtr(t pgtype.Timestamptz) *time.Time {
|
||||
if !t.Valid {
|
||||
return nil
|
||||
}
|
||||
tt := t.Time
|
||||
return &tt
|
||||
}
|
||||
|
||||
func pgTimestamptzTime(t pgtype.Timestamptz) (time.Time, bool) {
|
||||
if !t.Valid {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t.Time, true
|
||||
}
|
||||
227
internal/services/lmsprogress/admin_recent_activity.go
Normal file
227
internal/services/lmsprogress/admin_recent_activity.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
package lmsprogress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"Yimaru-Backend/internal/domain"
|
||||
)
|
||||
|
||||
const recentActivityJoinedHeadline = "Joined Yimaru"
|
||||
|
||||
func headlineLessonUnit(moduleSortOrder int32, lessonTitle string) string {
|
||||
if moduleSortOrder > 0 && lessonTitle != "" {
|
||||
return fmt.Sprintf("Completed unit %d: %s", moduleSortOrder, lessonTitle)
|
||||
}
|
||||
if lessonTitle != "" {
|
||||
return "Completed lesson: " + lessonTitle
|
||||
}
|
||||
return "Completed lesson"
|
||||
}
|
||||
|
||||
func headlineModuleUnit(moduleSortOrder int32, moduleName string) string {
|
||||
if moduleSortOrder > 0 && moduleName != "" {
|
||||
return fmt.Sprintf("Completed unit %d: %s", moduleSortOrder, moduleName)
|
||||
}
|
||||
if moduleName != "" {
|
||||
return "Completed module: " + moduleName
|
||||
}
|
||||
return "Completed module"
|
||||
}
|
||||
|
||||
func kindRank(kind string) int {
|
||||
switch kind {
|
||||
case domain.UserRecentActivityJoined:
|
||||
return 10
|
||||
case domain.UserRecentActivityProgramCompleted:
|
||||
return 8
|
||||
case domain.UserRecentActivityCourseCompleted:
|
||||
return 6
|
||||
case domain.UserRecentActivityModuleCompleted:
|
||||
return 4
|
||||
case domain.UserRecentActivityLessonCompleted:
|
||||
return 2
|
||||
case domain.UserRecentActivityPracticeCompleted:
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// AdminUserRecentActivity returns a reverse-chronological feed for admin profile / activity UIs.
|
||||
// Only completion milestones and account creation are included; "started learning path" is not stored and is not synthesized.
|
||||
func (s *Service) AdminUserRecentActivity(ctx context.Context, userID int64, limit int, includePractices bool) (domain.UserRecentActivityFeed, error) {
|
||||
if limit <= 0 {
|
||||
limit = 40
|
||||
}
|
||||
if limit > 120 {
|
||||
limit = 120
|
||||
}
|
||||
|
||||
createdAt, err := s.store.GetUserCreatedAt(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.UserRecentActivityFeed{}, domain.ErrUserNotFound
|
||||
}
|
||||
return domain.UserRecentActivityFeed{}, err
|
||||
}
|
||||
|
||||
var items []domain.UserRecentActivityItem
|
||||
|
||||
items = append(items, domain.UserRecentActivityItem{
|
||||
ID: fmt.Sprintf("joined:%d:%d", userID, createdAt.UnixNano()),
|
||||
Kind: domain.UserRecentActivityJoined,
|
||||
OccurredAt: createdAt,
|
||||
Headline: recentActivityJoinedHeadline,
|
||||
})
|
||||
|
||||
lessons, err := s.store.ListUserLessonCompletionsRecentActivity(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UserRecentActivityFeed{}, err
|
||||
}
|
||||
for _, row := range lessons {
|
||||
at, ok := pgTimestamptzTime(row.OccurredAt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, domain.UserRecentActivityItem{
|
||||
ID: fmt.Sprintf("lesson:%d:%d", row.LessonID, at.UnixNano()),
|
||||
Kind: domain.UserRecentActivityLessonCompleted,
|
||||
OccurredAt: at,
|
||||
Headline: headlineLessonUnit(row.ModuleSortOrder, row.LessonTitle),
|
||||
Program: &domain.RecentActivityProgramRef{ID: row.ProgramID, Name: row.ProgramName},
|
||||
Course: &domain.RecentActivityCourseRef{ID: row.CourseID, Name: row.CourseName},
|
||||
Module: &domain.RecentActivityModuleRef{ID: row.ModuleID, Name: row.ModuleName, SortOrder: row.ModuleSortOrder},
|
||||
Lesson: &domain.RecentActivityLessonRef{ID: row.LessonID, Title: row.LessonTitle},
|
||||
})
|
||||
}
|
||||
|
||||
mods, err := s.store.ListUserModuleCompletionsRecentActivity(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UserRecentActivityFeed{}, err
|
||||
}
|
||||
for _, row := range mods {
|
||||
at, ok := pgTimestamptzTime(row.OccurredAt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, domain.UserRecentActivityItem{
|
||||
ID: fmt.Sprintf("module:%d:%d", row.ModuleID, at.UnixNano()),
|
||||
Kind: domain.UserRecentActivityModuleCompleted,
|
||||
OccurredAt: at,
|
||||
Headline: headlineModuleUnit(row.ModuleSortOrder, row.ModuleName),
|
||||
Program: &domain.RecentActivityProgramRef{ID: row.ProgramID, Name: row.ProgramName},
|
||||
Course: &domain.RecentActivityCourseRef{ID: row.CourseID, Name: row.CourseName},
|
||||
Module: &domain.RecentActivityModuleRef{ID: row.ModuleID, Name: row.ModuleName, SortOrder: row.ModuleSortOrder},
|
||||
})
|
||||
}
|
||||
|
||||
courses, err := s.store.ListUserCourseCompletionsRecentActivity(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UserRecentActivityFeed{}, err
|
||||
}
|
||||
for _, row := range courses {
|
||||
at, ok := pgTimestamptzTime(row.OccurredAt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
headline := "Completed course"
|
||||
if row.CourseName != "" {
|
||||
headline = "Completed course: " + row.CourseName
|
||||
}
|
||||
items = append(items, domain.UserRecentActivityItem{
|
||||
ID: fmt.Sprintf("course:%d:%d", row.CourseID, at.UnixNano()),
|
||||
Kind: domain.UserRecentActivityCourseCompleted,
|
||||
OccurredAt: at,
|
||||
Headline: headline,
|
||||
Program: &domain.RecentActivityProgramRef{ID: row.ProgramID, Name: row.ProgramName},
|
||||
Course: &domain.RecentActivityCourseRef{ID: row.CourseID, Name: row.CourseName},
|
||||
})
|
||||
}
|
||||
|
||||
progs, err := s.store.ListUserProgramCompletionsRecentActivity(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UserRecentActivityFeed{}, err
|
||||
}
|
||||
for _, row := range progs {
|
||||
at, ok := pgTimestamptzTime(row.OccurredAt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
headline := "Completed learning path"
|
||||
if row.ProgramName != "" {
|
||||
headline = "Completed learning path: " + row.ProgramName
|
||||
}
|
||||
items = append(items, domain.UserRecentActivityItem{
|
||||
ID: fmt.Sprintf("program:%d:%d", row.ProgramID, at.UnixNano()),
|
||||
Kind: domain.UserRecentActivityProgramCompleted,
|
||||
OccurredAt: at,
|
||||
Headline: headline,
|
||||
Program: &domain.RecentActivityProgramRef{ID: row.ProgramID, Name: row.ProgramName},
|
||||
})
|
||||
}
|
||||
|
||||
if includePractices {
|
||||
practices, err := s.store.ListUserPracticeCompletionsRecentActivity(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UserRecentActivityFeed{}, err
|
||||
}
|
||||
for _, row := range practices {
|
||||
at, ok := pgTimestamptzTime(row.OccurredAt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
headline := "Completed practice"
|
||||
if row.PracticeTitle != "" {
|
||||
headline = "Completed practice: " + row.PracticeTitle
|
||||
}
|
||||
item := domain.UserRecentActivityItem{
|
||||
ID: fmt.Sprintf("practice:%d:%d", row.LmsPracticeID, at.UnixNano()),
|
||||
Kind: domain.UserRecentActivityPracticeCompleted,
|
||||
OccurredAt: at,
|
||||
Headline: headline,
|
||||
Program: &domain.RecentActivityProgramRef{ID: row.ProgramID, Name: row.ProgramName},
|
||||
Course: &domain.RecentActivityCourseRef{ID: row.CourseID, Name: row.CourseName},
|
||||
Practice: &domain.RecentActivityPracticeRef{
|
||||
LMSPracticeID: row.LmsPracticeID,
|
||||
Title: row.PracticeTitle,
|
||||
Scope: row.Scope,
|
||||
},
|
||||
}
|
||||
if row.ModuleID != 0 {
|
||||
item.Module = &domain.RecentActivityModuleRef{
|
||||
ID: row.ModuleID, Name: row.ModuleName, SortOrder: row.ModuleSortOrder,
|
||||
}
|
||||
}
|
||||
if row.LessonID != 0 {
|
||||
item.Lesson = &domain.RecentActivityLessonRef{ID: row.LessonID, Title: row.LessonTitle}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
ti, tj := items[i].OccurredAt, items[j].OccurredAt
|
||||
if !ti.Equal(tj) {
|
||||
return ti.After(tj)
|
||||
}
|
||||
ri, rj := kindRank(items[i].Kind), kindRank(items[j].Kind)
|
||||
if ri != rj {
|
||||
return ri > rj
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
|
||||
return domain.UserRecentActivityFeed{
|
||||
UserID: userID,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -146,7 +146,7 @@ func (s *Service) CanAccessLesson(ctx context.Context, userID, lessonID int64) (
|
|||
|
||||
// ApplyAccessProgram sets p.Access for a learner. Non-learners: clears Access to omit from JSON.
|
||||
func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error {
|
||||
if role != domain.RoleStudent {
|
||||
if !role.UsesLMSSequentialGating() {
|
||||
p.Access = nil
|
||||
return nil
|
||||
}
|
||||
|
|
@ -172,7 +172,7 @@ func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, user
|
|||
|
||||
// ApplyAccessCourse sets c.Access for a learner.
|
||||
func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userID int64, c *domain.Course) error {
|
||||
if role != domain.RoleStudent {
|
||||
if !role.UsesLMSSequentialGating() {
|
||||
c.Access = nil
|
||||
return nil
|
||||
}
|
||||
|
|
@ -198,7 +198,7 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI
|
|||
|
||||
// ApplyAccessModule sets m.Access for a learner.
|
||||
func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userID int64, m *domain.Module) error {
|
||||
if role != domain.RoleStudent {
|
||||
if !role.UsesLMSSequentialGating() {
|
||||
m.Access = nil
|
||||
return nil
|
||||
}
|
||||
|
|
@ -224,7 +224,7 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI
|
|||
|
||||
// ApplyAccessLesson sets l.Access for a learner.
|
||||
func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userID int64, les *domain.Lesson) error {
|
||||
if role != domain.RoleStudent {
|
||||
if !role.UsesLMSSequentialGating() {
|
||||
les.Access = nil
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,10 @@ func (s *Service) Create(ctx context.Context, in domain.CreatePracticeInput) (do
|
|||
return s.practices.CreateLmsPractice(ctx, in, courseID, moduleID, lessonID)
|
||||
}
|
||||
|
||||
func (s *Service) TryGetByQuestionSetID(ctx context.Context, questionSetID int64) (domain.Practice, bool, error) {
|
||||
return s.practices.TryGetLmsPracticeByQuestionSetID(ctx, questionSetID)
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(ctx context.Context, id int64) (domain.Practice, error) {
|
||||
p, err := s.practices.GetLmsPracticeByID(ctx, id)
|
||||
if err != nil {
|
||||
|
|
@ -139,7 +143,7 @@ func clampPracticePage(limit, offset int32) (int32, int32) {
|
|||
return limit, offset
|
||||
}
|
||||
|
||||
func (s *Service) ListByCourse(ctx context.Context, courseID int64, limit, offset int32) ([]domain.Practice, int64, error) {
|
||||
func (s *Service) ListByCourse(ctx context.Context, courseID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) {
|
||||
if _, err := s.courses.GetCourseByID(ctx, courseID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, 0, courses.ErrCourseNotFound
|
||||
|
|
@ -147,10 +151,10 @@ func (s *Service) ListByCourse(ctx context.Context, courseID int64, limit, offse
|
|||
return nil, 0, err
|
||||
}
|
||||
limit, offset = clampPracticePage(limit, offset)
|
||||
return s.practices.ListLmsPracticesByCourseID(ctx, courseID, limit, offset)
|
||||
return s.practices.ListLmsPracticesByCourseID(ctx, courseID, publishedOnly, limit, offset)
|
||||
}
|
||||
|
||||
func (s *Service) ListByModule(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Practice, int64, error) {
|
||||
func (s *Service) ListByModule(ctx context.Context, moduleID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) {
|
||||
if _, err := s.modules.GetModuleByID(ctx, moduleID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, 0, modules.ErrModuleNotFound
|
||||
|
|
@ -158,10 +162,10 @@ func (s *Service) ListByModule(ctx context.Context, moduleID int64, limit, offse
|
|||
return nil, 0, err
|
||||
}
|
||||
limit, offset = clampPracticePage(limit, offset)
|
||||
return s.practices.ListLmsPracticesByModuleID(ctx, moduleID, limit, offset)
|
||||
return s.practices.ListLmsPracticesByModuleID(ctx, moduleID, publishedOnly, limit, offset)
|
||||
}
|
||||
|
||||
func (s *Service) ListByLesson(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.Practice, int64, error) {
|
||||
func (s *Service) ListByLesson(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) {
|
||||
if _, err := s.lessons.GetLessonByID(ctx, lessonID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, 0, lessons.ErrLessonNotFound
|
||||
|
|
@ -169,7 +173,7 @@ func (s *Service) ListByLesson(ctx context.Context, lessonID int64, limit, offse
|
|||
return nil, 0, err
|
||||
}
|
||||
limit, offset = clampPracticePage(limit, offset)
|
||||
return s.practices.ListLmsPracticesByLessonID(ctx, lessonID, limit, offset)
|
||||
return s.practices.ListLmsPracticesByLessonID(ctx, lessonID, publishedOnly, limit, offset)
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error) {
|
||||
|
|
|
|||
|
|
@ -302,10 +302,71 @@ var AllPermissions = []domain.PermissionSeed{
|
|||
{Key: "internal.db.reset_reseed", Name: "Reset And Reseed Database", Description: "Dangerous operation: clears all data and re-seeds from SQL files", GroupName: "Internal Operations"},
|
||||
}
|
||||
|
||||
// defaultStudentLearnerPermissions is the learner consumption permission set shared by STUDENT and OPEN_LEARNER.
|
||||
// LMS sequential prerequisite locking applies only to STUDENT in application handlers.
|
||||
var defaultStudentLearnerPermissions = []string{
|
||||
// Course browsing
|
||||
"course_categories.list", "course_categories.get",
|
||||
"courses.get", "courses.list_by_program",
|
||||
"modules.get", "modules.list_by_course",
|
||||
"lessons.get", "lessons.list_by_module", "lessons.complete",
|
||||
"practices.get", "practices.list",
|
||||
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
|
||||
"videos.get", "videos.list_by_subcourse", "videos.list_published",
|
||||
"learning_tree.get",
|
||||
|
||||
"programs.list", "programs.get",
|
||||
"exam_prep.catalog_courses.list", "exam_prep.catalog_courses.get",
|
||||
"exam_prep.units.list", "exam_prep.units.get",
|
||||
"exam_prep.modules.list", "exam_prep.modules.get",
|
||||
"exam_prep.lessons.list_by_module", "exam_prep.lessons.get",
|
||||
"exam_prep.practices.list_by_lesson", "exam_prep.practices.get",
|
||||
"lms.get_my_progress",
|
||||
|
||||
// Questions (read + attempt)
|
||||
"questions.list", "questions.search", "questions.get",
|
||||
"question_sets.list", "question_sets.list_by_owner", "question_sets.get",
|
||||
"question_set_items.list",
|
||||
"question_set_personas.list",
|
||||
|
||||
// Subscriptions & Payments (own)
|
||||
"subscriptions.checkout", "subscriptions.get_mine", "subscriptions.history",
|
||||
"subscriptions.status", "subscriptions.cancel", "subscriptions.set_auto_renew",
|
||||
"payments.initiate", "payments.verify", "payments.list_mine", "payments.get", "payments.cancel",
|
||||
"payments.direct_initiate", "payments.direct_verify_otp",
|
||||
|
||||
// User (self-service)
|
||||
"users.update_self", "users.delete_self", "users.cancel_delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile",
|
||||
|
||||
// Notifications (own)
|
||||
"notifications.ws_connect", "notifications.list_mine", "notifications.list_all",
|
||||
"notifications.mark_read", "notifications.mark_all_read", "notifications.mark_unread", "notifications.mark_all_unread",
|
||||
"notifications.delete_mine", "notifications.count_unread",
|
||||
"notifications.test_push",
|
||||
|
||||
// Issues (own)
|
||||
"issues.create", "issues.list_mine",
|
||||
|
||||
// Devices
|
||||
"devices.register", "devices.unregister",
|
||||
|
||||
// Progress
|
||||
"progress.start", "progress.update", "progress.complete", "progress.check_access", "progress.get_course",
|
||||
|
||||
// Sub-course Prerequisites (read)
|
||||
"subcourse_prerequisites.list",
|
||||
|
||||
// Ratings
|
||||
"ratings.submit", "ratings.list_by_target", "ratings.summary", "ratings.get_mine", "ratings.list_mine", "ratings.delete",
|
||||
|
||||
// Auth
|
||||
"auth.logout",
|
||||
}
|
||||
|
||||
// DefaultRolePermissions maps each system role to the permission keys it should
|
||||
// have by default. This preserves the previous middleware behavior:
|
||||
// - ADMIN: everything that was previously OnlyAdminAndAbove + SuperAdminOnly + all authenticated routes
|
||||
// - STUDENT/INSTRUCTOR/SUPPORT: only self-service endpoints (profile, courses, progress, etc.)
|
||||
// - STUDENT/OPEN_LEARNER/INSTRUCTOR/SUPPORT: only self-service endpoints (profile, courses, progress, etc.)
|
||||
var DefaultRolePermissions = map[string][]string{
|
||||
"ADMIN": {
|
||||
// Course Management (full access)
|
||||
|
|
@ -409,64 +470,9 @@ var DefaultRolePermissions = map[string][]string{
|
|||
"internal.db.reset_reseed",
|
||||
},
|
||||
|
||||
"STUDENT": {
|
||||
// Course browsing
|
||||
"course_categories.list", "course_categories.get",
|
||||
"courses.get", "courses.list_by_program",
|
||||
"modules.get", "modules.list_by_course",
|
||||
"lessons.get", "lessons.list_by_module", "lessons.complete",
|
||||
"practices.get", "practices.list",
|
||||
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
|
||||
"videos.get", "videos.list_by_subcourse", "videos.list_published",
|
||||
"learning_tree.get",
|
||||
"STUDENT": defaultStudentLearnerPermissions,
|
||||
|
||||
"programs.list", "programs.get",
|
||||
"exam_prep.catalog_courses.list", "exam_prep.catalog_courses.get",
|
||||
"exam_prep.units.list", "exam_prep.units.get",
|
||||
"exam_prep.modules.list", "exam_prep.modules.get",
|
||||
"exam_prep.lessons.list_by_module", "exam_prep.lessons.get",
|
||||
"exam_prep.practices.list_by_lesson", "exam_prep.practices.get",
|
||||
"lms.get_my_progress",
|
||||
|
||||
// Questions (read + attempt)
|
||||
"questions.list", "questions.search", "questions.get",
|
||||
"question_sets.list", "question_sets.list_by_owner", "question_sets.get",
|
||||
"question_set_items.list",
|
||||
"question_set_personas.list",
|
||||
|
||||
// Subscriptions & Payments (own)
|
||||
"subscriptions.checkout", "subscriptions.get_mine", "subscriptions.history",
|
||||
"subscriptions.status", "subscriptions.cancel", "subscriptions.set_auto_renew",
|
||||
"payments.initiate", "payments.verify", "payments.list_mine", "payments.get", "payments.cancel",
|
||||
"payments.direct_initiate", "payments.direct_verify_otp",
|
||||
|
||||
// User (self-service)
|
||||
"users.update_self", "users.delete_self", "users.cancel_delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile",
|
||||
|
||||
// Notifications (own)
|
||||
"notifications.ws_connect", "notifications.list_mine", "notifications.list_all",
|
||||
"notifications.mark_read", "notifications.mark_all_read", "notifications.mark_unread", "notifications.mark_all_unread",
|
||||
"notifications.delete_mine", "notifications.count_unread",
|
||||
"notifications.test_push",
|
||||
|
||||
// Issues (own)
|
||||
"issues.create", "issues.list_mine",
|
||||
|
||||
// Devices
|
||||
"devices.register", "devices.unregister",
|
||||
|
||||
// Progress
|
||||
"progress.start", "progress.update", "progress.complete", "progress.check_access", "progress.get_course",
|
||||
|
||||
// Sub-course Prerequisites (read)
|
||||
"subcourse_prerequisites.list",
|
||||
|
||||
// Ratings
|
||||
"ratings.submit", "ratings.list_by_target", "ratings.summary", "ratings.get_mine", "ratings.list_mine", "ratings.delete",
|
||||
|
||||
// Auth
|
||||
"auth.logout",
|
||||
},
|
||||
"OPEN_LEARNER": defaultStudentLearnerPermissions,
|
||||
|
||||
"INSTRUCTOR": {
|
||||
// Course browsing + management
|
||||
|
|
|
|||
|
|
@ -103,13 +103,19 @@ func (s *Service) GetSubscriptionByID(ctx context.Context, id int64) (*domain.Us
|
|||
return sub, nil
|
||||
}
|
||||
|
||||
// GetActiveSubscription returns the ACTIVE, non-expired subscription for the user.
|
||||
func (s *Service) GetActiveSubscription(ctx context.Context, userID int64) (*domain.UserSubscription, error) {
|
||||
return s.store.GetActiveSubscriptionByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// ListActiveSubscriptionsForUserIDs returns the current ACTIVE, non-expired subscription per user (latest expiry).
|
||||
func (s *Service) ListActiveSubscriptionsForUserIDs(ctx context.Context, userIDs []int64) (map[int64]*domain.UserSubscription, error) {
|
||||
return s.store.ListActiveSubscriptionsByUserIDs(ctx, userIDs)
|
||||
// ListSubscriptionDisplayStatusesForUserIDs returns ACTIVE, PENDING, or Unsubscribed per user_id (admin list).
|
||||
func (s *Service) ListSubscriptionDisplayStatusesForUserIDs(ctx context.Context, userIDs []int64) (map[int64]string, error) {
|
||||
return s.store.ListSubscriptionDisplayStatusesByUserIDs(ctx, userIDs)
|
||||
}
|
||||
|
||||
// GetSubscriptionDisplayStatusForUserID returns ACTIVE, PENDING, or Unsubscribed for one user.
|
||||
func (s *Service) GetSubscriptionDisplayStatusForUserID(ctx context.Context, userID int64) (string, error) {
|
||||
return s.store.GetSubscriptionDisplayStatusByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) GetSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) {
|
||||
|
|
|
|||
|
|
@ -119,6 +119,14 @@ func (s *Service) GetTeamMemberStats(ctx context.Context) (domain.TeamMemberStat
|
|||
return s.teamStore.CountTeamMembersByStatus(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) BulkDeactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) {
|
||||
return s.teamStore.BulkDeactivateTeamMembersByRole(ctx, teamRole, excludeTeamMemberID)
|
||||
}
|
||||
|
||||
func (s *Service) BulkReactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) {
|
||||
return s.teamStore.BulkReactivateTeamMembersByRole(ctx, teamRole, excludeTeamMemberID)
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, req domain.TeamMemberLoginReq) (domain.TeamMember, error) {
|
||||
member, err := s.teamStore.GetTeamMemberByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,21 @@ func (s *Service) GetAllUsers(
|
|||
query = &filter.Query
|
||||
}
|
||||
|
||||
var country *string
|
||||
if filter.Country != "" {
|
||||
country = &filter.Country
|
||||
}
|
||||
|
||||
var region *string
|
||||
if filter.Region != "" {
|
||||
region = &filter.Region
|
||||
}
|
||||
|
||||
var subscriptionStatus *string
|
||||
if filter.SubscriptionStatus != "" {
|
||||
subscriptionStatus = &filter.SubscriptionStatus
|
||||
}
|
||||
|
||||
offset := int32(filter.Page * filter.PageSize)
|
||||
|
||||
return s.userStore.GetAllUsers(
|
||||
|
|
@ -98,6 +113,9 @@ func (s *Service) GetAllUsers(
|
|||
query,
|
||||
before,
|
||||
after,
|
||||
country,
|
||||
region,
|
||||
subscriptionStatus,
|
||||
int32(filter.PageSize),
|
||||
offset,
|
||||
)
|
||||
|
|
@ -115,6 +133,14 @@ func (s *Service) UpdateUserStatus(ctx context.Context, req domain.UpdateUserSta
|
|||
return s.userStore.UpdateUserStatus(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) BulkDeactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error) {
|
||||
return s.userStore.BulkDeactivateUsersByRole(ctx, role, excludeUserID)
|
||||
}
|
||||
|
||||
func (s *Service) BulkReactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error) {
|
||||
return s.userStore.BulkReactivateUsersByRole(ctx, role, excludeUserID)
|
||||
}
|
||||
|
||||
func (s *Service) GetUserById(ctx context.Context, id int64) (domain.User, error) {
|
||||
|
||||
return s.userStore.GetUserByID(ctx, id)
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ import (
|
|||
"Yimaru-Backend/internal/web_server/response"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
|
|
@ -379,3 +382,290 @@ func (h *Handler) UpdateAdmin(c *fiber.Ctx) error {
|
|||
|
||||
return response.WriteJSON(c, fiber.StatusOK, "Admin updated successfully", nil, nil)
|
||||
}
|
||||
|
||||
// bulkAccountsRoleFromPath resolves admin bulk :role: decimal digits → rbac roles.id lookup (same ids as GET /api/v1/rbac/roles); otherwise uppercase role key.
|
||||
func (h *Handler) bulkAccountsRoleFromPath(c *fiber.Ctx) (roleKey string, ok bool) {
|
||||
raw := strings.TrimSpace(c.Params("role"))
|
||||
if raw == "" {
|
||||
_ = c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid role",
|
||||
Error: "role path parameter is required",
|
||||
})
|
||||
return "", false
|
||||
}
|
||||
if rbacID, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil {
|
||||
if rbacID <= 0 {
|
||||
_ = c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid role",
|
||||
Error: "numeric role path must be a positive RBAC roles.id (see GET /api/v1/rbac/roles)",
|
||||
})
|
||||
return "", false
|
||||
}
|
||||
rec, err := h.rbacSvc.GetRoleByID(c.Context(), rbacID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
_ = c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid role",
|
||||
Error: "RBAC role id not found",
|
||||
})
|
||||
return "", false
|
||||
}
|
||||
_ = c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "RBAC lookup failed",
|
||||
Error: err.Error(),
|
||||
})
|
||||
return "", false
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(rec.Name)), true
|
||||
}
|
||||
return strings.ToUpper(raw), true
|
||||
}
|
||||
|
||||
// BulkDeactivateAccountsByRole godoc
|
||||
// @Summary Bulk deactivate accounts by role (SUPER_ADMIN or ADMIN platform users only)
|
||||
// @Description Sets all platform users with the given users.role to DEACTIVATED (except the caller) and all team_members with the given team_role to inactive. Path :role may be a role key (e.g. INSTRUCTOR, ADMIN) or a decimal RBAC roles.id from GET /api/v1/rbac/roles (resolved to RoleRecord.name uppercased). SUPER_ADMIN cannot be bulk-deactivated. ADMIN platform users must use SUPER_ADMIN to bulk change other platform ADMIN users (team_members with team_role ADMIN under path ADMIN remain allowed). Empty body allowed; optionally pass exclude_team_member_id to skip one team_members row (e.g. yourself).
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param role path string true "Role key (INSTRUCTOR etc.) or RBAC roles.id (integer string)"
|
||||
// @Param body body domain.BulkAccountsByRoleRequest false "Optional exclusions"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 403 {object} domain.ErrorResponse
|
||||
// @Failure 404 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/admin/roles/{role}/bulk-deactivate [post]
|
||||
func (h *Handler) BulkDeactivateAccountsByRole(c *fiber.Ctx) error {
|
||||
callerRole, ok := c.Locals("role").(domain.Role)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
||||
Message: "Forbidden",
|
||||
Error: "role not found in context",
|
||||
})
|
||||
}
|
||||
if callerRole != domain.RoleSuperAdmin && callerRole != domain.RoleAdmin {
|
||||
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
||||
Message: "Forbidden",
|
||||
Error: "only SUPER_ADMIN or ADMIN platform users may bulk deactivate by role",
|
||||
})
|
||||
}
|
||||
|
||||
actorID, ok := c.Locals("user_id").(int64)
|
||||
if !ok || actorID <= 0 {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
||||
Message: "Unauthorized",
|
||||
Error: "user id not found in context",
|
||||
})
|
||||
}
|
||||
|
||||
roleKey, rpOK := h.bulkAccountsRoleFromPath(c)
|
||||
if !rpOK {
|
||||
return nil
|
||||
}
|
||||
if roleKey == string(domain.RoleSuperAdmin) || roleKey == string(domain.TeamRoleSuperAdmin) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Refusing bulk deactivate",
|
||||
Error: "SUPER_ADMIN cannot be bulk deactivated",
|
||||
})
|
||||
}
|
||||
validUserRole := domain.Role(roleKey).IsValid()
|
||||
validTeamRole := domain.TeamRole(roleKey).IsValid()
|
||||
if !validUserRole && !validTeamRole {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid role",
|
||||
Error: "role is not a valid platform users.role nor team_members.team_role",
|
||||
})
|
||||
}
|
||||
|
||||
// Non-super-admins cannot bulk change other platform ADMIN users (same role); team_members ADMIN is still allowed.
|
||||
if callerRole != domain.RoleSuperAdmin && roleKey == string(domain.RoleAdmin) {
|
||||
validUserRole = false
|
||||
}
|
||||
|
||||
var req domain.BulkAccountsByRoleRequest
|
||||
if len(c.Body()) > 0 {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if req.ExcludeTeamMemberID != nil && *req.ExcludeTeamMemberID <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid exclude_team_member_id",
|
||||
Error: "exclude_team_member_id must be positive when set",
|
||||
})
|
||||
}
|
||||
|
||||
var usersN, teamN int64
|
||||
var err error
|
||||
if validUserRole {
|
||||
usersN, err = h.userSvc.BulkDeactivateUsersByRole(c.Context(), roleKey, actorID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Bulk user deactivation failed",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if validTeamRole {
|
||||
teamN, err = h.teamSvc.BulkDeactivateTeamMembersByRole(c.Context(), roleKey, req.ExcludeTeamMemberID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Bulk team member deactivation failed",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
out := domain.BulkDeactivateAccountsByRoleResult{
|
||||
Role: roleKey,
|
||||
UsersDeactivated: usersN,
|
||||
TeamMembersDeactivated: teamN,
|
||||
}
|
||||
|
||||
actorRole := string(callerRole)
|
||||
ip := c.IP()
|
||||
ua := c.Get("User-Agent")
|
||||
meta, _ := json.Marshal(map[string]interface{}{
|
||||
"role": roleKey,
|
||||
"users_deactivated": usersN,
|
||||
"team_members_deactivated": teamN,
|
||||
"exclude_team_member_id": req.ExcludeTeamMemberID,
|
||||
})
|
||||
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionUserUpdated, domain.ResourceUser, &actorID, fmt.Sprintf("Bulk deactivated role %s (%d users, %d team members)", roleKey, usersN, teamN), meta, &ip, &ua)
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Bulk deactivation completed",
|
||||
Data: out,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// BulkReactivateAccountsByRole godoc
|
||||
// @Summary Bulk reactivate accounts by role (SUPER_ADMIN or ADMIN platform users only)
|
||||
// @Description Sets all platform users with the given role from DEACTIVATED to ACTIVE (except the caller) and all team_members with the given team_role from inactive to active. Path :role may be a role key or decimal RBAC roles.id (see bulk-deactivate). Path role must correspond to valid platform users.role or team_members.team_role (after resolving id → name). SUPER_ADMIN cannot be bulk changed. ADMIN callers cannot bulk change other platform ADMIN users (team_members ADMIN under path ADMIN is allowed). Matches only users currently DEACTIVATED and team rows currently inactive.
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param role path string true "Role key (INSTRUCTOR etc.) or RBAC roles.id (integer string)"
|
||||
// @Param body body domain.BulkAccountsByRoleRequest false "Optional exclusions"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 403 {object} domain.ErrorResponse
|
||||
// @Failure 404 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/admin/roles/{role}/bulk-reactivate [post]
|
||||
func (h *Handler) BulkReactivateAccountsByRole(c *fiber.Ctx) error {
|
||||
callerRole, ok := c.Locals("role").(domain.Role)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
||||
Message: "Forbidden",
|
||||
Error: "role not found in context",
|
||||
})
|
||||
}
|
||||
if callerRole != domain.RoleSuperAdmin && callerRole != domain.RoleAdmin {
|
||||
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
||||
Message: "Forbidden",
|
||||
Error: "only SUPER_ADMIN or ADMIN platform users may bulk reactivate by role",
|
||||
})
|
||||
}
|
||||
|
||||
actorID, ok := c.Locals("user_id").(int64)
|
||||
if !ok || actorID <= 0 {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
||||
Message: "Unauthorized",
|
||||
Error: "user id not found in context",
|
||||
})
|
||||
}
|
||||
|
||||
roleKey, rpOK := h.bulkAccountsRoleFromPath(c)
|
||||
if !rpOK {
|
||||
return nil
|
||||
}
|
||||
if roleKey == string(domain.RoleSuperAdmin) || roleKey == string(domain.TeamRoleSuperAdmin) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Refusing bulk reactivate",
|
||||
Error: "SUPER_ADMIN role cannot be bulk reactivated via this endpoint",
|
||||
})
|
||||
}
|
||||
|
||||
validUserRole := domain.Role(roleKey).IsValid()
|
||||
validTeamRole := domain.TeamRole(roleKey).IsValid()
|
||||
if !validUserRole && !validTeamRole {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid role",
|
||||
Error: "role is not a valid platform users.role nor team_members.team_role",
|
||||
})
|
||||
}
|
||||
|
||||
// Non-super-admins cannot bulk change other platform ADMIN users; team_members ADMIN is still allowed.
|
||||
if callerRole != domain.RoleSuperAdmin && roleKey == string(domain.RoleAdmin) {
|
||||
validUserRole = false
|
||||
}
|
||||
|
||||
var req domain.BulkAccountsByRoleRequest
|
||||
if len(c.Body()) > 0 {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if req.ExcludeTeamMemberID != nil && *req.ExcludeTeamMemberID <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid exclude_team_member_id",
|
||||
Error: "exclude_team_member_id must be positive when set",
|
||||
})
|
||||
}
|
||||
|
||||
var usersN, teamN int64
|
||||
var err error
|
||||
if validUserRole {
|
||||
usersN, err = h.userSvc.BulkReactivateUsersByRole(c.Context(), roleKey, actorID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Bulk user reactivation failed",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if validTeamRole {
|
||||
teamN, err = h.teamSvc.BulkReactivateTeamMembersByRole(c.Context(), roleKey, req.ExcludeTeamMemberID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Bulk team member reactivation failed",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
out := domain.BulkReactivateAccountsByRoleResult{
|
||||
Role: roleKey,
|
||||
UsersReactivated: usersN,
|
||||
TeamMembersReactivated: teamN,
|
||||
}
|
||||
|
||||
actorRoleStr := string(callerRole)
|
||||
ip := c.IP()
|
||||
ua := c.Get("User-Agent")
|
||||
meta, _ := json.Marshal(map[string]interface{}{
|
||||
"role": roleKey,
|
||||
"users_reactivated": usersN,
|
||||
"team_members_reactivated": teamN,
|
||||
"exclude_team_member_id": req.ExcludeTeamMemberID,
|
||||
})
|
||||
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRoleStr, domain.ActionUserUpdated, domain.ResourceUser, &actorID, fmt.Sprintf("Bulk reactivated role %s (%d users, %d team members)", roleKey, usersN, teamN), meta, &ip, &ua)
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Bulk reactivation completed",
|
||||
Data: out,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ func (h *Handler) LoginAdmin(c *fiber.Ctx) error {
|
|||
}
|
||||
}
|
||||
|
||||
if successRes.Role == domain.RoleStudent || successRes.Role == domain.RoleInstructor {
|
||||
if successRes.Role == domain.RoleStudent || successRes.Role == domain.RoleOpenLearner || successRes.Role == domain.RoleInstructor {
|
||||
h.mongoLoggerSvc.Warn("Login attempt: admin login of user",
|
||||
zap.Int("status_code", fiber.StatusForbidden),
|
||||
zap.String("role", string(successRes.Role)),
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import (
|
|||
|
||||
// CreateCourse godoc
|
||||
// @Summary Create course
|
||||
// @Description Create a course under a program
|
||||
// @Description Create a course under a program. Optional sort_order assigns position within that program (siblings shifted); omit to append after the current highest sort_order in the program.
|
||||
// @Tags courses
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
|
|
|
|||
|
|
@ -74,7 +74,8 @@ func (h *Handler) ListExamPrepPracticesByLesson(c *fiber.Ctx) error {
|
|||
}
|
||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
||||
items, total, err := h.examPrepSvc.ListExamPrepPracticesByLesson(c.Context(), lessonID, int32(limit), int32(offset))
|
||||
publishedOnly := !h.canManageExamPrepPractices(c)
|
||||
items, total, err := h.examPrepSvc.ListExamPrepPracticesByLesson(c.Context(), lessonID, publishedOnly, int32(limit), int32(offset))
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrLessonNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
|
|
@ -126,6 +127,9 @@ func (h *Handler) GetExamPrepPracticeByID(c *fiber.Ctx) error {
|
|||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if !p.VisibleToLearners() && !h.canManageExamPrepPractices(c) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Practice retrieved successfully",
|
||||
Data: p,
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ func (h *Handler) CompleteLesson(c *fiber.Ctx) error {
|
|||
}
|
||||
uid := c.Locals("user_id").(int64)
|
||||
role := c.Locals("role").(domain.Role)
|
||||
if role == domain.RoleStudent {
|
||||
if role.UsesLMSSequentialGating() {
|
||||
ok, reason, err := h.lmsProgressSvc.CanAccessLesson(c.Context(), uid, id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"Yimaru-Backend/internal/domain"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
|
@ -30,3 +33,110 @@ func (h *Handler) GetMyLMSProgress(c *fiber.Ctx) error {
|
|||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminGetUserLMSLearningActivity godoc
|
||||
// @Summary Get a user's nested LMS learning activity (admin)
|
||||
// @Description Returns programs, courses, modules, and lessons with completion details and completed practices. Only persisted completion signals are included (completed lessons, completed published practices, and rollup completion timestamps—not partial or in-progress attempts).
|
||||
// @Tags lms
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param user_id path int true "Target user ID"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/admin/users/{user_id}/lms-learning-activity [get]
|
||||
func (h *Handler) AdminGetUserLMSLearningActivity(c *fiber.Ctx) error {
|
||||
targetIDStr := c.Params("user_id")
|
||||
targetID, err := strconv.ParseInt(targetIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid user ID",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if targetID <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid user ID",
|
||||
Error: "user ID must be a positive integer",
|
||||
})
|
||||
}
|
||||
tree, err := h.lmsProgressSvc.AdminUserLearningActivityTree(c.Context(), targetID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to load LMS learning activity",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "LMS learning activity retrieved successfully",
|
||||
Data: tree,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminGetUserRecentActivity godoc
|
||||
// @Summary Recent activity timeline for a user (admin)
|
||||
// @Description Reverse-chronological feed for profile UI: account joined plus LMS completion milestones (lessons/modules/courses/programs). Optional practice completions via include_practices. Does not include "started learning path" unless you add persisted engagement events—the schema stores completions only.
|
||||
// @Tags lms
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param user_id path int true "Target user ID"
|
||||
// @Param limit query int false "Max items after merge (default 40, max 120)"
|
||||
// @Param include_practices query bool false "Include completed LMS practices (more verbose)"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 404 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/admin/users/{user_id}/recent-activity [get]
|
||||
func (h *Handler) AdminGetUserRecentActivity(c *fiber.Ctx) error {
|
||||
targetIDStr := c.Params("user_id")
|
||||
targetID, err := strconv.ParseInt(targetIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid user ID",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if targetID <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid user ID",
|
||||
Error: "user ID must be a positive integer",
|
||||
})
|
||||
}
|
||||
|
||||
limit := 40
|
||||
if ls := c.Query("limit"); ls != "" {
|
||||
n, err := strconv.Atoi(ls)
|
||||
if err != nil || n < 1 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid limit",
|
||||
Error: "limit must be a positive integer",
|
||||
})
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
|
||||
includePractices := c.Query("include_practices") == "true" || c.Query("include_practices") == "1"
|
||||
|
||||
feed, err := h.lmsProgressSvc.AdminUserRecentActivity(c.Context(), targetID, limit, includePractices)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrUserNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "User not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to load recent activity",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Recent activity retrieved successfully",
|
||||
Data: feed,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,8 @@ func (h *Handler) ListPracticesByCourse(c *fiber.Ctx) error {
|
|||
}
|
||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
||||
items, total, err := h.practiceSvc.ListByCourse(c.Context(), courseID, int32(limit), int32(offset))
|
||||
publishedOnly := !h.canManageLMSPractices(c)
|
||||
items, total, err := h.practiceSvc.ListByCourse(c.Context(), courseID, publishedOnly, int32(limit), int32(offset))
|
||||
if err != nil {
|
||||
if errors.Is(err, courses.ErrCourseNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Course not found", Error: err.Error()})
|
||||
|
|
@ -107,7 +108,8 @@ func (h *Handler) ListPracticesByModule(c *fiber.Ctx) error {
|
|||
}
|
||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
||||
items, total, err := h.practiceSvc.ListByModule(c.Context(), moduleID, int32(limit), int32(offset))
|
||||
publishedOnly := !h.canManageLMSPractices(c)
|
||||
items, total, err := h.practiceSvc.ListByModule(c.Context(), moduleID, publishedOnly, int32(limit), int32(offset))
|
||||
if err != nil {
|
||||
if errors.Is(err, modules.ErrModuleNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Module not found", Error: err.Error()})
|
||||
|
|
@ -138,7 +140,8 @@ func (h *Handler) ListPracticesByLesson(c *fiber.Ctx) error {
|
|||
}
|
||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
||||
items, total, err := h.practiceSvc.ListByLesson(c.Context(), lessonID, int32(limit), int32(offset))
|
||||
publishedOnly := !h.canManageLMSPractices(c)
|
||||
items, total, err := h.practiceSvc.ListByLesson(c.Context(), lessonID, publishedOnly, int32(limit), int32(offset))
|
||||
if err != nil {
|
||||
if errors.Is(err, lessons.ErrLessonNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Lesson not found", Error: err.Error()})
|
||||
|
|
@ -174,6 +177,9 @@ func (h *Handler) GetPractice(c *fiber.Ctx) error {
|
|||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load practice", Error: err.Error()})
|
||||
}
|
||||
if !p.VisibleToLearners() && !h.canManageLMSPractices(c) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"})
|
||||
}
|
||||
return c.JSON(domain.Response{Message: "Practice retrieved successfully", Data: p, Success: true, StatusCode: fiber.StatusOK})
|
||||
}
|
||||
|
||||
|
|
|
|||
51
internal/web_server/handlers/practice_publish_gate.go
Normal file
51
internal/web_server/handlers/practice_publish_gate.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func (h *Handler) canManageLMSPractices(c *fiber.Ctx) bool {
|
||||
rn := string(c.Locals("role").(domain.Role))
|
||||
return h.rbacSvc.HasPermission(rn, "practices.create") || h.rbacSvc.HasPermission(rn, "practices.update")
|
||||
}
|
||||
|
||||
func (h *Handler) canManageExamPrepPractices(c *fiber.Ctx) bool {
|
||||
rn := string(c.Locals("role").(domain.Role))
|
||||
return h.rbacSvc.HasPermission(rn, "exam_prep.practices.create") || h.rbacSvc.HasPermission(rn, "exam_prep.practices.update")
|
||||
}
|
||||
|
||||
// forbidIfLinkedPracticeDraftForSubscriber returns 404 for draft LMS/exam-prep shells when the caller cannot manage content.
|
||||
func (h *Handler) forbidIfLinkedPracticeDraftForSubscriber(c *fiber.Ctx, questionSetID int64) error {
|
||||
if lp, ok, err := h.practiceSvc.TryGetByQuestionSetID(c.Context(), questionSetID); err != nil {
|
||||
return err
|
||||
} else if ok && !lp.VisibleToLearners() && !h.canManageLMSPractices(c) {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Practice not found")
|
||||
}
|
||||
|
||||
if ep, ok, err := h.examPrepSvc.TryGetExamPrepPracticeByQuestionSetID(c.Context(), questionSetID); err != nil {
|
||||
return err
|
||||
} else if ok && !ep.VisibleToLearners() && !h.canManageExamPrepPractices(c) {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Practice not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// forbidCompletingDraftPractice blocks completion when an LMS/exam-prep shell is still in draft (learners/students path).
|
||||
func (h *Handler) forbidCompletingDraftPractice(c *fiber.Ctx, questionSetID int64) error {
|
||||
if lp, ok, err := h.practiceSvc.TryGetByQuestionSetID(c.Context(), questionSetID); err != nil {
|
||||
return err
|
||||
} else if ok && !lp.VisibleToLearners() {
|
||||
return fiber.NewError(fiber.StatusForbidden, "Only published practices can be completed")
|
||||
}
|
||||
|
||||
if ep, ok, err := h.examPrepSvc.TryGetExamPrepPracticeByQuestionSetID(c.Context(), questionSetID); err != nil {
|
||||
return err
|
||||
} else if ok && !ep.VisibleToLearners() {
|
||||
return fiber.NewError(fiber.StatusForbidden, "Only published practices can be completed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import (
|
|||
|
||||
// CreateProgram godoc
|
||||
// @Summary Create program
|
||||
// @Description Create a top-level LMS program
|
||||
// @Description Create a top-level LMS program. Optional sort_order inserts at that global ordering; omit it to append after the current highest sort_order. Unique constraint applies to sort_order.
|
||||
// @Tags programs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"Yimaru-Backend/internal/domain"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -736,7 +737,7 @@ func isSequenceGatedPractice(set domain.QuestionSet) bool {
|
|||
|
||||
func (h *Handler) enforcePracticeSequenceForStudent(c *fiber.Ctx, set domain.QuestionSet) error {
|
||||
role := c.Locals("role").(domain.Role)
|
||||
if role != domain.RoleStudent || !isSequenceGatedPractice(set) {
|
||||
if !role.UsesLMSSequentialGating() || !isSequenceGatedPractice(set) {
|
||||
return nil
|
||||
}
|
||||
if !strings.EqualFold(set.Status, "PUBLISHED") {
|
||||
|
|
@ -1469,6 +1470,20 @@ func (h *Handler) GetQuestionsByPractice(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
if err := h.forbidIfLinkedPracticeDraftForSubscriber(c, set.ID); err != nil {
|
||||
code := fiber.StatusInternalServerError
|
||||
msg := err.Error()
|
||||
var ferr *fiber.Error
|
||||
if errors.As(err, &ferr) {
|
||||
code = ferr.Code
|
||||
msg = ferr.Message
|
||||
}
|
||||
return c.Status(code).JSON(domain.ErrorResponse{
|
||||
Message: msg,
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.enforcePracticeSequenceForStudent(c, set); err != nil {
|
||||
status := fiber.StatusForbidden
|
||||
if ferr, ok := err.(*fiber.Error); ok {
|
||||
|
|
@ -1532,7 +1547,7 @@ func (h *Handler) GetQuestionsByPractice(c *fiber.Ctx) error {
|
|||
// @Router /api/v1/progress/practices/{id}/complete [post]
|
||||
func (h *Handler) CompletePractice(c *fiber.Ctx) error {
|
||||
role := c.Locals("role").(domain.Role)
|
||||
if role != domain.RoleStudent {
|
||||
if !role.IsCustomerLearnerRole() {
|
||||
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
||||
Message: "Only learners can complete practices",
|
||||
})
|
||||
|
|
@ -1552,6 +1567,11 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
|
|||
var set domain.QuestionSet
|
||||
var setErr error
|
||||
if practiceErr == nil {
|
||||
if !practice.VisibleToLearners() {
|
||||
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
||||
Message: "Only published practices can be completed",
|
||||
})
|
||||
}
|
||||
set, setErr = h.questionsSvc.GetQuestionSetByID(c.Context(), practice.QuestionSetID)
|
||||
} else {
|
||||
// Backward compatibility: also accept question_set.id directly.
|
||||
|
|
@ -1566,6 +1586,21 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
|
|||
if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"})
|
||||
}
|
||||
if practiceErr != nil {
|
||||
if err := h.forbidCompletingDraftPractice(c, set.ID); err != nil {
|
||||
code := fiber.StatusInternalServerError
|
||||
msg := err.Error()
|
||||
var ferr *fiber.Error
|
||||
if errors.As(err, &ferr) {
|
||||
code = ferr.Code
|
||||
msg = ferr.Message
|
||||
}
|
||||
return c.Status(code).JSON(domain.ErrorResponse{
|
||||
Message: msg,
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce sequential gating only for published practices.
|
||||
if strings.EqualFold(set.Status, "PUBLISHED") {
|
||||
|
|
|
|||
|
|
@ -423,7 +423,7 @@ func (h *Handler) CheckUserPending(c *fiber.Ctx) error {
|
|||
|
||||
// GetAllUsers godoc
|
||||
// @Summary Get all users
|
||||
// @Description Get users with optional filters. Each user may include active_subscription when they have a current ACTIVE, non-expired plan.
|
||||
// @Description Get users with optional filters. Each user includes subscription_status: ACTIVE, PENDING, or Unsubscribed.
|
||||
// @Tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
|
|
@ -433,7 +433,10 @@ func (h *Handler) CheckUserPending(c *fiber.Ctx) error {
|
|||
// @Param page_size query int false "Page size"
|
||||
// @Param created_before query string false "Created before (RFC3339)"
|
||||
// @Param created_after query string false "Created after (RFC3339)"
|
||||
// @Param status query string false "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)"
|
||||
// @Param status query string false "User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)"
|
||||
// @Param country query string false "Country filter (case-insensitive match on stored value)"
|
||||
// @Param region query string false "Region filter (case-insensitive match on stored value)"
|
||||
// @Param subscription_status query string false "Derived subscription filter: ACTIVE, PENDING, or Unsubscribed (matches response subscription_status semantics)"
|
||||
// @Success 200 {object} response.APIResponse
|
||||
// @Failure 400 {object} response.APIResponse
|
||||
// @Failure 500 {object} response.APIResponse
|
||||
|
|
@ -467,9 +470,27 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
|
|||
createdAfter = domain.ValidTime{Value: parsed, Valid: true}
|
||||
}
|
||||
|
||||
subscriptionStatusQuery := strings.TrimSpace(c.Query("subscription_status"))
|
||||
var subscriptionStatusFilter string
|
||||
if subscriptionStatusQuery != "" {
|
||||
switch strings.ToUpper(subscriptionStatusQuery) {
|
||||
case "ACTIVE":
|
||||
subscriptionStatusFilter = "ACTIVE"
|
||||
case "PENDING":
|
||||
subscriptionStatusFilter = "PENDING"
|
||||
case "UNSUBSCRIBED":
|
||||
subscriptionStatusFilter = "Unsubscribed"
|
||||
default:
|
||||
return fiber.NewError(fiber.StatusBadRequest, `Invalid subscription_status; use ACTIVE, PENDING, or Unsubscribed`)
|
||||
}
|
||||
}
|
||||
|
||||
filter := domain.UserFilter{
|
||||
Role: c.Query("role"),
|
||||
Status: c.Query("status"),
|
||||
Country: strings.TrimSpace(c.Query("country")),
|
||||
Region: strings.TrimSpace(c.Query("region")),
|
||||
SubscriptionStatus: subscriptionStatusFilter,
|
||||
Page: int64(c.QueryInt("page", 1) - 1),
|
||||
PageSize: int64(c.QueryInt("page_size", 10)),
|
||||
Query: searchString.Value,
|
||||
|
|
@ -503,9 +524,9 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
|
|||
for i, u := range users {
|
||||
userIDs[i] = u.ID
|
||||
}
|
||||
activeSubs, err := h.subscriptionsSvc.ListActiveSubscriptionsForUserIDs(c.Context(), userIDs)
|
||||
subStatuses, err := h.subscriptionsSvc.ListSubscriptionDisplayStatusesForUserIDs(c.Context(), userIDs)
|
||||
if err != nil {
|
||||
h.mongoLoggerSvc.Error("failed to batch-load active subscriptions for user list",
|
||||
h.mongoLoggerSvc.Error("failed to batch-load subscription display status for user list",
|
||||
zap.Int("status_code", fiber.StatusInternalServerError),
|
||||
zap.Error(err),
|
||||
zap.Time("timestamp", time.Now()))
|
||||
|
|
@ -551,9 +572,11 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
|
|||
if !u.BirthDay.IsZero() {
|
||||
bd = u.BirthDay.Format("2006-01-02")
|
||||
}
|
||||
var activeSub *domain.UserSubscriptionSummary
|
||||
if sub, ok := activeSubs[u.ID]; ok {
|
||||
activeSub = sub.Summary()
|
||||
var subStatus string
|
||||
if s, ok := subStatuses[u.ID]; ok {
|
||||
subStatus = s
|
||||
} else {
|
||||
subStatus = "Unsubscribed"
|
||||
}
|
||||
|
||||
mapped = append(mapped, domain.UserProfileResponse{
|
||||
|
|
@ -585,7 +608,7 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
|
|||
PreferredLanguage: u.PreferredLanguage,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
ActiveSubscription: activeSub,
|
||||
SubscriptionStatus: subStatus,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1405,6 +1428,17 @@ func (h *Handler) GetUserProfile(c *fiber.Ctx) error {
|
|||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user profile:"+err.Error())
|
||||
}
|
||||
|
||||
subscriptionStatus, err := h.subscriptionsSvc.GetSubscriptionDisplayStatusForUserID(c.Context(), user.ID)
|
||||
if err != nil {
|
||||
h.mongoLoggerSvc.Error("Failed to get subscription display status for profile",
|
||||
zap.Int64("userID", userID),
|
||||
zap.Int("status_code", fiber.StatusInternalServerError),
|
||||
zap.Error(err),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve subscription status:"+err.Error())
|
||||
}
|
||||
|
||||
lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID)
|
||||
if err != nil {
|
||||
if err != authentication.ErrRefreshTokenNotFound {
|
||||
|
|
@ -1448,6 +1482,7 @@ func (h *Handler) GetUserProfile(c *fiber.Ctx) error {
|
|||
PreferredLanguage: user.PreferredLanguage,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
SubscriptionStatus: subscriptionStatus,
|
||||
}
|
||||
return response.WriteJSON(c, fiber.StatusOK, "User profile retrieved successfully", res, nil)
|
||||
}
|
||||
|
|
@ -1502,6 +1537,17 @@ func (h *Handler) AdminProfile(c *fiber.Ctx) error {
|
|||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user profile:"+err.Error())
|
||||
}
|
||||
|
||||
subscriptionStatus, err := h.subscriptionsSvc.GetSubscriptionDisplayStatusForUserID(c.Context(), user.ID)
|
||||
if err != nil {
|
||||
h.mongoLoggerSvc.Error("Failed to get subscription display status for admin profile",
|
||||
zap.Int64("userID", userID),
|
||||
zap.Int("status_code", fiber.StatusInternalServerError),
|
||||
zap.Error(err),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve subscription status:"+err.Error())
|
||||
}
|
||||
|
||||
lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID)
|
||||
if err != nil {
|
||||
if err != authentication.ErrRefreshTokenNotFound {
|
||||
|
|
@ -1537,6 +1583,7 @@ func (h *Handler) AdminProfile(c *fiber.Ctx) error {
|
|||
PreferredLanguage: user.PreferredLanguage,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
SubscriptionStatus: subscriptionStatus,
|
||||
}
|
||||
// Ensure birthday is included and formatted
|
||||
if !user.BirthDay.IsZero() {
|
||||
|
|
@ -1621,6 +1668,21 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error {
|
|||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get users: "+err.Error())
|
||||
}
|
||||
|
||||
userIDs := make([]int64, len(users))
|
||||
for i, u := range users {
|
||||
userIDs[i] = u.ID
|
||||
}
|
||||
subStatuses, err := h.subscriptionsSvc.ListSubscriptionDisplayStatusesForUserIDs(c.Context(), userIDs)
|
||||
if err != nil {
|
||||
h.mongoLoggerSvc.Error("SearchUserByNameOrPhone - failed to load subscription status",
|
||||
zap.Any("request", req),
|
||||
zap.Int("status_code", fiber.StatusInternalServerError),
|
||||
zap.Error(err),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get subscription info: "+err.Error())
|
||||
}
|
||||
|
||||
res := make([]domain.UserProfileResponse, 0, len(users))
|
||||
for _, user := range users {
|
||||
lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID)
|
||||
|
|
@ -1637,6 +1699,11 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error {
|
|||
lastLogin = &user.CreatedAt
|
||||
}
|
||||
|
||||
subStatus := "Unsubscribed"
|
||||
if s, ok := subStatuses[user.ID]; ok {
|
||||
subStatus = s
|
||||
}
|
||||
|
||||
// var orgID *int64
|
||||
// if user.OrganizationID.Valid {
|
||||
// orgID = &user.OrganizationID.Value
|
||||
|
|
@ -1669,6 +1736,7 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error {
|
|||
PreferredLanguage: user.PreferredLanguage,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
SubscriptionStatus: subStatus,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1711,6 +1779,17 @@ func (h *Handler) GetUserByID(c *fiber.Ctx) error {
|
|||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get user: "+err.Error())
|
||||
}
|
||||
|
||||
subscriptionStatus, err := h.subscriptionsSvc.GetSubscriptionDisplayStatusForUserID(c.Context(), user.ID)
|
||||
if err != nil {
|
||||
h.mongoLoggerSvc.Error("Failed to get subscription display status for user by id",
|
||||
zap.Int64("userID", userID),
|
||||
zap.Int("status_code", fiber.StatusInternalServerError),
|
||||
zap.Error(err),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve subscription status: "+err.Error())
|
||||
}
|
||||
|
||||
lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID)
|
||||
if err != nil && err != authentication.ErrRefreshTokenNotFound {
|
||||
h.mongoLoggerSvc.Error("Failed to get user last login",
|
||||
|
|
@ -1765,6 +1844,7 @@ func (h *Handler) GetUserByID(c *fiber.Ctx) error {
|
|||
PreferredLanguage: user.PreferredLanguage,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
SubscriptionStatus: subscriptionStatus,
|
||||
}
|
||||
|
||||
return response.WriteJSON(c, fiber.StatusOK, "User retrieved successfully", res, nil)
|
||||
|
|
@ -1853,7 +1933,7 @@ func (h *Handler) DeleteMyUserAccount(c *fiber.Ctx) error {
|
|||
if !ok {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Invalid authenticated role")
|
||||
}
|
||||
if role != domain.RoleStudent {
|
||||
if !role.IsCustomerLearnerRole() {
|
||||
return fiber.NewError(fiber.StatusForbidden, "Only learners can delete their own account using this endpoint")
|
||||
}
|
||||
|
||||
|
|
@ -1906,7 +1986,7 @@ func (h *Handler) CancelMyUserAccountDeletion(c *fiber.Ctx) error {
|
|||
if !ok {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Invalid authenticated role")
|
||||
}
|
||||
if role != domain.RoleStudent {
|
||||
if !role.IsCustomerLearnerRole() {
|
||||
return fiber.NewError(fiber.StatusForbidden, "Only learners can cancel their own account deletion using this endpoint")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ func (a *App) RequireActiveSubscription() fiber.Handler {
|
|||
switch role {
|
||||
case domain.RoleSuperAdmin, domain.RoleAdmin, domain.RoleInstructor, domain.RoleSupport:
|
||||
return c.Next()
|
||||
case domain.RoleStudent:
|
||||
case domain.RoleStudent, domain.RoleOpenLearner:
|
||||
userID, ok := c.Locals("user_id").(int64)
|
||||
if !ok || userID == 0 {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Unauthorized")
|
||||
|
|
|
|||
|
|
@ -277,6 +277,8 @@ func (a *App) initAppRoutes() {
|
|||
groupV1.Get("/user/:user_id/is-profile-completed", a.authMiddleware, a.RequirePermission("users.profile_completed"), h.CheckProfileCompleted)
|
||||
groupV1.Get("/users", a.authMiddleware, a.RequirePermission("users.list"), h.GetAllUsers)
|
||||
groupV1.Get("/admin/users/deletion-requests", a.authMiddleware, a.RequirePermission("users.deletion_requests.list"), h.ListAccountDeletionRequests)
|
||||
groupV1.Get("/admin/users/:user_id/lms-learning-activity", a.authMiddleware, a.RequirePermission("progress.get_any_user"), h.AdminGetUserLMSLearningActivity)
|
||||
groupV1.Get("/admin/users/:user_id/recent-activity", a.authMiddleware, a.RequirePermission("progress.get_any_user"), h.AdminGetUserRecentActivity)
|
||||
groupV1.Get("/users/summary", a.authMiddleware, a.RequirePermission("users.summary"), h.GetUserSummary)
|
||||
groupV1.Put("/user", a.authMiddleware, a.RequirePermission("users.update_self"), h.UpdateUser)
|
||||
groupV1.Patch("/user/status", a.authMiddleware, a.RequirePermission("users.update_status"), h.UpdateUserStatus)
|
||||
|
|
@ -303,6 +305,8 @@ func (a *App) initAppRoutes() {
|
|||
groupV1.Get("/admin/:id", a.authMiddleware, a.RequirePermission("admins.get"), h.GetAdminByID)
|
||||
groupV1.Post("/admin", a.authMiddleware, a.RequirePermission("admins.create"), h.CreateAdmin)
|
||||
groupV1.Put("/admin/:id", a.authMiddleware, a.RequirePermission("admins.update"), h.UpdateAdmin)
|
||||
groupV1.Post("/admin/roles/:role/bulk-deactivate", a.authMiddleware, h.BulkDeactivateAccountsByRole)
|
||||
groupV1.Post("/admin/roles/:role/bulk-reactivate", a.authMiddleware, h.BulkReactivateAccountsByRole)
|
||||
|
||||
// Logs
|
||||
groupV1.Get("/logs", a.authMiddleware, a.RequirePermission("logs.list"), handlers.GetLogsHandler(context.Background()))
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user