Compare commits

..

14 Commits

Author SHA1 Message Date
7e61e34292 Add OPEN_LEARNER role without LMS sequential gating.
Migration 000061 inserts the RBAC role and demo user (openlearner@yimaru.com). STUDENT keeps sequential ApplyAccess and practice ordering; OPEN_LEARNER shares learner permissions and customer flows. Document the role in Swagger and point initial seed SQL at the migration for the demo account.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 10:26:25 -07:00
83db13bed0 Honor optional sort_order on module create under a course.
Accept sort_order in CreateModuleInput, shift siblings when set, and default to max+1 when omitted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 04:15:18 -07:00
12ad59c409 Add draft vs published status for LMS and exam-prep practices.
Expose publish_status on create/update, filter learner-facing lists and gates, and add migration 000060.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 03:57:43 -07:00
37aef49e28 Honor optional sort_order on course create under a program.
Parses body sort_order, shifts sibling courses in-program, and inserts at the requested slot; omitting it keeps append-after-max behavior. Swagger/sqlc regenerated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 02:54:17 -07:00
1136a166f5 Shift sibling sort_order when creating or updating LMS hierarchy rows.
Sequential reorder uses a temporary negative id slot plus range shifts so UNIQUE constraints on programs, courses, modules, and lessons stay valid; replaces module pairwise swap-only behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 02:38:29 -07:00
d28bddace1 Accept optional sort_order when creating LMS programs.
Preserve append-after-max ordering when omitting sort_order and keep global uniqueness enforced by DB.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 02:10:49 -07:00
4a681265d7 Resolve bulk role path segment from RBAC roles.id.
Admin bulk deactivate/reactivate accepts decimal path segments matching GET /rbac/roles IDs, resolving RoleRecord.name to the platform key. Document 404 when id is unknown. Add Cursor rule: on push, commit dirty tree first without secrets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 01:16:28 -07:00
2f73b60122 Allow ADMIN users to bulk deactivate and reactivate by role.
Platform ADMIN callers no longer hit 403 on these endpoints; bulk changes to platform users.role ADMIN remain restricted to SUPER_ADMIN, while team_members.team_role ADMIN is still eligible under path ADMIN.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 01:09:42 -07:00
ecad91d89e Add SUPER_ADMIN bulk deactivate and reactivate by role.
Expose POST /admin/roles/:role/bulk-deactivate and bulk-reactivate for platform users and team_members, mirroring deactivate/reactivate semantics and optional team member exclusions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:52:14 -07:00
a80db8afd9 Add admin recent-activity timeline for learner profile UIs.
Expose GET /api/v1/admin/users/:user_id/recent-activity (progress.get_any_user) merging account creation and LMS completion milestones, with optional practice rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 01:13:21 -07:00
52effaa321 Add admin endpoint for nested user LMS completion activity.
Expose GET /api/v1/admin/users/:user_id/lms-learning-activity for progress.get_any_user so admins see program/course/module/lesson completions and practices from stored completion rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 00:58:49 -07:00
062b1f6151 Add country, region, and subscription_status filters to GET /users.
Filtering matches user profile country/region (case-insensitive trim) and derived subscription state in SQL so pagination totals stay correct.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 00:37:11 -07:00
49bcc22d0d Expose subscription_status on user profile responses instead of active_subscription.
Users see ACTIVE, PENDING, or Unsubscribed via new batch and single SQL helpers; Swagger refreshed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 00:28:19 -07:00
1e62510321 Always serialize active_subscription on profile responses.
Null encodes when there is no active plan so clients see explicit subscription state; Swagger regenerated and GET /users description updated accordingly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 00:16:10 -07:00
86 changed files with 4766 additions and 658 deletions

View 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 repos 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.

View File

@ -4,6 +4,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ====================================================== -- ======================================================
-- Customer/Learner Users (login via /api/v1/auth/customer-login) -- Customer/Learner Users (login via /api/v1/auth/customer-login)
-- Credentials: email + password@123 -- Credentials: email + password@123
-- OPEN_LEARNER demo user is seeded by migration 000061_open_learner_role (not here).
-- ====================================================== -- ======================================================
INSERT INTO users ( INSERT INTO users (
id, id,

View 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;

View 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'));

View 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';

View 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;

View File

@ -6,8 +6,9 @@ INSERT INTO exam_prep.lesson_practices (
story_image, story_image,
persona_id, persona_id,
question_set_id, question_set_id,
quick_tips quick_tips,
) VALUES ($1, $2, $3, $4, $5, $6, $7) publish_status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *; RETURNING *;
-- name: ExamPrepGetLessonPracticeByID :one -- name: ExamPrepGetLessonPracticeByID :one
@ -15,6 +16,13 @@ SELECT *
FROM exam_prep.lesson_practices FROM exam_prep.lesson_practices
WHERE id = $1; 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 -- name: ExamPrepListLessonPracticesByLessonID :many
SELECT SELECT
COUNT(*) OVER () AS total_count, COUNT(*) OVER () AS total_count,
@ -26,10 +34,15 @@ SELECT
p.persona_id, p.persona_id,
p.question_set_id, p.question_set_id,
p.quick_tips, p.quick_tips,
p.publish_status,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM exam_prep.lesson_practices p FROM exam_prep.lesson_practices p
WHERE p.unit_module_lesson_id = $1 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 ORDER BY p.created_at DESC
LIMIT $2 LIMIT $2
OFFSET $3; OFFSET $3;
@ -43,6 +56,7 @@ SET
persona_id = coalesce(sqlc.narg('persona_id')::bigint, persona_id), persona_id = coalesce(sqlc.narg('persona_id')::bigint, persona_id),
question_set_id = coalesce(sqlc.narg('question_set_id')::bigint, question_set_id), question_set_id = coalesce(sqlc.narg('question_set_id')::bigint, question_set_id),
quick_tips = coalesce(sqlc.narg('quick_tips')::text, quick_tips), quick_tips = coalesce(sqlc.narg('quick_tips')::text, quick_tips),
publish_status = coalesce(sqlc.narg('publish_status')::varchar, publish_status),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id') WHERE id = sqlc.arg('id')
RETURNING *; RETURNING *;

View 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;

View File

@ -1,16 +1,17 @@
-- name: CreateCourse :one -- name: CreateCourse :one
INSERT INTO courses (program_id, name, description, thumbnail, sort_order) INSERT INTO courses (program_id, name, description, thumbnail, sort_order)
SELECT SELECT
$1, sqlc.arg('program_id'),
$2, sqlc.arg('name'),
$3, sqlc.arg('description'),
$4, sqlc.arg('thumbnail'),
coalesce(( COALESCE(sqlc.narg('sort_order')::int,
SELECT COALESCE((
max(c.sort_order) SELECT
FROM courses c max(c.sort_order)
WHERE FROM courses c
c.program_id = $1), 0) + 1 WHERE
c.program_id = sqlc.arg('program_id')), 0) + 1)
RETURNING RETURNING
*; *;
@ -23,6 +24,7 @@ SELECT
WHERE p.course_id = c.id WHERE p.course_id = c.id
AND p.module_id IS NULL AND p.module_id IS NULL
AND p.lesson_id IS NULL AND p.lesson_id IS NULL
AND p.publish_status = 'PUBLISHED'
) AS has_practice ) AS has_practice
FROM courses FROM courses
c c
@ -74,13 +76,15 @@ SELECT
WHERE WHERE
p.course_id = c.id p.course_id = c.id
AND p.module_id IS NULL 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 ( EXISTS (
SELECT 1 SELECT 1
FROM lms_practices p FROM lms_practices p
WHERE p.course_id = c.id WHERE p.course_id = c.id
AND p.module_id IS NULL AND p.module_id IS NULL
AND p.lesson_id IS NULL AND p.lesson_id IS NULL
AND p.publish_status = 'PUBLISHED'
) AS has_practice ) AS has_practice
FROM FROM
courses c courses c

View File

@ -22,6 +22,7 @@ SELECT
SELECT 1 SELECT 1
FROM lms_practices p FROM lms_practices p
WHERE p.lesson_id = l.id WHERE p.lesson_id = l.id
AND p.publish_status = 'PUBLISHED'
) AS has_practice ) AS has_practice
FROM lessons FROM lessons
l l
@ -43,6 +44,7 @@ SELECT
SELECT 1 SELECT 1
FROM lms_practices p FROM lms_practices p
WHERE p.lesson_id = l.id WHERE p.lesson_id = l.id
AND p.publish_status = 'PUBLISHED'
) AS has_practice ) AS has_practice
FROM FROM
lessons l lessons l

View File

@ -1,17 +1,18 @@
-- name: CreateModule :one -- name: CreateModule :one
INSERT INTO modules (program_id, course_id, name, description, icon, sort_order) INSERT INTO modules (program_id, course_id, name, description, icon, sort_order)
SELECT SELECT
$1, sqlc.arg('program_id'),
$2, sqlc.arg('course_id'),
$3, sqlc.arg('name'),
$4, sqlc.arg('description'),
$5, sqlc.arg('icon'),
coalesce(( COALESCE(sqlc.narg('sort_order')::int,
SELECT COALESCE((
max(m.sort_order) SELECT
FROM modules m max(m.sort_order)
WHERE FROM modules m
m.course_id = $2), 0) + 1 WHERE
m.course_id = sqlc.arg('course_id')), 0) + 1)
RETURNING RETURNING
*; *;
@ -23,6 +24,7 @@ SELECT
FROM lms_practices p FROM lms_practices p
WHERE p.module_id = m.id WHERE p.module_id = m.id
AND p.lesson_id IS NULL AND p.lesson_id IS NULL
AND p.publish_status = 'PUBLISHED'
) AS has_practice ) AS has_practice
FROM modules FROM modules
m m
@ -55,6 +57,7 @@ SELECT
FROM lms_practices p FROM lms_practices p
WHERE p.module_id = m.id WHERE p.module_id = m.id
AND p.lesson_id IS NULL AND p.lesson_id IS NULL
AND p.publish_status = 'PUBLISHED'
) AS has_practice ) AS has_practice
FROM FROM
modules m modules m

View File

@ -1,8 +1,8 @@
-- name: CreateLmsPractice :one -- name: CreateLmsPractice :one
INSERT INTO lms_practices ( INSERT INTO lms_practices (
course_id, module_id, lesson_id, course_id, module_id, lesson_id,
title, story_description, story_image, persona_id, question_set_id, quick_tips title, story_description, story_image, persona_id, question_set_id, quick_tips, publish_status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *; RETURNING *;
-- name: GetLmsPracticeByID :one -- name: GetLmsPracticeByID :one
@ -10,6 +10,13 @@ SELECT *
FROM lms_practices FROM lms_practices
WHERE id = $1; WHERE id = $1;
-- name: GetLmsPracticeByQuestionSetID :one
SELECT *
FROM lms_practices
WHERE question_set_id = $1
ORDER BY id DESC
LIMIT 1;
-- name: ListLmsPracticesByCourseID :many -- name: ListLmsPracticesByCourseID :many
SELECT SELECT
COUNT(*) OVER () AS total_count, COUNT(*) OVER () AS total_count,
@ -23,10 +30,15 @@ SELECT
p.persona_id, p.persona_id,
p.question_set_id, p.question_set_id,
p.quick_tips, p.quick_tips,
p.publish_status,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM lms_practices p FROM lms_practices p
WHERE p.course_id = $1 WHERE p.course_id = $1
AND (
sqlc.arg('published_only')::boolean = FALSE
OR p.publish_status = 'PUBLISHED'::TEXT
)
ORDER BY p.created_at DESC ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3; LIMIT $2 OFFSET $3;
@ -43,10 +55,15 @@ SELECT
p.persona_id, p.persona_id,
p.question_set_id, p.question_set_id,
p.quick_tips, p.quick_tips,
p.publish_status,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM lms_practices p FROM lms_practices p
WHERE p.module_id = $1 WHERE p.module_id = $1
AND (
sqlc.arg('published_only')::boolean = FALSE
OR p.publish_status = 'PUBLISHED'::TEXT
)
ORDER BY p.created_at DESC ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3; LIMIT $2 OFFSET $3;
@ -63,10 +80,15 @@ SELECT
p.persona_id, p.persona_id,
p.question_set_id, p.question_set_id,
p.quick_tips, p.quick_tips,
p.publish_status,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM lms_practices p FROM lms_practices p
WHERE p.lesson_id = $1 WHERE p.lesson_id = $1
AND (
sqlc.arg('published_only')::boolean = FALSE
OR p.publish_status = 'PUBLISHED'::TEXT
)
ORDER BY p.created_at DESC ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3; LIMIT $2 OFFSET $3;
@ -79,6 +101,7 @@ SET
persona_id = COALESCE(sqlc.narg('persona_id')::bigint, persona_id), persona_id = COALESCE(sqlc.narg('persona_id')::bigint, persona_id),
question_set_id = COALESCE(sqlc.narg('question_set_id')::bigint, question_set_id), question_set_id = COALESCE(sqlc.narg('question_set_id')::bigint, question_set_id),
quick_tips = COALESCE(sqlc.narg('quick_tips')::text, quick_tips), quick_tips = COALESCE(sqlc.narg('quick_tips')::text, quick_tips),
publish_status = COALESCE(sqlc.narg('publish_status')::varchar, publish_status),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id') WHERE id = sqlc.arg('id')
RETURNING *; RETURNING *;

View File

@ -257,7 +257,8 @@ FROM
WHERE WHERE
lp.module_id = $1 lp.module_id = $1
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'; AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED';
-- name: CountUserCompletedPublishedPracticesInModule :one -- name: CountUserCompletedPublishedPracticesInModule :one
SELECT SELECT
@ -271,7 +272,8 @@ WHERE
AND upp.user_id = $2 AND upp.user_id = $2
AND upp.completed_at IS NOT NULL AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'; AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED';
-- name: CountPublishedPracticesInCourse :one -- name: CountPublishedPracticesInCourse :one
SELECT SELECT
@ -282,7 +284,8 @@ FROM
WHERE WHERE
lp.course_id = $1 lp.course_id = $1
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'; AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED';
-- name: CountUserCompletedPublishedPracticesInCourse :one -- name: CountUserCompletedPublishedPracticesInCourse :one
SELECT SELECT
@ -308,7 +311,8 @@ FROM
WHERE WHERE
c.program_id = $1 c.program_id = $1
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'; AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED';
-- name: CountUserCompletedPublishedPracticesInProgram :one -- name: CountUserCompletedPublishedPracticesInProgram :one
SELECT SELECT
@ -323,7 +327,8 @@ WHERE
AND upp.user_id = $2 AND upp.user_id = $2
AND upp.completed_at IS NOT NULL AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'; AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED';
-- name: GetPracticeScopeByQuestionSetID :one -- name: GetPracticeScopeByQuestionSetID :one
SELECT SELECT

View File

@ -1,13 +1,13 @@
-- name: CreateProgram :one -- name: CreateProgram :one
INSERT INTO programs (name, description, thumbnail, sort_order) INSERT INTO programs (name, description, thumbnail, sort_order)
SELECT SELECT
$1, sqlc.arg('name'),
$2, sqlc.arg('description'),
$3, sqlc.arg('thumbnail'),
coalesce(( COALESCE(sqlc.narg('sort_order')::int, COALESCE((
SELECT SELECT
max(p.sort_order) max(p.sort_order)
FROM programs AS p), 0) + 1 FROM programs AS p), 0) + 1)
RETURNING RETURNING
*; *;

View File

@ -61,28 +61,38 @@ FROM user_subscriptions us
JOIN subscription_plans sp ON sp.id = us.plan_id JOIN subscription_plans sp ON sp.id = us.plan_id
WHERE us.id = $1; WHERE us.id = $1;
-- name: ListActiveSubscriptionsByUserIDs :many -- Display status for admin user lists: ACTIVE (non-expired), else latest PENDING, else Unsubscribed.
-- One ACTIVE, non-expired row per user (latest expires_at wins), same rules as GetActiveSubscriptionByUserID. -- name: ListSubscriptionDisplayStatusesByUserIDs :many
SELECT DISTINCT ON (us.user_id) WITH input AS (
us.user_id, SELECT unnest($1::bigint[])::bigint AS user_id
us.id, )
us.plan_id, SELECT
us.starts_at, input.user_id,
us.expires_at, COALESCE(
us.status, (SELECT us.status::text FROM user_subscriptions us
us.auto_renew, WHERE us.user_id = input.user_id
us.payment_method, AND us.status = 'ACTIVE' AND us.expires_at > CURRENT_TIMESTAMP
sp.name AS plan_name, ORDER BY us.expires_at DESC LIMIT 1),
sp.duration_value, (SELECT us.status::text FROM user_subscriptions us
sp.duration_unit, WHERE us.user_id = input.user_id
sp.price, AND us.status = 'PENDING'
sp.currency ORDER BY us.created_at DESC LIMIT 1),
FROM user_subscriptions us 'Unsubscribed'
JOIN subscription_plans sp ON sp.id = us.plan_id )::text AS subscription_status
WHERE us.user_id = ANY($1::bigint[]) FROM input;
AND us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP -- name: GetSubscriptionDisplayStatusByUserID :one
ORDER BY us.user_id, us.expires_at DESC; 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 -- name: GetActiveSubscriptionByUserID :one
SELECT SELECT

View File

@ -140,6 +140,32 @@ WHERE id = $1;
DELETE FROM team_members DELETE FROM team_members
WHERE id = $1; 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 -- name: CheckTeamMemberEmailExists :one
SELECT EXISTS ( SELECT EXISTS (
SELECT 1 FROM team_members WHERE email = $1 SELECT 1 FROM team_members WHERE email = $1

View File

@ -141,6 +141,11 @@ RETURNING
updated_at; updated_at;
-- name: GetUserCreatedAt :one
SELECT created_at
FROM users
WHERE id = $1;
-- name: GetUserByID :one -- name: GetUserByID :one
SELECT * SELECT *
FROM users 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_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('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 ORDER BY created_at DESC
LIMIT sqlc.narg('limit')::INT LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT; OFFSET sqlc.narg('offset')::INT;
@ -376,6 +421,26 @@ SET
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $2; 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 -- name: GetUserSummary :one
SELECT SELECT
COUNT(*) AS total_users, COUNT(*) AS total_users,

View 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;

View File

@ -415,6 +415,8 @@ This creates the practice record scoped to lesson.
### Request ### 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 ```json
{ {
"parent_kind": "LESSON", "parent_kind": "LESSON",
@ -423,7 +425,8 @@ This creates the practice record scoped to lesson.
"story_description": "A short two-speaker scenario.", "story_description": "A short two-speaker scenario.",
"story_image": "https://cdn.example.com/images/story.webp", "story_image": "https://cdn.example.com/images/story.webp",
"question_set_id": 55, "question_set_id": 55,
"quick_tips": "Listen carefully before answering." "quick_tips": "Listen carefully before answering.",
"publish_status": "DRAFT"
} }
``` ```

View File

@ -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": { "/api/v1/admin/users/deletion-requests": {
"get": { "get": {
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination", "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}": { "/api/v1/admin/{id}": {
"get": { "get": {
"description": "Get a single admin by id", "description": "Get a single admin by id",
@ -3902,7 +4150,7 @@ const docTemplate = `{
} }
}, },
"post": { "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": [ "consumes": [
"application/json" "application/json"
], ],
@ -4147,7 +4395,7 @@ const docTemplate = `{
} }
}, },
"post": { "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": [ "consumes": [
"application/json" "application/json"
], ],
@ -8436,7 +8684,7 @@ const docTemplate = `{
}, },
"/api/v1/users": { "/api/v1/users": {
"get": { "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": [ "consumes": [
"application/json" "application/json"
], ],
@ -8486,9 +8734,27 @@ const docTemplate = `{
}, },
{ {
"type": "string", "type": "string",
"description": "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)", "description": "User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)",
"name": "status", "name": "status",
"in": "query" "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": { "responses": {
@ -10078,6 +10344,14 @@ const docTemplate = `{
} }
} }
}, },
"domain.BulkAccountsByRoleRequest": {
"type": "object",
"properties": {
"exclude_team_member_id": {
"type": "integer"
}
}
},
"domain.CreateCourseInput": { "domain.CreateCourseInput": {
"type": "object", "type": "object",
"required": [ "required": [
@ -10090,6 +10364,11 @@ const docTemplate = `{
"name": { "name": {
"type": "string" "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": { "thumbnail": {
"type": "string" "type": "string"
} }
@ -10289,6 +10568,11 @@ const docTemplate = `{
"name": { "name": {
"type": "string" "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": { "thumbnail": {
"type": "string" "type": "string"
} }
@ -10779,6 +11063,7 @@ const docTemplate = `{
"SUPER_ADMIN", "SUPER_ADMIN",
"ADMIN", "ADMIN",
"STUDENT", "STUDENT",
"OPEN_LEARNER",
"INSTRUCTOR", "INSTRUCTOR",
"SUPPORT" "SUPPORT"
], ],
@ -10786,6 +11071,7 @@ const docTemplate = `{
"RoleSuperAdmin", "RoleSuperAdmin",
"RoleAdmin", "RoleAdmin",
"RoleStudent", "RoleStudent",
"RoleOpenLearner",
"RoleInstructor", "RoleInstructor",
"RoleSupport" "RoleSupport"
] ]
@ -11294,9 +11580,6 @@ const docTemplate = `{
"domain.UserProfileResponse": { "domain.UserProfileResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"active_subscription": {
"$ref": "#/definitions/domain.UserSubscriptionSummary"
},
"age_group": { "age_group": {
"type": "string" "type": "string"
}, },
@ -11384,6 +11667,9 @@ const docTemplate = `{
"status": { "status": {
"$ref": "#/definitions/domain.UserStatus" "$ref": "#/definitions/domain.UserStatus"
}, },
"subscription_status": {
"type": "string"
},
"updated_at": { "updated_at": {
"type": "string" "type": "string"
} }
@ -11404,47 +11690,6 @@ const docTemplate = `{
"UserStatusDeactivated" "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": { "domain.UserSummary": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -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": { "/api/v1/admin/users/deletion-requests": {
"get": { "get": {
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination", "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}": { "/api/v1/admin/{id}": {
"get": { "get": {
"description": "Get a single admin by id", "description": "Get a single admin by id",
@ -3894,7 +4142,7 @@
} }
}, },
"post": { "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": [ "consumes": [
"application/json" "application/json"
], ],
@ -4139,7 +4387,7 @@
} }
}, },
"post": { "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": [ "consumes": [
"application/json" "application/json"
], ],
@ -8428,7 +8676,7 @@
}, },
"/api/v1/users": { "/api/v1/users": {
"get": { "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": [ "consumes": [
"application/json" "application/json"
], ],
@ -8478,9 +8726,27 @@
}, },
{ {
"type": "string", "type": "string",
"description": "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)", "description": "User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)",
"name": "status", "name": "status",
"in": "query" "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": { "responses": {
@ -10070,6 +10336,14 @@
} }
} }
}, },
"domain.BulkAccountsByRoleRequest": {
"type": "object",
"properties": {
"exclude_team_member_id": {
"type": "integer"
}
}
},
"domain.CreateCourseInput": { "domain.CreateCourseInput": {
"type": "object", "type": "object",
"required": [ "required": [
@ -10082,6 +10356,11 @@
"name": { "name": {
"type": "string" "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": { "thumbnail": {
"type": "string" "type": "string"
} }
@ -10281,6 +10560,11 @@
"name": { "name": {
"type": "string" "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": { "thumbnail": {
"type": "string" "type": "string"
} }
@ -10771,6 +11055,7 @@
"SUPER_ADMIN", "SUPER_ADMIN",
"ADMIN", "ADMIN",
"STUDENT", "STUDENT",
"OPEN_LEARNER",
"INSTRUCTOR", "INSTRUCTOR",
"SUPPORT" "SUPPORT"
], ],
@ -10778,6 +11063,7 @@
"RoleSuperAdmin", "RoleSuperAdmin",
"RoleAdmin", "RoleAdmin",
"RoleStudent", "RoleStudent",
"RoleOpenLearner",
"RoleInstructor", "RoleInstructor",
"RoleSupport" "RoleSupport"
] ]
@ -11286,9 +11572,6 @@
"domain.UserProfileResponse": { "domain.UserProfileResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"active_subscription": {
"$ref": "#/definitions/domain.UserSubscriptionSummary"
},
"age_group": { "age_group": {
"type": "string" "type": "string"
}, },
@ -11376,6 +11659,9 @@
"status": { "status": {
"$ref": "#/definitions/domain.UserStatus" "$ref": "#/definitions/domain.UserStatus"
}, },
"subscription_status": {
"type": "string"
},
"updated_at": { "updated_at": {
"type": "string" "type": "string"
} }
@ -11396,47 +11682,6 @@
"UserStatusDeactivated" "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": { "domain.UserSummary": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -327,12 +327,22 @@ definitions:
total_users: total_users:
type: integer type: integer
type: object type: object
domain.BulkAccountsByRoleRequest:
properties:
exclude_team_member_id:
type: integer
type: object
domain.CreateCourseInput: domain.CreateCourseInput:
properties: properties:
description: description:
type: string type: string
name: name:
type: string 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: thumbnail:
type: string type: string
required: required:
@ -463,6 +473,11 @@ definitions:
type: string type: string
name: name:
type: string 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: thumbnail:
type: string type: string
required: required:
@ -798,6 +813,7 @@ definitions:
- SUPER_ADMIN - SUPER_ADMIN
- ADMIN - ADMIN
- STUDENT - STUDENT
- OPEN_LEARNER
- INSTRUCTOR - INSTRUCTOR
- SUPPORT - SUPPORT
type: string type: string
@ -805,6 +821,7 @@ definitions:
- RoleSuperAdmin - RoleSuperAdmin
- RoleAdmin - RoleAdmin
- RoleStudent - RoleStudent
- RoleOpenLearner
- RoleInstructor - RoleInstructor
- RoleSupport - RoleSupport
domain.RoleRecord: domain.RoleRecord:
@ -1145,8 +1162,6 @@ definitions:
type: object type: object
domain.UserProfileResponse: domain.UserProfileResponse:
properties: properties:
active_subscription:
$ref: '#/definitions/domain.UserSubscriptionSummary'
age_group: age_group:
type: string type: string
birth_day: birth_day:
@ -1206,6 +1221,8 @@ definitions:
$ref: '#/definitions/domain.Role' $ref: '#/definitions/domain.Role'
status: status:
$ref: '#/definitions/domain.UserStatus' $ref: '#/definitions/domain.UserStatus'
subscription_status:
type: string
updated_at: updated_at:
type: string type: string
type: object type: object
@ -1221,33 +1238,6 @@ definitions:
- UserStatusActive - UserStatusActive
- UserStatusSuspended - UserStatusSuspended
- UserStatusDeactivated - 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: domain.UserSummary:
properties: properties:
active_users: active_users:
@ -2895,6 +2885,186 @@ paths:
summary: Update FAQ summary: Update FAQ
tags: tags:
- faqs - 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: /api/v1/admin/users/deletion-requests:
get: get:
consumes: consumes:
@ -5072,7 +5242,9 @@ paths:
post: post:
consumes: consumes:
- application/json - 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: parameters:
- description: Program - description: Program
in: body in: body
@ -5207,7 +5379,9 @@ paths:
post: post:
consumes: consumes:
- application/json - 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: parameters:
- description: Program ID - description: Program ID
in: path in: path
@ -8017,8 +8191,8 @@ paths:
get: get:
consumes: consumes:
- application/json - application/json
description: Get users with optional filters. Each user may include active_subscription description: 'Get users with optional filters. Each user includes subscription_status:
when they have a current ACTIVE, non-expired plan. ACTIVE, PENDING, or Unsubscribed.'
parameters: parameters:
- description: Role filter - description: Role filter
in: query in: query
@ -8044,10 +8218,23 @@ paths:
in: query in: query
name: created_after name: created_after
type: string type: string
- description: Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED) - description: User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)
in: query in: query
name: status name: status
type: string 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: produces:
- application/json - application/json
responses: responses:

View File

@ -19,9 +19,10 @@ INSERT INTO exam_prep.lesson_practices (
story_image, story_image,
persona_id, persona_id,
question_set_id, question_set_id,
quick_tips quick_tips,
) VALUES ($1, $2, $3, $4, $5, $6, $7) publish_status
RETURNING id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at ) 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 { type ExamPrepCreateLessonPracticeParams struct {
@ -32,6 +33,7 @@ type ExamPrepCreateLessonPracticeParams struct {
PersonaID pgtype.Int8 `json:"persona_id"` PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"` QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"` QuickTips pgtype.Text `json:"quick_tips"`
PublishStatus string `json:"publish_status"`
} }
func (q *Queries) ExamPrepCreateLessonPractice(ctx context.Context, arg ExamPrepCreateLessonPracticeParams) (ExamPrepLessonPractice, error) { 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.PersonaID,
arg.QuestionSetID, arg.QuestionSetID,
arg.QuickTips, arg.QuickTips,
arg.PublishStatus,
) )
var i ExamPrepLessonPractice var i ExamPrepLessonPractice
err := row.Scan( err := row.Scan(
@ -56,6 +59,7 @@ func (q *Queries) ExamPrepCreateLessonPractice(ctx context.Context, arg ExamPrep
&i.QuickTips, &i.QuickTips,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.PublishStatus,
) )
return i, err return i, err
} }
@ -71,7 +75,7 @@ func (q *Queries) ExamPrepDeleteLessonPractice(ctx context.Context, id int64) er
} }
const ExamPrepGetLessonPracticeByID = `-- name: ExamPrepGetLessonPracticeByID :one 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 FROM exam_prep.lesson_practices
WHERE id = $1 WHERE id = $1
` `
@ -90,6 +94,34 @@ func (q *Queries) ExamPrepGetLessonPracticeByID(ctx context.Context, id int64) (
&i.QuickTips, &i.QuickTips,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &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 return i, err
} }
@ -105,10 +137,15 @@ SELECT
p.persona_id, p.persona_id,
p.question_set_id, p.question_set_id,
p.quick_tips, p.quick_tips,
p.publish_status,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM exam_prep.lesson_practices p FROM exam_prep.lesson_practices p
WHERE p.unit_module_lesson_id = $1 WHERE p.unit_module_lesson_id = $1
AND (
$4::boolean = FALSE
OR p.publish_status = 'PUBLISHED'::TEXT
)
ORDER BY p.created_at DESC ORDER BY p.created_at DESC
LIMIT $2 LIMIT $2
OFFSET $3 OFFSET $3
@ -118,6 +155,7 @@ type ExamPrepListLessonPracticesByLessonIDParams struct {
UnitModuleLessonID int64 `json:"unit_module_lesson_id"` UnitModuleLessonID int64 `json:"unit_module_lesson_id"`
Limit int32 `json:"limit"` Limit int32 `json:"limit"`
Offset int32 `json:"offset"` Offset int32 `json:"offset"`
PublishedOnly bool `json:"published_only"`
} }
type ExamPrepListLessonPracticesByLessonIDRow struct { type ExamPrepListLessonPracticesByLessonIDRow struct {
@ -130,12 +168,18 @@ type ExamPrepListLessonPracticesByLessonIDRow struct {
PersonaID pgtype.Int8 `json:"persona_id"` PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"` QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"` QuickTips pgtype.Text `json:"quick_tips"`
PublishStatus string `json:"publish_status"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
func (q *Queries) ExamPrepListLessonPracticesByLessonID(ctx context.Context, arg ExamPrepListLessonPracticesByLessonIDParams) ([]ExamPrepListLessonPracticesByLessonIDRow, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -153,6 +197,7 @@ func (q *Queries) ExamPrepListLessonPracticesByLessonID(ctx context.Context, arg
&i.PersonaID, &i.PersonaID,
&i.QuestionSetID, &i.QuestionSetID,
&i.QuickTips, &i.QuickTips,
&i.PublishStatus,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@ -175,9 +220,10 @@ SET
persona_id = coalesce($4::bigint, persona_id), persona_id = coalesce($4::bigint, persona_id),
question_set_id = coalesce($5::bigint, question_set_id), question_set_id = coalesce($5::bigint, question_set_id),
quick_tips = coalesce($6::text, quick_tips), quick_tips = coalesce($6::text, quick_tips),
publish_status = coalesce($7::varchar, publish_status),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $7 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 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 { type ExamPrepUpdateLessonPracticeParams struct {
@ -187,6 +233,7 @@ type ExamPrepUpdateLessonPracticeParams struct {
PersonaID pgtype.Int8 `json:"persona_id"` PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID pgtype.Int8 `json:"question_set_id"` QuestionSetID pgtype.Int8 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"` QuickTips pgtype.Text `json:"quick_tips"`
PublishStatus pgtype.Text `json:"publish_status"`
ID int64 `json:"id"` ID int64 `json:"id"`
} }
@ -198,6 +245,7 @@ func (q *Queries) ExamPrepUpdateLessonPractice(ctx context.Context, arg ExamPrep
arg.PersonaID, arg.PersonaID,
arg.QuestionSetID, arg.QuestionSetID,
arg.QuickTips, arg.QuickTips,
arg.PublishStatus,
arg.ID, arg.ID,
) )
var i ExamPrepLessonPractice var i ExamPrepLessonPractice
@ -212,6 +260,7 @@ func (q *Queries) ExamPrepUpdateLessonPractice(ctx context.Context, arg ExamPrep
&i.QuickTips, &i.QuickTips,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.PublishStatus,
) )
return i, err return i, err
} }

View 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
}

View File

@ -18,12 +18,13 @@ SELECT
$2, $2,
$3, $3,
$4, $4,
coalesce(( COALESCE($5::int,
SELECT COALESCE((
max(c.sort_order) SELECT
FROM courses c max(c.sort_order)
WHERE FROM courses c
c.program_id = $1), 0) + 1 WHERE
c.program_id = $1), 0) + 1)
RETURNING RETURNING
id, program_id, name, description, thumbnail, created_at, updated_at, sort_order id, program_id, name, description, thumbnail, created_at, updated_at, sort_order
` `
@ -33,6 +34,7 @@ type CreateCourseParams struct {
Name string `json:"name"` Name string `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder pgtype.Int4 `json:"sort_order"`
} }
func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Course, error) { 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.Name,
arg.Description, arg.Description,
arg.Thumbnail, arg.Thumbnail,
arg.SortOrder,
) )
var i Course var i Course
err := row.Scan( err := row.Scan(
@ -75,6 +78,7 @@ SELECT
WHERE p.course_id = c.id WHERE p.course_id = c.id
AND p.module_id IS NULL AND p.module_id IS NULL
AND p.lesson_id IS NULL AND p.lesson_id IS NULL
AND p.publish_status = 'PUBLISHED'
) AS has_practice ) AS has_practice
FROM courses FROM courses
c c
@ -177,13 +181,15 @@ SELECT
WHERE WHERE
p.course_id = c.id p.course_id = c.id
AND p.module_id IS NULL 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 ( EXISTS (
SELECT 1 SELECT 1
FROM lms_practices p FROM lms_practices p
WHERE p.course_id = c.id WHERE p.course_id = c.id
AND p.module_id IS NULL AND p.module_id IS NULL
AND p.lesson_id IS NULL AND p.lesson_id IS NULL
AND p.publish_status = 'PUBLISHED'
) AS has_practice ) AS has_practice
FROM FROM
courses c courses c

View File

@ -77,6 +77,7 @@ SELECT
SELECT 1 SELECT 1
FROM lms_practices p FROM lms_practices p
WHERE p.lesson_id = l.id WHERE p.lesson_id = l.id
AND p.publish_status = 'PUBLISHED'
) AS has_practice ) AS has_practice
FROM lessons FROM lessons
l l
@ -130,6 +131,7 @@ SELECT
SELECT 1 SELECT 1
FROM lms_practices p FROM lms_practices p
WHERE p.lesson_id = l.id WHERE p.lesson_id = l.id
AND p.publish_status = 'PUBLISHED'
) AS has_practice ) AS has_practice
FROM FROM
lessons l lessons l

View File

@ -19,12 +19,13 @@ SELECT
$3, $3,
$4, $4,
$5, $5,
coalesce(( COALESCE($6::int,
SELECT COALESCE((
max(m.sort_order) SELECT
FROM modules m max(m.sort_order)
WHERE FROM modules m
m.course_id = $2), 0) + 1 WHERE
m.course_id = $2), 0) + 1)
RETURNING RETURNING
id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order 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"` Name string `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"` Icon pgtype.Text `json:"icon"`
SortOrder pgtype.Int4 `json:"sort_order"`
} }
func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Module, error) { 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.Name,
arg.Description, arg.Description,
arg.Icon, arg.Icon,
arg.SortOrder,
) )
var i Module var i Module
err := row.Scan( err := row.Scan(
@ -78,6 +81,7 @@ SELECT
FROM lms_practices p FROM lms_practices p
WHERE p.module_id = m.id WHERE p.module_id = m.id
AND p.lesson_id IS NULL AND p.lesson_id IS NULL
AND p.publish_status = 'PUBLISHED'
) AS has_practice ) AS has_practice
FROM modules FROM modules
m m
@ -163,6 +167,7 @@ SELECT
FROM lms_practices p FROM lms_practices p
WHERE p.module_id = m.id WHERE p.module_id = m.id
AND p.lesson_id IS NULL AND p.lesson_id IS NULL
AND p.publish_status = 'PUBLISHED'
) AS has_practice ) AS has_practice
FROM FROM
modules m modules m

View File

@ -14,9 +14,9 @@ import (
const CreateLmsPractice = `-- name: CreateLmsPractice :one const CreateLmsPractice = `-- name: CreateLmsPractice :one
INSERT INTO lms_practices ( INSERT INTO lms_practices (
course_id, module_id, lesson_id, course_id, module_id, lesson_id,
title, story_description, story_image, persona_id, question_set_id, quick_tips title, story_description, story_image, persona_id, question_set_id, quick_tips, publish_status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ) 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 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 { type CreateLmsPracticeParams struct {
@ -29,6 +29,7 @@ type CreateLmsPracticeParams struct {
PersonaID pgtype.Int8 `json:"persona_id"` PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"` QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"` QuickTips pgtype.Text `json:"quick_tips"`
PublishStatus string `json:"publish_status"`
} }
func (q *Queries) CreateLmsPractice(ctx context.Context, arg CreateLmsPracticeParams) (LmsPractice, error) { 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.PersonaID,
arg.QuestionSetID, arg.QuestionSetID,
arg.QuickTips, arg.QuickTips,
arg.PublishStatus,
) )
var i LmsPractice var i LmsPractice
err := row.Scan( err := row.Scan(
@ -57,6 +59,7 @@ func (q *Queries) CreateLmsPractice(ctx context.Context, arg CreateLmsPracticePa
&i.QuickTips, &i.QuickTips,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.PublishStatus,
) )
return i, err return i, err
} }
@ -72,7 +75,7 @@ func (q *Queries) DeleteLmsPractice(ctx context.Context, id int64) error {
} }
const GetLmsPracticeByID = `-- name: GetLmsPracticeByID :one 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 FROM lms_practices
WHERE id = $1 WHERE id = $1
` `
@ -93,6 +96,36 @@ func (q *Queries) GetLmsPracticeByID(ctx context.Context, id int64) (LmsPractice
&i.QuickTips, &i.QuickTips,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &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 return i, err
} }
@ -110,18 +143,24 @@ SELECT
p.persona_id, p.persona_id,
p.question_set_id, p.question_set_id,
p.quick_tips, p.quick_tips,
p.publish_status,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM lms_practices p FROM lms_practices p
WHERE p.course_id = $1 WHERE p.course_id = $1
AND (
$4::boolean = FALSE
OR p.publish_status = 'PUBLISHED'::TEXT
)
ORDER BY p.created_at DESC ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3 LIMIT $2 OFFSET $3
` `
type ListLmsPracticesByCourseIDParams struct { type ListLmsPracticesByCourseIDParams struct {
CourseID pgtype.Int8 `json:"course_id"` CourseID pgtype.Int8 `json:"course_id"`
Limit int32 `json:"limit"` Limit int32 `json:"limit"`
Offset int32 `json:"offset"` Offset int32 `json:"offset"`
PublishedOnly bool `json:"published_only"`
} }
type ListLmsPracticesByCourseIDRow struct { type ListLmsPracticesByCourseIDRow struct {
@ -136,12 +175,18 @@ type ListLmsPracticesByCourseIDRow struct {
PersonaID pgtype.Int8 `json:"persona_id"` PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"` QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"` QuickTips pgtype.Text `json:"quick_tips"`
PublishStatus string `json:"publish_status"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
func (q *Queries) ListLmsPracticesByCourseID(ctx context.Context, arg ListLmsPracticesByCourseIDParams) ([]ListLmsPracticesByCourseIDRow, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -161,6 +206,7 @@ func (q *Queries) ListLmsPracticesByCourseID(ctx context.Context, arg ListLmsPra
&i.PersonaID, &i.PersonaID,
&i.QuestionSetID, &i.QuestionSetID,
&i.QuickTips, &i.QuickTips,
&i.PublishStatus,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@ -187,18 +233,24 @@ SELECT
p.persona_id, p.persona_id,
p.question_set_id, p.question_set_id,
p.quick_tips, p.quick_tips,
p.publish_status,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM lms_practices p FROM lms_practices p
WHERE p.lesson_id = $1 WHERE p.lesson_id = $1
AND (
$4::boolean = FALSE
OR p.publish_status = 'PUBLISHED'::TEXT
)
ORDER BY p.created_at DESC ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3 LIMIT $2 OFFSET $3
` `
type ListLmsPracticesByLessonIDParams struct { type ListLmsPracticesByLessonIDParams struct {
LessonID pgtype.Int8 `json:"lesson_id"` LessonID pgtype.Int8 `json:"lesson_id"`
Limit int32 `json:"limit"` Limit int32 `json:"limit"`
Offset int32 `json:"offset"` Offset int32 `json:"offset"`
PublishedOnly bool `json:"published_only"`
} }
type ListLmsPracticesByLessonIDRow struct { type ListLmsPracticesByLessonIDRow struct {
@ -213,12 +265,18 @@ type ListLmsPracticesByLessonIDRow struct {
PersonaID pgtype.Int8 `json:"persona_id"` PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"` QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"` QuickTips pgtype.Text `json:"quick_tips"`
PublishStatus string `json:"publish_status"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
func (q *Queries) ListLmsPracticesByLessonID(ctx context.Context, arg ListLmsPracticesByLessonIDParams) ([]ListLmsPracticesByLessonIDRow, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -238,6 +296,7 @@ func (q *Queries) ListLmsPracticesByLessonID(ctx context.Context, arg ListLmsPra
&i.PersonaID, &i.PersonaID,
&i.QuestionSetID, &i.QuestionSetID,
&i.QuickTips, &i.QuickTips,
&i.PublishStatus,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@ -264,18 +323,24 @@ SELECT
p.persona_id, p.persona_id,
p.question_set_id, p.question_set_id,
p.quick_tips, p.quick_tips,
p.publish_status,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM lms_practices p FROM lms_practices p
WHERE p.module_id = $1 WHERE p.module_id = $1
AND (
$4::boolean = FALSE
OR p.publish_status = 'PUBLISHED'::TEXT
)
ORDER BY p.created_at DESC ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3 LIMIT $2 OFFSET $3
` `
type ListLmsPracticesByModuleIDParams struct { type ListLmsPracticesByModuleIDParams struct {
ModuleID pgtype.Int8 `json:"module_id"` ModuleID pgtype.Int8 `json:"module_id"`
Limit int32 `json:"limit"` Limit int32 `json:"limit"`
Offset int32 `json:"offset"` Offset int32 `json:"offset"`
PublishedOnly bool `json:"published_only"`
} }
type ListLmsPracticesByModuleIDRow struct { type ListLmsPracticesByModuleIDRow struct {
@ -290,12 +355,18 @@ type ListLmsPracticesByModuleIDRow struct {
PersonaID pgtype.Int8 `json:"persona_id"` PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"` QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"` QuickTips pgtype.Text `json:"quick_tips"`
PublishStatus string `json:"publish_status"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
func (q *Queries) ListLmsPracticesByModuleID(ctx context.Context, arg ListLmsPracticesByModuleIDParams) ([]ListLmsPracticesByModuleIDRow, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -315,6 +386,7 @@ func (q *Queries) ListLmsPracticesByModuleID(ctx context.Context, arg ListLmsPra
&i.PersonaID, &i.PersonaID,
&i.QuestionSetID, &i.QuestionSetID,
&i.QuickTips, &i.QuickTips,
&i.PublishStatus,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@ -337,9 +409,10 @@ SET
persona_id = COALESCE($4::bigint, persona_id), persona_id = COALESCE($4::bigint, persona_id),
question_set_id = COALESCE($5::bigint, question_set_id), question_set_id = COALESCE($5::bigint, question_set_id),
quick_tips = COALESCE($6::text, quick_tips), quick_tips = COALESCE($6::text, quick_tips),
publish_status = COALESCE($7::varchar, publish_status),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $7 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 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 { type UpdateLmsPracticeParams struct {
@ -349,6 +422,7 @@ type UpdateLmsPracticeParams struct {
PersonaID pgtype.Int8 `json:"persona_id"` PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID pgtype.Int8 `json:"question_set_id"` QuestionSetID pgtype.Int8 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"` QuickTips pgtype.Text `json:"quick_tips"`
PublishStatus pgtype.Text `json:"publish_status"`
ID int64 `json:"id"` ID int64 `json:"id"`
} }
@ -360,6 +434,7 @@ func (q *Queries) UpdateLmsPractice(ctx context.Context, arg UpdateLmsPracticePa
arg.PersonaID, arg.PersonaID,
arg.QuestionSetID, arg.QuestionSetID,
arg.QuickTips, arg.QuickTips,
arg.PublishStatus,
arg.ID, arg.ID,
) )
var i LmsPractice var i LmsPractice
@ -376,6 +451,7 @@ func (q *Queries) UpdateLmsPractice(ctx context.Context, arg UpdateLmsPracticePa
&i.QuickTips, &i.QuickTips,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.PublishStatus,
) )
return i, err return i, err
} }

View File

@ -106,6 +106,7 @@ WHERE
lp.course_id = $1 lp.course_id = $1
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED' AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
` `
func (q *Queries) CountPublishedPracticesInCourse(ctx context.Context, courseID pgtype.Int8) (int32, error) { func (q *Queries) CountPublishedPracticesInCourse(ctx context.Context, courseID pgtype.Int8) (int32, error) {
@ -125,6 +126,7 @@ WHERE
lp.module_id = $1 lp.module_id = $1
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED' AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
` `
// Published practices in a module (module-level and lesson-level practices should carry module_id). // Published practices in a module (module-level and lesson-level practices should carry module_id).
@ -146,6 +148,7 @@ WHERE
c.program_id = $1 c.program_id = $1
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED' AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
` `
func (q *Queries) CountPublishedPracticesInProgram(ctx context.Context, programID int64) (int32, error) { func (q *Queries) CountPublishedPracticesInProgram(ctx context.Context, programID int64) (int32, error) {
@ -313,6 +316,7 @@ WHERE
AND upp.completed_at IS NOT NULL AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED' AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
` `
type CountUserCompletedPublishedPracticesInModuleParams struct { type CountUserCompletedPublishedPracticesInModuleParams struct {
@ -341,6 +345,7 @@ WHERE
AND upp.completed_at IS NOT NULL AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE' AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED' AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
` `
type CountUserCompletedPublishedPracticesInProgramParams struct { type CountUserCompletedPublishedPracticesInProgramParams struct {

View File

@ -64,6 +64,7 @@ type ExamPrepLessonPractice struct {
QuickTips pgtype.Text `json:"quick_tips"` QuickTips pgtype.Text `json:"quick_tips"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
PublishStatus string `json:"publish_status"`
} }
type ExamPrepUnit struct { type ExamPrepUnit struct {
@ -149,6 +150,7 @@ type LmsPractice struct {
QuickTips pgtype.Text `json:"quick_tips"` QuickTips pgtype.Text `json:"quick_tips"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
PublishStatus string `json:"publish_status"`
} }
type LmsUserCourseProgress struct { type LmsUserCourseProgress struct {

View File

@ -17,10 +17,10 @@ SELECT
$1, $1,
$2, $2,
$3, $3,
coalesce(( COALESCE($4::int, COALESCE((
SELECT SELECT
max(p.sort_order) max(p.sort_order)
FROM programs AS p), 0) + 1 FROM programs AS p), 0) + 1)
RETURNING RETURNING
id, name, description, thumbnail, created_at, updated_at, sort_order id, name, description, thumbnail, created_at, updated_at, sort_order
` `
@ -29,10 +29,16 @@ type CreateProgramParams struct {
Name string `json:"name"` Name string `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder pgtype.Int4 `json:"sort_order"`
} }
func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (Program, error) { 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 var i Program
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,

View File

@ -365,6 +365,27 @@ func (q *Queries) GetExpiringSubscriptions(ctx context.Context) ([]GetExpiringSu
return items, nil 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 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 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 return items, nil
} }
const ListActiveSubscriptionsByUserIDs = `-- name: ListActiveSubscriptionsByUserIDs :many const ListSubscriptionDisplayStatusesByUserIDs = `-- name: ListSubscriptionDisplayStatusesByUserIDs :many
SELECT DISTINCT ON (us.user_id) WITH input AS (
us.user_id, SELECT unnest($1::bigint[])::bigint AS user_id
us.id, )
us.plan_id, SELECT
us.starts_at, input.user_id,
us.expires_at, COALESCE(
us.status, (SELECT us.status::text FROM user_subscriptions us
us.auto_renew, WHERE us.user_id = input.user_id
us.payment_method, AND us.status = 'ACTIVE' AND us.expires_at > CURRENT_TIMESTAMP
sp.name AS plan_name, ORDER BY us.expires_at DESC LIMIT 1),
sp.duration_value, (SELECT us.status::text FROM user_subscriptions us
sp.duration_unit, WHERE us.user_id = input.user_id
sp.price, AND us.status = 'PENDING'
sp.currency ORDER BY us.created_at DESC LIMIT 1),
FROM user_subscriptions us 'Unsubscribed'
JOIN subscription_plans sp ON sp.id = us.plan_id )::text AS subscription_status
WHERE us.user_id = ANY($1::bigint[]) FROM input
AND us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
ORDER BY us.user_id, us.expires_at DESC
` `
type ListActiveSubscriptionsByUserIDsRow struct { type ListSubscriptionDisplayStatusesByUserIDsRow struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
ID int64 `json:"id"` SubscriptionStatus string `json:"subscription_status"`
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"`
} }
// One ACTIVE, non-expired row per user (latest expires_at wins), same rules as GetActiveSubscriptionByUserID. // Display status for admin user lists: ACTIVE (non-expired), else latest PENDING, else Unsubscribed.
func (q *Queries) ListActiveSubscriptionsByUserIDs(ctx context.Context, dollar_1 []int64) ([]ListActiveSubscriptionsByUserIDsRow, error) { func (q *Queries) ListSubscriptionDisplayStatusesByUserIDs(ctx context.Context, dollar_1 []int64) ([]ListSubscriptionDisplayStatusesByUserIDsRow, error) {
rows, err := q.db.Query(ctx, ListActiveSubscriptionsByUserIDs, dollar_1) rows, err := q.db.Query(ctx, ListSubscriptionDisplayStatusesByUserIDs, dollar_1)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []ListActiveSubscriptionsByUserIDsRow var items []ListSubscriptionDisplayStatusesByUserIDsRow
for rows.Next() { for rows.Next() {
var i ListActiveSubscriptionsByUserIDsRow var i ListSubscriptionDisplayStatusesByUserIDsRow
if err := rows.Scan( if err := rows.Scan(&i.UserID, &i.SubscriptionStatus); err != nil {
&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 {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)

View File

@ -11,6 +11,60 @@ import (
"github.com/jackc/pgx/v5/pgtype" "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 const CheckTeamMemberEmailExists = `-- name: CheckTeamMemberEmailExists :one
SELECT EXISTS ( SELECT EXISTS (
SELECT 1 FROM team_members WHERE email = $1 SELECT 1 FROM team_members WHERE email = $1

View File

@ -11,6 +11,54 @@ import (
"github.com/jackc/pgx/v5/pgtype" "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 const CheckPhoneEmailExist = `-- name: CheckPhoneEmailExist :one
SELECT SELECT
EXISTS ( EXISTS (
@ -386,19 +434,62 @@ WHERE
)) ))
AND ($4::TIMESTAMPTZ IS NULL OR created_at >= $4::TIMESTAMPTZ) AND ($4::TIMESTAMPTZ IS NULL OR created_at >= $4::TIMESTAMPTZ)
AND ($5::TIMESTAMPTZ IS NULL OR created_at <= $5::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 ORDER BY created_at DESC
LIMIT $7::INT LIMIT $10::INT
OFFSET $6::INT OFFSET $9::INT
` `
type GetAllUsersParams struct { type GetAllUsersParams struct {
Role pgtype.Text `json:"role"` Role pgtype.Text `json:"role"`
Status pgtype.Text `json:"status"` Status pgtype.Text `json:"status"`
Query pgtype.Text `json:"query"` Query pgtype.Text `json:"query"`
CreatedAfter pgtype.Timestamptz `json:"created_after"` CreatedAfter pgtype.Timestamptz `json:"created_after"`
CreatedBefore pgtype.Timestamptz `json:"created_before"` CreatedBefore pgtype.Timestamptz `json:"created_before"`
Offset pgtype.Int4 `json:"offset"` Country pgtype.Text `json:"country"`
Limit pgtype.Int4 `json:"limit"` Region pgtype.Text `json:"region"`
SubscriptionStatus pgtype.Text `json:"subscription_status"`
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
} }
type GetAllUsersRow struct { type GetAllUsersRow struct {
@ -441,6 +532,9 @@ func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]Get
arg.Query, arg.Query,
arg.CreatedAfter, arg.CreatedAfter,
arg.CreatedBefore, arg.CreatedBefore,
arg.Country,
arg.Region,
arg.SubscriptionStatus,
arg.Offset, arg.Offset,
arg.Limit, arg.Limit,
) )
@ -773,6 +867,19 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
return i, err 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 const GetUserSummary = `-- name: GetUserSummary :one
SELECT SELECT
COUNT(*) AS total_users, COUNT(*) AS total_users,

View 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
}

View 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"`
}

View File

@ -25,10 +25,10 @@ type Course struct {
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
// Populated on list-by-program. Practice count: lms_practices rows with course_id = course only // Populated on list-by-program. Practice count: lms_practices rows with course_id = course only
// (not practices attached to a module or lesson under this course). // (not practices attached to a module or lesson under this course).
ModuleCount int `json:"module_count"` ModuleCount int `json:"module_count"`
LessonCount int `json:"lesson_count"` LessonCount int `json:"lesson_count"`
PracticeCount int `json:"practice_count"` PracticeCount int `json:"practice_count"`
HasPractice bool `json:"has_practice"` HasPractice bool `json:"has_practice"`
Access *LMSEntityAccess `json:"access,omitempty"` Access *LMSEntityAccess `json:"access,omitempty"`
} }
@ -36,6 +36,8 @@ type CreateCourseInput struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,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 { type UpdateCourseInput struct {

View File

@ -4,16 +4,22 @@ import "time"
// ExamPrepPractice is question-set content tied to an exam-prep lesson; uses shared question_sets / questions. // ExamPrepPractice is question-set content tied to an exam-prep lesson; uses shared question_sets / questions.
type ExamPrepPractice struct { type ExamPrepPractice struct {
ID int64 `json:"id"` ID int64 `json:"id"`
LessonID int64 `json:"lesson_id"` // exam_prep.unit_module_lessons.id LessonID int64 `json:"lesson_id"` // exam_prep.unit_module_lessons.id
Title string `json:"title"` Title string `json:"title"`
StoryDescription *string `json:"story_description,omitempty"` StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"` StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"` PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID int64 `json:"question_set_id"` QuestionSetID int64 `json:"question_set_id"`
QuickTips *string `json:"quick_tips,omitempty"` PublishStatus PracticePublishStatus `json:"publish_status"`
CreatedAt time.Time `json:"created_at"` QuickTips *string `json:"quick_tips,omitempty"`
UpdatedAt *time.Time `json:"updated_at,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). // CreateExamPrepPracticeInput is the body for POST .../exam-prep/lessons/{lessonId}/practices (lesson from path).
@ -24,6 +30,7 @@ type CreateExamPrepPracticeInput struct {
PersonaID *int64 `json:"persona_id,omitempty"` PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID int64 `json:"question_set_id" validate:"required,gt=0"` QuestionSetID int64 `json:"question_set_id" validate:"required,gt=0"`
QuickTips *string `json:"quick_tips,omitempty"` QuickTips *string `json:"quick_tips,omitempty"`
PublishStatus string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
} }
type UpdateExamPrepPracticeInput struct { type UpdateExamPrepPracticeInput struct {
@ -33,4 +40,5 @@ type UpdateExamPrepPracticeInput struct {
PersonaID *int64 `json:"persona_id,omitempty"` PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID *int64 `json:"question_set_id,omitempty"` QuestionSetID *int64 `json:"question_set_id,omitempty"`
QuickTips *string `json:"quick_tips,omitempty"` QuickTips *string `json:"quick_tips,omitempty"`
PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
} }

View 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"`
}

View File

@ -21,6 +21,8 @@ type CreateModuleInput struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Icon *string `json:"icon,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 { type UpdateModuleInput struct {

View File

@ -31,7 +31,7 @@ const (
NOTIFICATION_TYPE_ADMIN_CREATED NotificationType = "admin_created" NOTIFICATION_TYPE_ADMIN_CREATED NotificationType = "admin_created"
NOTIFICATION_TYPE_TEAM_MEMBER_CREATED NotificationType = "team_member_created" NOTIFICATION_TYPE_TEAM_MEMBER_CREATED NotificationType = "team_member_created"
NOTIFICATION_TYPE_USER_DELETED NotificationType = "user_deleted" NOTIFICATION_TYPE_USER_DELETED NotificationType = "user_deleted"
NOTIFICATION_TYPE_SYSTEM_ALERT NotificationType = "system_alert" NOTIFICATION_TYPE_SYSTEM_ALERT NotificationType = "system_alert"
NotificationRecieverSideAdmin NotificationRecieverSide = "admin" NotificationRecieverSideAdmin NotificationRecieverSide = "admin"
NotificationRecieverSideCustomer NotificationRecieverSide = "customer" NotificationRecieverSideCustomer NotificationRecieverSide = "customer"
@ -137,6 +137,8 @@ func ReceiverFromRole(role Role) NotificationRecieverSide {
return NotificationRecieverSideAdmin return NotificationRecieverSideAdmin
case RoleStudent: case RoleStudent:
return NotificationRecieverSideCustomer return NotificationRecieverSideCustomer
case RoleOpenLearner:
return NotificationRecieverSideCustomer
case RoleInstructor: case RoleInstructor:
return NotificationRecieverSideCustomer return NotificationRecieverSideCustomer
default: default:

View File

@ -1,6 +1,9 @@
package domain package domain
import "time" import (
"strings"
"time"
)
// ParentKind identifies which hierarchy entity owns a practice (exactly one). // ParentKind identifies which hierarchy entity owns a practice (exactly one).
type ParentKind string type ParentKind string
@ -11,19 +14,50 @@ const (
ParentKindLesson ParentKind = "LESSON" 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. // Practice is question-set content (story, persona, tips) scoped to a course, module, or lesson.
type Practice struct { type Practice struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ParentKind ParentKind `json:"parent_kind"` ParentKind ParentKind `json:"parent_kind"`
ParentID int64 `json:"parent_id"` ParentID int64 `json:"parent_id"`
Title string `json:"title"` Title string `json:"title"`
StoryDescription *string `json:"story_description,omitempty"` StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"` StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"` PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID int64 `json:"question_set_id"` QuestionSetID int64 `json:"question_set_id"`
QuickTips *string `json:"quick_tips,omitempty"` PublishStatus PracticePublishStatus `json:"publish_status"`
CreatedAt time.Time `json:"created_at"` QuickTips *string `json:"quick_tips,omitempty"`
UpdatedAt *time.Time `json:"updated_at,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 { type CreatePracticeInput struct {
@ -35,6 +69,8 @@ type CreatePracticeInput struct {
PersonaID *int64 `json:"persona_id,omitempty"` PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID int64 `json:"question_set_id" validate:"required,gt=0"` QuestionSetID int64 `json:"question_set_id" validate:"required,gt=0"`
QuickTips *string `json:"quick_tips,omitempty"` 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 { type UpdatePracticeInput struct {
@ -44,4 +80,5 @@ type UpdatePracticeInput struct {
PersonaID *int64 `json:"persona_id,omitempty"` PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID *int64 `json:"question_set_id,omitempty"` QuestionSetID *int64 `json:"question_set_id,omitempty"`
QuickTips *string `json:"quick_tips,omitempty"` QuickTips *string `json:"quick_tips,omitempty"`
PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
} }

View File

@ -4,13 +4,13 @@ import "time"
// Program is the top-level container in the LMS hierarchy (e.g. tracks like Beginner / Intermediate / Advanced). // Program is the top-level container in the LMS hierarchy (e.g. tracks like Beginner / Intermediate / Advanced).
type Program struct { type Program struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"` Access *LMSEntityAccess `json:"access,omitempty"`
} }
@ -18,6 +18,8 @@ type CreateProgramInput struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,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 { type UpdateProgramInput struct {

View File

@ -6,19 +6,31 @@ const (
RoleSuperAdmin Role = "SUPER_ADMIN" RoleSuperAdmin Role = "SUPER_ADMIN"
RoleAdmin Role = "ADMIN" RoleAdmin Role = "ADMIN"
RoleStudent Role = "STUDENT" RoleStudent Role = "STUDENT"
RoleInstructor Role = "INSTRUCTOR" // RoleOpenLearner can consume LMS content like a learner but without sequential prerequisite locking (step-by-step gates).
RoleSupport Role = "SUPPORT" RoleOpenLearner Role = "OPEN_LEARNER"
RoleInstructor Role = "INSTRUCTOR"
RoleSupport Role = "SUPPORT"
) )
func (r Role) IsValid() bool { func (r Role) IsValid() bool {
switch r { switch r {
case RoleSuperAdmin, RoleAdmin, RoleStudent, RoleInstructor, RoleSupport: case RoleSuperAdmin, RoleAdmin, RoleStudent, RoleOpenLearner, RoleInstructor, RoleSupport:
return true return true
default: default:
return false 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 { func (r Role) Value() string {
return string(r) return string(r)
} }

View File

@ -56,54 +56,6 @@ type UserSubscription struct {
Currency *string 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 { type CreateSubscriptionPlanInput struct {
Name string Name string
Description *string Description *string

View File

@ -121,12 +121,15 @@ type UserProfileResponse struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
ActiveSubscription *UserSubscriptionSummary `json:"active_subscription,omitempty"` SubscriptionStatus string `json:"subscription_status"`
} }
type UserFilter struct { type UserFilter struct {
Role string Role string
Status string Status string
Country string
Region string
SubscriptionStatus string // display filter: ACTIVE, PENDING, Unsubscribed (same as API subscription_status values)
Page int64 Page int64
PageSize int64 PageSize int64

View 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
}

View File

@ -9,7 +9,8 @@ import (
type ExamPrepPracticeStore interface { type ExamPrepPracticeStore interface {
CreateExamPrepLessonPractice(ctx context.Context, lessonID int64, in domain.CreateExamPrepPracticeInput) (domain.ExamPrepPractice, error) CreateExamPrepLessonPractice(ctx context.Context, lessonID int64, in domain.CreateExamPrepPracticeInput) (domain.ExamPrepPractice, error)
GetExamPrepLessonPracticeByID(ctx context.Context, id int64) (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) UpdateExamPrepLessonPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error)
DeleteExamPrepLessonPractice(ctx context.Context, id int64) error DeleteExamPrepLessonPractice(ctx context.Context, id int64) error
} }

View File

@ -23,9 +23,11 @@ type LmsPracticeStore interface {
courseID, moduleID, lessonID *int64, courseID, moduleID, lessonID *int64,
) (domain.Practice, error) ) (domain.Practice, error)
GetLmsPracticeByID(ctx context.Context, id 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) // TryGetLmsPracticeByQuestionSetID returns false when no LMS practice row references the question set.
ListLmsPracticesByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Practice, int64, error) TryGetLmsPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (domain.Practice, bool, error)
ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.Practice, int64, 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) UpdateLmsPractice(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error)
DeleteLmsPractice(ctx context.Context, id int64) error DeleteLmsPractice(ctx context.Context, id int64) error
} }

View File

@ -18,7 +18,8 @@ type SubscriptionStore interface {
CreateUserSubscription(ctx context.Context, input domain.CreateUserSubscriptionInput) (*domain.UserSubscription, error) CreateUserSubscription(ctx context.Context, input domain.CreateUserSubscriptionInput) (*domain.UserSubscription, error)
GetUserSubscriptionByID(ctx context.Context, id int64) (*domain.UserSubscription, error) GetUserSubscriptionByID(ctx context.Context, id int64) (*domain.UserSubscription, error)
GetActiveSubscriptionByUserID(ctx context.Context, userID 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) GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error)
HasActiveSubscription(ctx context.Context, userID int64) (bool, error) HasActiveSubscription(ctx context.Context, userID int64) (bool, error)
CancelUserSubscription(ctx context.Context, id int64) error CancelUserSubscription(ctx context.Context, id int64) error

View File

@ -36,4 +36,6 @@ type TeamStore interface {
CreateTeamRefreshToken(ctx context.Context, memberID int64, token string, expiresAt, createdAt time.Time) error CreateTeamRefreshToken(ctx context.Context, memberID int64, token string, expiresAt, createdAt time.Time) error
GetTeamRefreshTokenByToken(ctx context.Context, token string) (domain.TeamRefreshToken, error) GetTeamRefreshTokenByToken(ctx context.Context, token string) (domain.TeamRefreshToken, error)
RevokeTeamRefreshTokenByToken(ctx context.Context, token string) 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)
} }

View File

@ -55,6 +55,9 @@ type UserStore interface {
status *string, status *string,
query *string, query *string,
createdBefore, createdAfter *time.Time, createdBefore, createdAfter *time.Time,
country *string,
region *string,
subscriptionStatus *string,
limit, offset int32, limit, offset int32,
) ([]domain.User, int64, error) ) ([]domain.User, int64, error)
ListAccountDeletionRequests(ctx context.Context, filter domain.AccountDeletionRequestFilter) ([]domain.AccountDeletionRequest, 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) GetUserDeviceTokens(ctx context.Context, userID int64) ([]string, error)
DeactivateDevice(ctx context.Context, userID int64, deviceToken string) error DeactivateDevice(ctx context.Context, userID int64, deviceToken string) error
DeactivateAllUserDevices(ctx context.Context, userID int64) 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 { type SmsGateway interface {
SendSMSOTP(ctx context.Context, phoneNumber, otp string) error SendSMSOTP(ctx context.Context, phoneNumber, otp string) error

View File

@ -21,6 +21,7 @@ func examPrepPracticeFromListRow(r dbgen.ExamPrepListLessonPracticesByLessonIDRo
PersonaID: r.PersonaID, PersonaID: r.PersonaID,
QuestionSetID: r.QuestionSetID, QuestionSetID: r.QuestionSetID,
QuickTips: r.QuickTips, QuickTips: r.QuickTips,
PublishStatus: r.PublishStatus,
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt, UpdatedAt: r.UpdatedAt,
}) })
@ -32,6 +33,7 @@ func examPrepPracticeToDomain(p dbgen.ExamPrepLessonPractice) domain.ExamPrepPra
LessonID: p.UnitModuleLessonID, LessonID: p.UnitModuleLessonID,
Title: p.Title, Title: p.Title,
QuestionSetID: p.QuestionSetID, QuestionSetID: p.QuestionSetID,
PublishStatus: domain.PracticePublishStatusFromDB(p.PublishStatus),
} }
out.StoryDescription = fromPgText(p.StoryDescription) out.StoryDescription = fromPgText(p.StoryDescription)
out.StoryImage = fromPgText(p.StoryImage) 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) { 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{ p, err := s.queries.ExamPrepCreateLessonPractice(ctx, dbgen.ExamPrepCreateLessonPracticeParams{
UnitModuleLessonID: lessonID, UnitModuleLessonID: lessonID,
Title: in.Title, Title: in.Title,
@ -54,6 +57,7 @@ func (s *Store) CreateExamPrepLessonPractice(ctx context.Context, lessonID int64
PersonaID: int64PtrToPg8(in.PersonaID), PersonaID: int64PtrToPg8(in.PersonaID),
QuestionSetID: in.QuestionSetID, QuestionSetID: in.QuestionSetID,
QuickTips: toPgText(in.QuickTips), QuickTips: toPgText(in.QuickTips),
PublishStatus: string(ps),
}) })
if err != nil { if err != nil {
return domain.ExamPrepPractice{}, err return domain.ExamPrepPractice{}, err
@ -72,9 +76,22 @@ func (s *Store) GetExamPrepLessonPracticeByID(ctx context.Context, id int64) (do
return examPrepPracticeToDomain(p), nil 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{ rows, err := s.queries.ExamPrepListLessonPracticesByLessonID(ctx, dbgen.ExamPrepListLessonPracticesByLessonIDParams{
UnitModuleLessonID: lessonID, UnitModuleLessonID: lessonID,
PublishedOnly: publishedOnly,
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
}) })
@ -111,6 +128,7 @@ func (s *Store) UpdateExamPrepLessonPractice(ctx context.Context, id int64, inpu
PersonaID: optionalInt8UpdateID(input.PersonaID), PersonaID: optionalInt8UpdateID(input.PersonaID),
QuestionSetID: qs, QuestionSetID: qs,
QuickTips: optionalTextUpdate(input.QuickTips), QuickTips: optionalTextUpdate(input.QuickTips),
PublishStatus: optionalPublishStatusUpdate(input.PublishStatus),
}) })
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {

View File

@ -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) { 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{ c, err := s.queries.CreateCourse(ctx, dbgen.CreateCourseParams{
ProgramID: programID, ProgramID: programID,
Name: input.Name, Name: input.Name,
Description: toPgText(input.Description), Description: toPgText(input.Description),
Thumbnail: toPgText(input.Thumbnail), Thumbnail: toPgText(input.Thumbnail),
SortOrder: pgtype.Int4{Valid: false},
}) })
if err != nil { if err != nil {
return domain.Course{}, err 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) { func (s *Store) UpdateCourse(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error) {
sortParam := optionalInt4Update(input.SortOrder)
var nameText pgtype.Text var nameText pgtype.Text
if input.Name != nil { if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true} nameText = pgtype.Text{String: *input.Name, Valid: true}
} else { } else {
nameText = pgtype.Text{Valid: false} 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{ c, err := s.queries.UpdateCourse(ctx, dbgen.UpdateCourseParams{
ID: id, ID: id,
Name: nameText, Name: nameText,
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
Thumbnail: optionalTextUpdate(input.Thumbnail), Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: optionalInt4Update(input.SortOrder), SortOrder: sortParam,
}) })
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {

View File

@ -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) { func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) {
sortParam := optionalInt4Update(input.SortOrder)
var titleText pgtype.Text var titleText pgtype.Text
if input.Title != nil { if input.Title != nil {
titleText = pgtype.Text{String: *input.Title, Valid: true} titleText = pgtype.Text{String: *input.Title, Valid: true}
} else { } else {
titleText = pgtype.Text{Valid: false} 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{ l, err := s.queries.UpdateLesson(ctx, dbgen.UpdateLessonParams{
ID: id, ID: id,
Title: titleText, Title: titleText,
VideoUrl: optionalTextUpdate(input.VideoURL), VideoUrl: optionalTextUpdate(input.VideoURL),
Thumbnail: optionalTextUpdate(input.Thumbnail), Thumbnail: optionalTextUpdate(input.Thumbnail),
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
SortOrder: optionalInt4Update(input.SortOrder), SortOrder: sortParam,
}) })
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {

View File

@ -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) { 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{ m, err := s.queries.CreateModule(ctx, dbgen.CreateModuleParams{
ProgramID: programID, ProgramID: programID,
CourseID: courseID, CourseID: courseID,
Name: input.Name, Name: input.Name,
Description: toPgText(input.Description), Description: toPgText(input.Description),
Icon: toPgText(input.Icon), Icon: toPgText(input.Icon),
SortOrder: pgtype.Int4{Valid: false},
}) })
if err != nil { if err != nil {
return domain.Module{}, err 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) { 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) q, tx, err := s.BeginTx(ctx)
if err != nil { if err != nil {
return domain.Module{}, err return domain.Module{}, err
} }
defer tx.Rollback(ctx) defer func() { _ = 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(
&current.ID,
&current.ProgramID,
&current.CourseID,
&current.Name,
&current.Description,
&current.Icon,
&current.SortOrder,
&current.CreatedAt,
&current.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Module{}, pgx.ErrNoRows
}
return domain.Module{}, err
}
sortParam := optionalInt4Update(input.SortOrder)
if input.SortOrder != nil { if input.SortOrder != nil {
targetSort := int32(*input.SortOrder) oldPos := int32(cur.SortOrder)
if targetSort != current.SortOrder { newPos := int32(*input.SortOrder)
var conflictID int64 if oldPos != newPos {
err = tx.QueryRow(ctx, ` if err := repositionModuleSortOrder(ctx, tx, cur.CourseID, id, oldPos, newPos); err != nil {
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 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 {
return domain.Module{}, err
}
}
} }
sortParam = pgtype.Int4{Valid: false}
} }
var nameText pgtype.Text var nameText pgtype.Text
@ -183,7 +172,7 @@ WHERE course_id = $1
Name: nameText, Name: nameText,
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
Icon: optionalTextUpdate(input.Icon), Icon: optionalTextUpdate(input.Icon),
SortOrder: optionalInt4Update(input.SortOrder), SortOrder: sortParam,
}) })
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@ -195,7 +184,9 @@ WHERE course_id = $1
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
return domain.Module{}, err 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 { func (s *Store) DeleteModule(ctx context.Context, id int64) error {

View File

@ -26,11 +26,19 @@ func fromPgInt8ID(c pgtype.Int8) *int64 {
return &v 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 { func lmsPracticeToDomain(p dbgen.LmsPractice) domain.Practice {
out := domain.Practice{ out := domain.Practice{
ID: p.ID, ID: p.ID,
Title: p.Title, Title: p.Title,
QuestionSetID: p.QuestionSetID, QuestionSetID: p.QuestionSetID,
PublishStatus: domain.PracticePublishStatusFromDB(p.PublishStatus),
} }
if p.CourseID.Valid { if p.CourseID.Valid {
out.ParentKind = domain.ParentKindCourse out.ParentKind = domain.ParentKindCourse
@ -55,7 +63,9 @@ func lmsPracticeToDomain(p dbgen.LmsPractice) domain.Practice {
} }
func lmsFromListRow( func lmsFromListRow(
id, qid int64, title string, id, qid int64,
publishStatus string,
title string,
cid, mid, lid pgtype.Int8, cid, mid, lid pgtype.Int8,
sd, si, qt pgtype.Text, pid pgtype.Int8, sd, si, qt pgtype.Text, pid pgtype.Int8,
ca, ua pgtype.Timestamptz, ca, ua pgtype.Timestamptz,
@ -71,6 +81,7 @@ func lmsFromListRow(
PersonaID: pid, PersonaID: pid,
QuestionSetID: qid, QuestionSetID: qid,
QuickTips: qt, QuickTips: qt,
PublishStatus: publishStatus,
CreatedAt: ca, CreatedAt: ca,
UpdatedAt: ua, UpdatedAt: ua,
}) })
@ -82,6 +93,7 @@ func (s *Store) CreateLmsPractice(
in domain.CreatePracticeInput, in domain.CreatePracticeInput,
courseID, moduleID, lessonID *int64, courseID, moduleID, lessonID *int64,
) (domain.Practice, error) { ) (domain.Practice, error) {
ps := domain.ParsePracticePublishStatusInput(in.PublishStatus)
p, err := s.queries.CreateLmsPractice(ctx, dbgen.CreateLmsPracticeParams{ p, err := s.queries.CreateLmsPractice(ctx, dbgen.CreateLmsPracticeParams{
CourseID: int64PtrToPg8(courseID), CourseID: int64PtrToPg8(courseID),
ModuleID: int64PtrToPg8(moduleID), ModuleID: int64PtrToPg8(moduleID),
@ -92,6 +104,7 @@ func (s *Store) CreateLmsPractice(
PersonaID: int64PtrToPg8(in.PersonaID), PersonaID: int64PtrToPg8(in.PersonaID),
QuestionSetID: in.QuestionSetID, QuestionSetID: in.QuestionSetID,
QuickTips: toPgText(in.QuickTips), QuickTips: toPgText(in.QuickTips),
PublishStatus: string(ps),
}) })
if err != nil { if err != nil {
return domain.Practice{}, err return domain.Practice{}, err
@ -110,11 +123,24 @@ func (s *Store) GetLmsPracticeByID(ctx context.Context, id int64) (domain.Practi
return lmsPracticeToDomain(p), nil 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{ rows, err := s.queries.ListLmsPracticesByCourseID(ctx, dbgen.ListLmsPracticesByCourseIDParams{
CourseID: pgtype.Int8{Int64: courseID, Valid: true}, CourseID: pgtype.Int8{Int64: courseID, Valid: true},
Limit: limit, PublishedOnly: publishedOnly,
Offset: offset, Limit: limit,
Offset: offset,
}) })
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -129,7 +155,7 @@ func (s *Store) ListLmsPracticesByCourseID(ctx context.Context, courseID int64,
total = r.TotalCount total = r.TotalCount
} }
out = append(out, lmsFromListRow( 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.CourseID, r.ModuleID, r.LessonID,
r.StoryDescription, r.StoryImage, r.QuickTips, r.StoryDescription, r.StoryImage, r.QuickTips,
r.PersonaID, r.CreatedAt, r.UpdatedAt, r.PersonaID, r.CreatedAt, r.UpdatedAt,
@ -138,11 +164,12 @@ func (s *Store) ListLmsPracticesByCourseID(ctx context.Context, courseID int64,
return out, total, nil 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{ rows, err := s.queries.ListLmsPracticesByModuleID(ctx, dbgen.ListLmsPracticesByModuleIDParams{
ModuleID: pgtype.Int8{Int64: moduleID, Valid: true}, ModuleID: pgtype.Int8{Int64: moduleID, Valid: true},
Limit: limit, PublishedOnly: publishedOnly,
Offset: offset, Limit: limit,
Offset: offset,
}) })
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -157,7 +184,7 @@ func (s *Store) ListLmsPracticesByModuleID(ctx context.Context, moduleID int64,
total = r.TotalCount total = r.TotalCount
} }
out = append(out, lmsFromListRow( 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.CourseID, r.ModuleID, r.LessonID,
r.StoryDescription, r.StoryImage, r.QuickTips, r.StoryDescription, r.StoryImage, r.QuickTips,
r.PersonaID, r.CreatedAt, r.UpdatedAt, r.PersonaID, r.CreatedAt, r.UpdatedAt,
@ -166,11 +193,12 @@ func (s *Store) ListLmsPracticesByModuleID(ctx context.Context, moduleID int64,
return out, total, nil 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{ rows, err := s.queries.ListLmsPracticesByLessonID(ctx, dbgen.ListLmsPracticesByLessonIDParams{
LessonID: pgtype.Int8{Int64: lessonID, Valid: true}, LessonID: pgtype.Int8{Int64: lessonID, Valid: true},
Limit: limit, PublishedOnly: publishedOnly,
Offset: offset, Limit: limit,
Offset: offset,
}) })
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -185,7 +213,7 @@ func (s *Store) ListLmsPracticesByLessonID(ctx context.Context, lessonID int64,
total = r.TotalCount total = r.TotalCount
} }
out = append(out, lmsFromListRow( 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.CourseID, r.ModuleID, r.LessonID,
r.StoryDescription, r.StoryImage, r.QuickTips, r.StoryDescription, r.StoryImage, r.QuickTips,
r.PersonaID, r.CreatedAt, r.UpdatedAt, r.PersonaID, r.CreatedAt, r.UpdatedAt,
@ -194,13 +222,6 @@ func (s *Store) ListLmsPracticesByLessonID(ctx context.Context, lessonID int64,
return out, total, nil 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) { func (s *Store) UpdateLmsPractice(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error) {
var titleText pgtype.Text var titleText pgtype.Text
if input.Title != nil { if input.Title != nil {
@ -217,6 +238,7 @@ func (s *Store) UpdateLmsPractice(ctx context.Context, id int64, input domain.Up
PersonaID: optionalInt8UpdateID(input.PersonaID), PersonaID: optionalInt8UpdateID(input.PersonaID),
QuestionSetID: qs, QuestionSetID: qs,
QuickTips: optionalTextUpdate(input.QuickTips), QuickTips: optionalTextUpdate(input.QuickTips),
PublishStatus: optionalPublishStatusUpdate(input.PublishStatus),
}) })
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {

View 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
}

View File

@ -3,6 +3,7 @@ package repository
import ( import (
"context" "context"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
) )
@ -31,3 +32,8 @@ func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (d
ProgramIDs: programs, ProgramIDs: programs,
}, nil }, 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)
}

View File

@ -3,6 +3,7 @@ package repository
import ( import (
"context" "context"
"errors" "errors"
"strings"
dbgen "Yimaru-Backend/gen/db" dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain" "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) { 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{ p, err := s.queries.CreateProgram(ctx, dbgen.CreateProgramParams{
Name: input.Name, Name: input.Name,
Description: toPgText(input.Description), Description: toPgText(input.Description),
Thumbnail: toPgText(input.Thumbnail), Thumbnail: toPgText(input.Thumbnail),
SortOrder: pgtype.Int4{Valid: false},
}) })
if err != nil { if err != nil {
return domain.Program{}, err return domain.Program{}, err
@ -91,6 +118,19 @@ func optionalTextUpdate(val *string) pgtype.Text {
return pgtype.Text{String: *val, Valid: true} 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 { func optionalInt4Update(v *int) pgtype.Int4 {
if v == nil { if v == nil {
return pgtype.Int4{Valid: false} 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) { 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 var nameText pgtype.Text
if input.Name != nil { if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true} 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, Name: nameText,
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
Thumbnail: optionalTextUpdate(input.Thumbnail), Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: optionalInt4Update(input.SortOrder), SortOrder: sortParam,
}) })
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {

View File

@ -157,39 +157,25 @@ func (s *Store) GetActiveSubscriptionByUserID(ctx context.Context, userID int64)
}, nil }, 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 { 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 { if err != nil {
return nil, err return nil, err
} }
out := make(map[int64]*domain.UserSubscription, len(rows)) out := make(map[int64]string, len(rows))
for _, r := range rows { for _, r := range rows {
dv := r.DurationValue out[r.UserID] = r.SubscriptionStatus
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,
}
} }
return out, nil 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) { func (s *Store) GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) {
subs, err := s.queries.GetUserSubscriptionHistory(ctx, dbgen.GetUserSubscriptionHistoryParams{ subs, err := s.queries.GetUserSubscriptionHistory(ctx, dbgen.GetUserSubscriptionHistoryParams{
UserID: userID, UserID: userID,

View File

@ -219,6 +219,28 @@ func (s *Store) DeleteTeamMember(ctx context.Context, memberID int64) error {
return s.queries.DeleteTeamMember(ctx, memberID) 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) { func (s *Store) CheckTeamMemberEmailExists(ctx context.Context, email string) (bool, error) {
return s.queries.CheckTeamMemberEmailExists(ctx, email) return s.queries.CheckTeamMemberEmailExists(ctx, email)
} }

View File

@ -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( func (s *Store) CreateUserWithoutOtp(
ctx context.Context, ctx context.Context,
user domain.User, user domain.User,
@ -347,6 +361,18 @@ func (s *Store) GetUserByID(
}, nil }, 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( func (s *Store) GetUserByGoogleID(
ctx context.Context, ctx context.Context,
googleId string, googleId string,
@ -414,6 +440,9 @@ func (s *Store) GetAllUsers(
status *string, status *string,
query *string, query *string,
createdBefore, createdAfter *time.Time, createdBefore, createdAfter *time.Time,
country *string,
region *string,
subscriptionStatus *string,
limit, offset int32, limit, offset int32,
) ([]domain.User, int64, error) { ) ([]domain.User, int64, error) {
@ -442,12 +471,30 @@ func (s *Store) GetAllUsers(
createdBeforeParam = pgtype.Timestamptz{Time: *createdBefore, Valid: true} 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{ params := dbgen.GetAllUsersParams{
Role: roleParam, Role: roleParam,
Status: statusParam, Status: statusParam,
Query: queryParam, Query: queryParam,
CreatedAfter: createdAfterParam, CreatedAfter: createdAfterParam,
CreatedBefore: createdBeforeParam, CreatedBefore: createdBeforeParam,
Country: countryParam,
Region: regionParam,
SubscriptionStatus: subscriptionStatusParam,
Limit: pgtype.Int4{ Limit: pgtype.Int4{
Int32: limit, Int32: limit,
Valid: true, Valid: true,

View 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)
}

View File

@ -358,7 +358,7 @@ func (s *Service) CreateExamPrepPractice(ctx context.Context, lessonID int64, in
return s.store.CreateExamPrepLessonPractice(ctx, lessonID, input) 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 { if err := s.ensureLesson(ctx, lessonID); err != nil {
return nil, 0, err return nil, 0, err
} }
@ -371,7 +371,7 @@ func (s *Service) ListExamPrepPracticesByLesson(ctx context.Context, lessonID in
if offset < 0 { if offset < 0 {
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) { 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 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) { func (s *Service) UpdateExamPrepPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error) {
p, err := s.store.UpdateExamPrepLessonPractice(ctx, id, input) p, err := s.store.UpdateExamPrepLessonPractice(ctx, id, input)
if err != nil { if err != nil {

View 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
}

View 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
}

View File

@ -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. // 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 { 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 p.Access = nil
return 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. // ApplyAccessCourse sets c.Access for a learner.
func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userID int64, c *domain.Course) error { 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 c.Access = nil
return 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. // ApplyAccessModule sets m.Access for a learner.
func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userID int64, m *domain.Module) error { 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 m.Access = nil
return 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. // ApplyAccessLesson sets l.Access for a learner.
func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userID int64, les *domain.Lesson) error { 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 les.Access = nil
return nil return nil
} }

View File

@ -115,6 +115,10 @@ func (s *Service) Create(ctx context.Context, in domain.CreatePracticeInput) (do
return s.practices.CreateLmsPractice(ctx, in, courseID, moduleID, lessonID) 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) { func (s *Service) GetByID(ctx context.Context, id int64) (domain.Practice, error) {
p, err := s.practices.GetLmsPracticeByID(ctx, id) p, err := s.practices.GetLmsPracticeByID(ctx, id)
if err != nil { if err != nil {
@ -139,7 +143,7 @@ func clampPracticePage(limit, offset int32) (int32, int32) {
return limit, offset 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 _, err := s.courses.GetCourseByID(ctx, courseID); err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, 0, courses.ErrCourseNotFound return nil, 0, courses.ErrCourseNotFound
@ -147,10 +151,10 @@ func (s *Service) ListByCourse(ctx context.Context, courseID int64, limit, offse
return nil, 0, err return nil, 0, err
} }
limit, offset = clampPracticePage(limit, offset) 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 _, err := s.modules.GetModuleByID(ctx, moduleID); err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, 0, modules.ErrModuleNotFound return nil, 0, modules.ErrModuleNotFound
@ -158,10 +162,10 @@ func (s *Service) ListByModule(ctx context.Context, moduleID int64, limit, offse
return nil, 0, err return nil, 0, err
} }
limit, offset = clampPracticePage(limit, offset) 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 _, err := s.lessons.GetLessonByID(ctx, lessonID); err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, 0, lessons.ErrLessonNotFound return nil, 0, lessons.ErrLessonNotFound
@ -169,7 +173,7 @@ func (s *Service) ListByLesson(ctx context.Context, lessonID int64, limit, offse
return nil, 0, err return nil, 0, err
} }
limit, offset = clampPracticePage(limit, offset) 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) { func (s *Service) Update(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error) {

View File

@ -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"}, {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 // DefaultRolePermissions maps each system role to the permission keys it should
// have by default. This preserves the previous middleware behavior: // have by default. This preserves the previous middleware behavior:
// - ADMIN: everything that was previously OnlyAdminAndAbove + SuperAdminOnly + all authenticated routes // - 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{ var DefaultRolePermissions = map[string][]string{
"ADMIN": { "ADMIN": {
// Course Management (full access) // Course Management (full access)
@ -409,64 +470,9 @@ var DefaultRolePermissions = map[string][]string{
"internal.db.reset_reseed", "internal.db.reset_reseed",
}, },
"STUDENT": { "STUDENT": defaultStudentLearnerPermissions,
// 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", "OPEN_LEARNER": defaultStudentLearnerPermissions,
"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",
},
"INSTRUCTOR": { "INSTRUCTOR": {
// Course browsing + management // Course browsing + management

View File

@ -103,13 +103,19 @@ func (s *Service) GetSubscriptionByID(ctx context.Context, id int64) (*domain.Us
return sub, nil 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) { func (s *Service) GetActiveSubscription(ctx context.Context, userID int64) (*domain.UserSubscription, error) {
return s.store.GetActiveSubscriptionByUserID(ctx, userID) return s.store.GetActiveSubscriptionByUserID(ctx, userID)
} }
// ListActiveSubscriptionsForUserIDs returns the current ACTIVE, non-expired subscription per user (latest expiry). // ListSubscriptionDisplayStatusesForUserIDs returns ACTIVE, PENDING, or Unsubscribed per user_id (admin list).
func (s *Service) ListActiveSubscriptionsForUserIDs(ctx context.Context, userIDs []int64) (map[int64]*domain.UserSubscription, error) { func (s *Service) ListSubscriptionDisplayStatusesForUserIDs(ctx context.Context, userIDs []int64) (map[int64]string, error) {
return s.store.ListActiveSubscriptionsByUserIDs(ctx, userIDs) 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) { func (s *Service) GetSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) {

View File

@ -119,6 +119,14 @@ func (s *Service) GetTeamMemberStats(ctx context.Context) (domain.TeamMemberStat
return s.teamStore.CountTeamMembersByStatus(ctx) 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) { func (s *Service) Login(ctx context.Context, req domain.TeamMemberLoginReq) (domain.TeamMember, error) {
member, err := s.teamStore.GetTeamMemberByEmail(ctx, req.Email) member, err := s.teamStore.GetTeamMemberByEmail(ctx, req.Email)
if err != nil { if err != nil {

View File

@ -89,6 +89,21 @@ func (s *Service) GetAllUsers(
query = &filter.Query 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) offset := int32(filter.Page * filter.PageSize)
return s.userStore.GetAllUsers( return s.userStore.GetAllUsers(
@ -98,6 +113,9 @@ func (s *Service) GetAllUsers(
query, query,
before, before,
after, after,
country,
region,
subscriptionStatus,
int32(filter.PageSize), int32(filter.PageSize),
offset, offset,
) )
@ -115,6 +133,14 @@ func (s *Service) UpdateUserStatus(ctx context.Context, req domain.UpdateUserSta
return s.userStore.UpdateUserStatus(ctx, req) 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) { func (s *Service) GetUserById(ctx context.Context, id int64) (domain.User, error) {
return s.userStore.GetUserByID(ctx, id) return s.userStore.GetUserByID(ctx, id)

View File

@ -6,11 +6,14 @@ import (
"Yimaru-Backend/internal/web_server/response" "Yimaru-Backend/internal/web_server/response"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"go.uber.org/zap" "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) 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,
})
}

View File

@ -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", h.mongoLoggerSvc.Warn("Login attempt: admin login of user",
zap.Int("status_code", fiber.StatusForbidden), zap.Int("status_code", fiber.StatusForbidden),
zap.String("role", string(successRes.Role)), zap.String("role", string(successRes.Role)),

View File

@ -13,7 +13,7 @@ import (
// CreateCourse godoc // CreateCourse godoc
// @Summary Create course // @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 // @Tags courses
// @Accept json // @Accept json
// @Produce json // @Produce json

View File

@ -74,7 +74,8 @@ func (h *Handler) ListExamPrepPracticesByLesson(c *fiber.Ctx) error {
} }
limit, _ := strconv.Atoi(c.Query("limit", "20")) limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0")) 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 err != nil {
if errors.Is(err, examprep.ErrLessonNotFound) { if errors.Is(err, examprep.ErrLessonNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
@ -126,6 +127,9 @@ func (h *Handler) GetExamPrepPracticeByID(c *fiber.Ctx) error {
Error: err.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{ return c.JSON(domain.Response{
Message: "Practice retrieved successfully", Message: "Practice retrieved successfully",
Data: p, Data: p,

View File

@ -279,7 +279,7 @@ func (h *Handler) CompleteLesson(c *fiber.Ctx) error {
} }
uid := c.Locals("user_id").(int64) uid := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role) role := c.Locals("role").(domain.Role)
if role == domain.RoleStudent { if role.UsesLMSSequentialGating() {
ok, reason, err := h.lmsProgressSvc.CanAccessLesson(c.Context(), uid, id) ok, reason, err := h.lmsProgressSvc.CanAccessLesson(c.Context(), uid, id)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{

View File

@ -1,6 +1,9 @@
package handlers package handlers
import ( import (
"errors"
"strconv"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@ -30,3 +33,110 @@ func (h *Handler) GetMyLMSProgress(c *fiber.Ctx) error {
StatusCode: fiber.StatusOK, 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,
})
}

View File

@ -76,7 +76,8 @@ func (h *Handler) ListPracticesByCourse(c *fiber.Ctx) error {
} }
limit, _ := strconv.Atoi(c.Query("limit", "20")) limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0")) 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 err != nil {
if errors.Is(err, courses.ErrCourseNotFound) { if errors.Is(err, courses.ErrCourseNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Course not found", Error: err.Error()}) 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")) limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0")) 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 err != nil {
if errors.Is(err, modules.ErrModuleNotFound) { if errors.Is(err, modules.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Module not found", Error: err.Error()}) 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")) limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0")) 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 err != nil {
if errors.Is(err, lessons.ErrLessonNotFound) { if errors.Is(err, lessons.ErrLessonNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Lesson not found", Error: err.Error()}) 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()}) 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}) return c.JSON(domain.Response{Message: "Practice retrieved successfully", Data: p, Success: true, StatusCode: fiber.StatusOK})
} }

View 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
}

View File

@ -12,7 +12,7 @@ import (
// CreateProgram godoc // CreateProgram godoc
// @Summary Create program // @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 // @Tags programs
// @Accept json // @Accept json
// @Produce json // @Produce json

View File

@ -4,6 +4,7 @@ import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
@ -736,7 +737,7 @@ func isSequenceGatedPractice(set domain.QuestionSet) bool {
func (h *Handler) enforcePracticeSequenceForStudent(c *fiber.Ctx, set domain.QuestionSet) error { func (h *Handler) enforcePracticeSequenceForStudent(c *fiber.Ctx, set domain.QuestionSet) error {
role := c.Locals("role").(domain.Role) role := c.Locals("role").(domain.Role)
if role != domain.RoleStudent || !isSequenceGatedPractice(set) { if !role.UsesLMSSequentialGating() || !isSequenceGatedPractice(set) {
return nil return nil
} }
if !strings.EqualFold(set.Status, "PUBLISHED") { 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 { if err := h.enforcePracticeSequenceForStudent(c, set); err != nil {
status := fiber.StatusForbidden status := fiber.StatusForbidden
if ferr, ok := err.(*fiber.Error); ok { 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] // @Router /api/v1/progress/practices/{id}/complete [post]
func (h *Handler) CompletePractice(c *fiber.Ctx) error { func (h *Handler) CompletePractice(c *fiber.Ctx) error {
role := c.Locals("role").(domain.Role) role := c.Locals("role").(domain.Role)
if role != domain.RoleStudent { if !role.IsCustomerLearnerRole() {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Only learners can complete practices", Message: "Only learners can complete practices",
}) })
@ -1552,6 +1567,11 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
var set domain.QuestionSet var set domain.QuestionSet
var setErr error var setErr error
if practiceErr == nil { 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) set, setErr = h.questionsSvc.GetQuestionSetByID(c.Context(), practice.QuestionSetID)
} else { } else {
// Backward compatibility: also accept question_set.id directly. // 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)) { if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"}) 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. // Enforce sequential gating only for published practices.
if strings.EqualFold(set.Status, "PUBLISHED") { if strings.EqualFold(set.Status, "PUBLISHED") {

View File

@ -423,7 +423,7 @@ func (h *Handler) CheckUserPending(c *fiber.Ctx) error {
// GetAllUsers godoc // GetAllUsers godoc
// @Summary Get all users // @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 // @Tags user
// @Accept json // @Accept json
// @Produce json // @Produce json
@ -433,7 +433,10 @@ func (h *Handler) CheckUserPending(c *fiber.Ctx) error {
// @Param page_size query int false "Page size" // @Param page_size query int false "Page size"
// @Param created_before query string false "Created before (RFC3339)" // @Param created_before query string false "Created before (RFC3339)"
// @Param created_after query string false "Created after (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 // @Success 200 {object} response.APIResponse
// @Failure 400 {object} response.APIResponse // @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
@ -467,14 +470,32 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
createdAfter = domain.ValidTime{Value: parsed, Valid: true} 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{ filter := domain.UserFilter{
Role: c.Query("role"), Role: c.Query("role"),
Status: c.Query("status"), Status: c.Query("status"),
Page: int64(c.QueryInt("page", 1) - 1), Country: strings.TrimSpace(c.Query("country")),
PageSize: int64(c.QueryInt("page_size", 10)), Region: strings.TrimSpace(c.Query("region")),
Query: searchString.Value, SubscriptionStatus: subscriptionStatusFilter,
CreatedBefore: createdBefore, Page: int64(c.QueryInt("page", 1) - 1),
CreatedAfter: createdAfter, PageSize: int64(c.QueryInt("page_size", 10)),
Query: searchString.Value,
CreatedBefore: createdBefore,
CreatedAfter: createdAfter,
} }
if valErrs, ok := h.validator.Validate(c, filter); !ok { if valErrs, ok := h.validator.Validate(c, filter); !ok {
@ -503,9 +524,9 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
for i, u := range users { for i, u := range users {
userIDs[i] = u.ID userIDs[i] = u.ID
} }
activeSubs, err := h.subscriptionsSvc.ListActiveSubscriptionsForUserIDs(c.Context(), userIDs) subStatuses, err := h.subscriptionsSvc.ListSubscriptionDisplayStatusesForUserIDs(c.Context(), userIDs)
if err != nil { 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.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err), zap.Error(err),
zap.Time("timestamp", time.Now())) zap.Time("timestamp", time.Now()))
@ -551,9 +572,11 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
if !u.BirthDay.IsZero() { if !u.BirthDay.IsZero() {
bd = u.BirthDay.Format("2006-01-02") bd = u.BirthDay.Format("2006-01-02")
} }
var activeSub *domain.UserSubscriptionSummary var subStatus string
if sub, ok := activeSubs[u.ID]; ok { if s, ok := subStatuses[u.ID]; ok {
activeSub = sub.Summary() subStatus = s
} else {
subStatus = "Unsubscribed"
} }
mapped = append(mapped, domain.UserProfileResponse{ mapped = append(mapped, domain.UserProfileResponse{
@ -585,7 +608,7 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
PreferredLanguage: u.PreferredLanguage, PreferredLanguage: u.PreferredLanguage,
CreatedAt: u.CreatedAt, CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt, 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()) 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) lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID)
if err != nil { if err != nil {
if err != authentication.ErrRefreshTokenNotFound { if err != authentication.ErrRefreshTokenNotFound {
@ -1436,18 +1470,19 @@ func (h *Handler) GetUserProfile(c *fiber.Ctx) error {
} }
return "" return ""
}(), }(),
EducationLevel: user.EducationLevel, EducationLevel: user.EducationLevel,
Country: user.Country, Country: user.Country,
Region: user.Region, Region: user.Region,
EmailVerified: user.EmailVerified, EmailVerified: user.EmailVerified,
PhoneVerified: user.PhoneVerified, PhoneVerified: user.PhoneVerified,
Status: user.Status, Status: user.Status,
LastLogin: lastLogin, LastLogin: lastLogin,
ProfileCompleted: user.ProfileCompleted, ProfileCompleted: user.ProfileCompleted,
ProfilePictureURL: user.ProfilePictureURL, ProfilePictureURL: user.ProfilePictureURL,
PreferredLanguage: user.PreferredLanguage, PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt, CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt, UpdatedAt: user.UpdatedAt,
SubscriptionStatus: subscriptionStatus,
} }
return response.WriteJSON(c, fiber.StatusOK, "User profile retrieved successfully", res, nil) 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()) 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) lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID)
if err != nil { if err != nil {
if err != authentication.ErrRefreshTokenNotFound { if err != authentication.ErrRefreshTokenNotFound {
@ -1521,22 +1567,23 @@ func (h *Handler) AdminProfile(c *fiber.Ctx) error {
FirstName: user.FirstName, FirstName: user.FirstName,
LastName: user.LastName, LastName: user.LastName,
// UserName: user.UserName, // UserName: user.UserName,
Email: user.Email, Email: user.Email,
PhoneNumber: user.PhoneNumber, PhoneNumber: user.PhoneNumber,
Role: user.Role, Role: user.Role,
AgeGroup: user.AgeGroup, AgeGroup: user.AgeGroup,
EducationLevel: user.EducationLevel, EducationLevel: user.EducationLevel,
Country: user.Country, Country: user.Country,
Region: user.Region, Region: user.Region,
EmailVerified: user.EmailVerified, EmailVerified: user.EmailVerified,
PhoneVerified: user.PhoneVerified, PhoneVerified: user.PhoneVerified,
Status: user.Status, Status: user.Status,
LastLogin: lastLogin, LastLogin: lastLogin,
ProfileCompleted: user.ProfileCompleted, ProfileCompleted: user.ProfileCompleted,
ProfilePictureURL: user.ProfilePictureURL, ProfilePictureURL: user.ProfilePictureURL,
PreferredLanguage: user.PreferredLanguage, PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt, CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt, UpdatedAt: user.UpdatedAt,
SubscriptionStatus: subscriptionStatus,
} }
// Ensure birthday is included and formatted // Ensure birthday is included and formatted
if !user.BirthDay.IsZero() { 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()) 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)) res := make([]domain.UserProfileResponse, 0, len(users))
for _, user := range users { for _, user := range users {
lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID) lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID)
@ -1637,6 +1699,11 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error {
lastLogin = &user.CreatedAt lastLogin = &user.CreatedAt
} }
subStatus := "Unsubscribed"
if s, ok := subStatuses[user.ID]; ok {
subStatus = s
}
// var orgID *int64 // var orgID *int64
// if user.OrganizationID.Valid { // if user.OrganizationID.Valid {
// orgID = &user.OrganizationID.Value // orgID = &user.OrganizationID.Value
@ -1654,21 +1721,22 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error {
} }
return "" return ""
}(), }(),
PhoneNumber: user.PhoneNumber, PhoneNumber: user.PhoneNumber,
Role: user.Role, Role: user.Role,
AgeGroup: user.AgeGroup, AgeGroup: user.AgeGroup,
EducationLevel: user.EducationLevel, EducationLevel: user.EducationLevel,
Country: user.Country, Country: user.Country,
Region: user.Region, Region: user.Region,
EmailVerified: user.EmailVerified, EmailVerified: user.EmailVerified,
PhoneVerified: user.PhoneVerified, PhoneVerified: user.PhoneVerified,
Status: user.Status, Status: user.Status,
LastLogin: lastLogin, LastLogin: lastLogin,
ProfileCompleted: user.ProfileCompleted, ProfileCompleted: user.ProfileCompleted,
ProfilePictureURL: user.ProfilePictureURL, ProfilePictureURL: user.ProfilePictureURL,
PreferredLanguage: user.PreferredLanguage, PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt, CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt, 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()) 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) lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID)
if err != nil && err != authentication.ErrRefreshTokenNotFound { if err != nil && err != authentication.ErrRefreshTokenNotFound {
h.mongoLoggerSvc.Error("Failed to get user last login", h.mongoLoggerSvc.Error("Failed to get user last login",
@ -1749,22 +1828,23 @@ func (h *Handler) GetUserByID(c *fiber.Ctx) error {
Occupation: user.Occupation, Occupation: user.Occupation,
FavouriteTopic: user.FavouriteTopic, FavouriteTopic: user.FavouriteTopic,
// UserName: user.UserName, // UserName: user.UserName,
Email: user.Email, Email: user.Email,
PhoneNumber: user.PhoneNumber, PhoneNumber: user.PhoneNumber,
Role: user.Role, Role: user.Role,
AgeGroup: user.AgeGroup, AgeGroup: user.AgeGroup,
EducationLevel: user.EducationLevel, EducationLevel: user.EducationLevel,
Country: user.Country, Country: user.Country,
Region: user.Region, Region: user.Region,
EmailVerified: user.EmailVerified, EmailVerified: user.EmailVerified,
PhoneVerified: user.PhoneVerified, PhoneVerified: user.PhoneVerified,
Status: user.Status, Status: user.Status,
LastLogin: lastLogin, LastLogin: lastLogin,
ProfileCompleted: user.ProfileCompleted, ProfileCompleted: user.ProfileCompleted,
ProfilePictureURL: user.ProfilePictureURL, ProfilePictureURL: user.ProfilePictureURL,
PreferredLanguage: user.PreferredLanguage, PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt, CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt, UpdatedAt: user.UpdatedAt,
SubscriptionStatus: subscriptionStatus,
} }
return response.WriteJSON(c, fiber.StatusOK, "User retrieved successfully", res, nil) 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 { if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "Invalid authenticated role") 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") 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 { if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "Invalid authenticated role") 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") return fiber.NewError(fiber.StatusForbidden, "Only learners can cancel their own account deletion using this endpoint")
} }

View File

@ -183,7 +183,7 @@ func (a *App) RequireActiveSubscription() fiber.Handler {
switch role { switch role {
case domain.RoleSuperAdmin, domain.RoleAdmin, domain.RoleInstructor, domain.RoleSupport: case domain.RoleSuperAdmin, domain.RoleAdmin, domain.RoleInstructor, domain.RoleSupport:
return c.Next() return c.Next()
case domain.RoleStudent: case domain.RoleStudent, domain.RoleOpenLearner:
userID, ok := c.Locals("user_id").(int64) userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 { if !ok || userID == 0 {
return fiber.NewError(fiber.StatusUnauthorized, "Unauthorized") return fiber.NewError(fiber.StatusUnauthorized, "Unauthorized")

View File

@ -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("/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("/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/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.Get("/users/summary", a.authMiddleware, a.RequirePermission("users.summary"), h.GetUserSummary)
groupV1.Put("/user", a.authMiddleware, a.RequirePermission("users.update_self"), h.UpdateUser) 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) 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.Get("/admin/:id", a.authMiddleware, a.RequirePermission("admins.get"), h.GetAdminByID)
groupV1.Post("/admin", a.authMiddleware, a.RequirePermission("admins.create"), h.CreateAdmin) groupV1.Post("/admin", a.authMiddleware, a.RequirePermission("admins.create"), h.CreateAdmin)
groupV1.Put("/admin/:id", a.authMiddleware, a.RequirePermission("admins.update"), h.UpdateAdmin) 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 // Logs
groupV1.Get("/logs", a.authMiddleware, a.RequirePermission("logs.list"), handlers.GetLogsHandler(context.Background())) groupV1.Get("/logs", a.authMiddleware, a.RequirePermission("logs.list"), handlers.GetLogsHandler(context.Background()))