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)
-- Credentials: email + password@123
-- OPEN_LEARNER demo user is seeded by migration 000061_open_learner_role (not here).
-- ======================================================
INSERT INTO users (
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,
persona_id,
question_set_id,
quick_tips
) VALUES ($1, $2, $3, $4, $5, $6, $7)
quick_tips,
publish_status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: ExamPrepGetLessonPracticeByID :one
@ -15,6 +16,13 @@ SELECT *
FROM exam_prep.lesson_practices
WHERE id = $1;
-- name: ExamPrepGetLessonPracticeByQuestionSetID :one
SELECT *
FROM exam_prep.lesson_practices
WHERE question_set_id = $1
ORDER BY id DESC
LIMIT 1;
-- name: ExamPrepListLessonPracticesByLessonID :many
SELECT
COUNT(*) OVER () AS total_count,
@ -26,10 +34,15 @@ SELECT
p.persona_id,
p.question_set_id,
p.quick_tips,
p.publish_status,
p.created_at,
p.updated_at
FROM exam_prep.lesson_practices p
WHERE p.unit_module_lesson_id = $1
AND (
sqlc.arg('published_only')::boolean = FALSE
OR p.publish_status = 'PUBLISHED'::TEXT
)
ORDER BY p.created_at DESC
LIMIT $2
OFFSET $3;
@ -43,6 +56,7 @@ SET
persona_id = coalesce(sqlc.narg('persona_id')::bigint, persona_id),
question_set_id = coalesce(sqlc.narg('question_set_id')::bigint, question_set_id),
quick_tips = coalesce(sqlc.narg('quick_tips')::text, quick_tips),
publish_status = coalesce(sqlc.narg('publish_status')::varchar, publish_status),
updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id')
RETURNING *;

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -140,6 +140,32 @@ WHERE id = $1;
DELETE FROM team_members
WHERE id = $1;
-- name: BulkDeactivateTeamMembersByRole :execrows
UPDATE team_members
SET
status = 'inactive',
updated_at = CURRENT_TIMESTAMP
WHERE
team_role = $1
AND (
sqlc.narg('exclude_team_member_id')::BIGINT IS NULL
OR id <> sqlc.narg('exclude_team_member_id')::BIGINT
)
AND status = 'active';
-- name: BulkReactivateTeamMembersByRole :execrows
UPDATE team_members
SET
status = 'active',
updated_at = CURRENT_TIMESTAMP
WHERE
team_role = $1
AND (
sqlc.narg('exclude_team_member_id')::BIGINT IS NULL
OR id <> sqlc.narg('exclude_team_member_id')::BIGINT
)
AND status = 'inactive';
-- name: CheckTeamMemberEmailExists :one
SELECT EXISTS (
SELECT 1 FROM team_members WHERE email = $1

View File

@ -141,6 +141,11 @@ RETURNING
updated_at;
-- name: GetUserCreatedAt :one
SELECT created_at
FROM users
WHERE id = $1;
-- name: GetUserByID :one
SELECT *
FROM users
@ -196,6 +201,46 @@ WHERE
))
AND (sqlc.narg('created_after')::TIMESTAMPTZ IS NULL OR created_at >= sqlc.narg('created_after')::TIMESTAMPTZ)
AND (sqlc.narg('created_before')::TIMESTAMPTZ IS NULL OR created_at <= sqlc.narg('created_before')::TIMESTAMPTZ)
AND (sqlc.narg('country')::TEXT IS NULL OR LOWER(TRIM(COALESCE(country, ''))) = LOWER(TRIM(sqlc.narg('country')::TEXT)))
AND (sqlc.narg('region')::TEXT IS NULL OR LOWER(TRIM(COALESCE(region, ''))) = LOWER(TRIM(sqlc.narg('region')::TEXT)))
AND (
sqlc.narg('subscription_status')::TEXT IS NULL
OR (
sqlc.narg('subscription_status')::TEXT = 'ACTIVE'
AND EXISTS (
SELECT 1 FROM user_subscriptions us
WHERE us.user_id = users.id
AND us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
)
)
OR (
sqlc.narg('subscription_status')::TEXT = 'PENDING'
AND NOT EXISTS (
SELECT 1 FROM user_subscriptions us
WHERE us.user_id = users.id
AND us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
)
AND EXISTS (
SELECT 1 FROM user_subscriptions us
WHERE us.user_id = users.id AND us.status = 'PENDING'
)
)
OR (
sqlc.narg('subscription_status')::TEXT = 'Unsubscribed'
AND NOT EXISTS (
SELECT 1 FROM user_subscriptions us
WHERE us.user_id = users.id
AND us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
)
AND NOT EXISTS (
SELECT 1 FROM user_subscriptions us
WHERE us.user_id = users.id AND us.status = 'PENDING'
)
)
)
ORDER BY created_at DESC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
@ -376,6 +421,26 @@ SET
updated_at = CURRENT_TIMESTAMP
WHERE id = $2;
-- name: BulkDeactivateUsersByRole :execrows
UPDATE users
SET
status = 'DEACTIVATED',
updated_at = CURRENT_TIMESTAMP
WHERE
role = $1
AND id <> $2
AND status <> 'DEACTIVATED';
-- name: BulkReactivateUsersByRole :execrows
UPDATE users
SET
status = 'ACTIVE',
updated_at = CURRENT_TIMESTAMP
WHERE
role = $1
AND id <> $2
AND status = 'DEACTIVATED';
-- name: GetUserSummary :one
SELECT
COUNT(*) AS total_users,

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
Include `publish_status`: `DRAFT` to hide the practice from subscribed learners until you set it to `PUBLISHED` (via create or `PUT /practices/:id`). Omit the field or send `PUBLISHED` to go live immediately (backward compatible).
```json
{
"parent_kind": "LESSON",
@ -423,7 +425,8 @@ This creates the practice record scoped to lesson.
"story_description": "A short two-speaker scenario.",
"story_image": "https://cdn.example.com/images/story.webp",
"question_set_id": 55,
"quick_tips": "Listen carefully before answering."
"quick_tips": "Listen carefully before answering.",
"publish_status": "DRAFT"
}
```

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": {
"get": {
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination",
@ -602,6 +740,116 @@ const docTemplate = `{
}
}
},
"/api/v1/admin/users/{user_id}/lms-learning-activity": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Returns programs, courses, modules, and lessons with completion details and completed practices. Only persisted completion signals are included (completed lessons, completed published practices, and rollup completion timestamps—not partial or in-progress attempts).",
"produces": [
"application/json"
],
"tags": [
"lms"
],
"summary": "Get a user's nested LMS learning activity (admin)",
"parameters": [
{
"type": "integer",
"description": "Target user ID",
"name": "user_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/admin/users/{user_id}/recent-activity": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Reverse-chronological feed for profile UI: account joined plus LMS completion milestones (lessons/modules/courses/programs). Optional practice completions via include_practices. Does not include \"started learning path\" unless you add persisted engagement events—the schema stores completions only.",
"produces": [
"application/json"
],
"tags": [
"lms"
],
"summary": "Recent activity timeline for a user (admin)",
"parameters": [
{
"type": "integer",
"description": "Target user ID",
"name": "user_id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Max items after merge (default 40, max 120)",
"name": "limit",
"in": "query"
},
{
"type": "boolean",
"description": "Include completed LMS practices (more verbose)",
"name": "include_practices",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/admin/{id}": {
"get": {
"description": "Get a single admin by id",
@ -3902,7 +4150,7 @@ const docTemplate = `{
}
},
"post": {
"description": "Create a top-level LMS program",
"description": "Create a top-level LMS program. Optional sort_order inserts at that global ordering; omit it to append after the current highest sort_order. Unique constraint applies to sort_order.",
"consumes": [
"application/json"
],
@ -4147,7 +4395,7 @@ const docTemplate = `{
}
},
"post": {
"description": "Create a course under a program",
"description": "Create a course under a program. Optional sort_order assigns position within that program (siblings shifted); omit to append after the current highest sort_order in the program.",
"consumes": [
"application/json"
],
@ -8436,7 +8684,7 @@ const docTemplate = `{
},
"/api/v1/users": {
"get": {
"description": "Get users with optional filters. Each user may include active_subscription when they have a current ACTIVE, non-expired plan.",
"description": "Get users with optional filters. Each user includes subscription_status: ACTIVE, PENDING, or Unsubscribed.",
"consumes": [
"application/json"
],
@ -8486,9 +8734,27 @@ const docTemplate = `{
},
{
"type": "string",
"description": "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)",
"description": "User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "Country filter (case-insensitive match on stored value)",
"name": "country",
"in": "query"
},
{
"type": "string",
"description": "Region filter (case-insensitive match on stored value)",
"name": "region",
"in": "query"
},
{
"type": "string",
"description": "Derived subscription filter: ACTIVE, PENDING, or Unsubscribed (matches response subscription_status semantics)",
"name": "subscription_status",
"in": "query"
}
],
"responses": {
@ -10078,6 +10344,14 @@ const docTemplate = `{
}
}
},
"domain.BulkAccountsByRoleRequest": {
"type": "object",
"properties": {
"exclude_team_member_id": {
"type": "integer"
}
}
},
"domain.CreateCourseInput": {
"type": "object",
"required": [
@ -10090,6 +10364,11 @@ const docTemplate = `{
"name": {
"type": "string"
},
"sort_order": {
"description": "SortOrder within the program when set; omit to append after current max within program_id (uniqueness is per-program).",
"type": "integer",
"minimum": 0
},
"thumbnail": {
"type": "string"
}
@ -10289,6 +10568,11 @@ const docTemplate = `{
"name": {
"type": "string"
},
"sort_order": {
"description": "SortOrder inserts at this global program order when set; omit to append after current max (sort_order uniqueness is enforced).",
"type": "integer",
"minimum": 0
},
"thumbnail": {
"type": "string"
}
@ -10779,6 +11063,7 @@ const docTemplate = `{
"SUPER_ADMIN",
"ADMIN",
"STUDENT",
"OPEN_LEARNER",
"INSTRUCTOR",
"SUPPORT"
],
@ -10786,6 +11071,7 @@ const docTemplate = `{
"RoleSuperAdmin",
"RoleAdmin",
"RoleStudent",
"RoleOpenLearner",
"RoleInstructor",
"RoleSupport"
]
@ -11294,9 +11580,6 @@ const docTemplate = `{
"domain.UserProfileResponse": {
"type": "object",
"properties": {
"active_subscription": {
"$ref": "#/definitions/domain.UserSubscriptionSummary"
},
"age_group": {
"type": "string"
},
@ -11384,6 +11667,9 @@ const docTemplate = `{
"status": {
"$ref": "#/definitions/domain.UserStatus"
},
"subscription_status": {
"type": "string"
},
"updated_at": {
"type": "string"
}
@ -11404,47 +11690,6 @@ const docTemplate = `{
"UserStatusDeactivated"
]
},
"domain.UserSubscriptionSummary": {
"type": "object",
"properties": {
"auto_renew": {
"type": "boolean"
},
"currency": {
"type": "string"
},
"duration_unit": {
"type": "string"
},
"duration_value": {
"type": "integer"
},
"expires_at": {
"type": "string"
},
"id": {
"type": "integer"
},
"payment_method": {
"type": "string"
},
"plan_id": {
"type": "integer"
},
"plan_name": {
"type": "string"
},
"price": {
"type": "number"
},
"starts_at": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"domain.UserSummary": {
"type": "object",
"properties": {

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": {
"get": {
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination",
@ -594,6 +732,116 @@
}
}
},
"/api/v1/admin/users/{user_id}/lms-learning-activity": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Returns programs, courses, modules, and lessons with completion details and completed practices. Only persisted completion signals are included (completed lessons, completed published practices, and rollup completion timestamps—not partial or in-progress attempts).",
"produces": [
"application/json"
],
"tags": [
"lms"
],
"summary": "Get a user's nested LMS learning activity (admin)",
"parameters": [
{
"type": "integer",
"description": "Target user ID",
"name": "user_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/admin/users/{user_id}/recent-activity": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Reverse-chronological feed for profile UI: account joined plus LMS completion milestones (lessons/modules/courses/programs). Optional practice completions via include_practices. Does not include \"started learning path\" unless you add persisted engagement events—the schema stores completions only.",
"produces": [
"application/json"
],
"tags": [
"lms"
],
"summary": "Recent activity timeline for a user (admin)",
"parameters": [
{
"type": "integer",
"description": "Target user ID",
"name": "user_id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Max items after merge (default 40, max 120)",
"name": "limit",
"in": "query"
},
{
"type": "boolean",
"description": "Include completed LMS practices (more verbose)",
"name": "include_practices",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/admin/{id}": {
"get": {
"description": "Get a single admin by id",
@ -3894,7 +4142,7 @@
}
},
"post": {
"description": "Create a top-level LMS program",
"description": "Create a top-level LMS program. Optional sort_order inserts at that global ordering; omit it to append after the current highest sort_order. Unique constraint applies to sort_order.",
"consumes": [
"application/json"
],
@ -4139,7 +4387,7 @@
}
},
"post": {
"description": "Create a course under a program",
"description": "Create a course under a program. Optional sort_order assigns position within that program (siblings shifted); omit to append after the current highest sort_order in the program.",
"consumes": [
"application/json"
],
@ -8428,7 +8676,7 @@
},
"/api/v1/users": {
"get": {
"description": "Get users with optional filters. Each user may include active_subscription when they have a current ACTIVE, non-expired plan.",
"description": "Get users with optional filters. Each user includes subscription_status: ACTIVE, PENDING, or Unsubscribed.",
"consumes": [
"application/json"
],
@ -8478,9 +8726,27 @@
},
{
"type": "string",
"description": "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)",
"description": "User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "Country filter (case-insensitive match on stored value)",
"name": "country",
"in": "query"
},
{
"type": "string",
"description": "Region filter (case-insensitive match on stored value)",
"name": "region",
"in": "query"
},
{
"type": "string",
"description": "Derived subscription filter: ACTIVE, PENDING, or Unsubscribed (matches response subscription_status semantics)",
"name": "subscription_status",
"in": "query"
}
],
"responses": {
@ -10070,6 +10336,14 @@
}
}
},
"domain.BulkAccountsByRoleRequest": {
"type": "object",
"properties": {
"exclude_team_member_id": {
"type": "integer"
}
}
},
"domain.CreateCourseInput": {
"type": "object",
"required": [
@ -10082,6 +10356,11 @@
"name": {
"type": "string"
},
"sort_order": {
"description": "SortOrder within the program when set; omit to append after current max within program_id (uniqueness is per-program).",
"type": "integer",
"minimum": 0
},
"thumbnail": {
"type": "string"
}
@ -10281,6 +10560,11 @@
"name": {
"type": "string"
},
"sort_order": {
"description": "SortOrder inserts at this global program order when set; omit to append after current max (sort_order uniqueness is enforced).",
"type": "integer",
"minimum": 0
},
"thumbnail": {
"type": "string"
}
@ -10771,6 +11055,7 @@
"SUPER_ADMIN",
"ADMIN",
"STUDENT",
"OPEN_LEARNER",
"INSTRUCTOR",
"SUPPORT"
],
@ -10778,6 +11063,7 @@
"RoleSuperAdmin",
"RoleAdmin",
"RoleStudent",
"RoleOpenLearner",
"RoleInstructor",
"RoleSupport"
]
@ -11286,9 +11572,6 @@
"domain.UserProfileResponse": {
"type": "object",
"properties": {
"active_subscription": {
"$ref": "#/definitions/domain.UserSubscriptionSummary"
},
"age_group": {
"type": "string"
},
@ -11376,6 +11659,9 @@
"status": {
"$ref": "#/definitions/domain.UserStatus"
},
"subscription_status": {
"type": "string"
},
"updated_at": {
"type": "string"
}
@ -11396,47 +11682,6 @@
"UserStatusDeactivated"
]
},
"domain.UserSubscriptionSummary": {
"type": "object",
"properties": {
"auto_renew": {
"type": "boolean"
},
"currency": {
"type": "string"
},
"duration_unit": {
"type": "string"
},
"duration_value": {
"type": "integer"
},
"expires_at": {
"type": "string"
},
"id": {
"type": "integer"
},
"payment_method": {
"type": "string"
},
"plan_id": {
"type": "integer"
},
"plan_name": {
"type": "string"
},
"price": {
"type": "number"
},
"starts_at": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"domain.UserSummary": {
"type": "object",
"properties": {

View File

@ -327,12 +327,22 @@ definitions:
total_users:
type: integer
type: object
domain.BulkAccountsByRoleRequest:
properties:
exclude_team_member_id:
type: integer
type: object
domain.CreateCourseInput:
properties:
description:
type: string
name:
type: string
sort_order:
description: SortOrder within the program when set; omit to append after current
max within program_id (uniqueness is per-program).
minimum: 0
type: integer
thumbnail:
type: string
required:
@ -463,6 +473,11 @@ definitions:
type: string
name:
type: string
sort_order:
description: SortOrder inserts at this global program order when set; omit
to append after current max (sort_order uniqueness is enforced).
minimum: 0
type: integer
thumbnail:
type: string
required:
@ -798,6 +813,7 @@ definitions:
- SUPER_ADMIN
- ADMIN
- STUDENT
- OPEN_LEARNER
- INSTRUCTOR
- SUPPORT
type: string
@ -805,6 +821,7 @@ definitions:
- RoleSuperAdmin
- RoleAdmin
- RoleStudent
- RoleOpenLearner
- RoleInstructor
- RoleSupport
domain.RoleRecord:
@ -1145,8 +1162,6 @@ definitions:
type: object
domain.UserProfileResponse:
properties:
active_subscription:
$ref: '#/definitions/domain.UserSubscriptionSummary'
age_group:
type: string
birth_day:
@ -1206,6 +1221,8 @@ definitions:
$ref: '#/definitions/domain.Role'
status:
$ref: '#/definitions/domain.UserStatus'
subscription_status:
type: string
updated_at:
type: string
type: object
@ -1221,33 +1238,6 @@ definitions:
- UserStatusActive
- UserStatusSuspended
- UserStatusDeactivated
domain.UserSubscriptionSummary:
properties:
auto_renew:
type: boolean
currency:
type: string
duration_unit:
type: string
duration_value:
type: integer
expires_at:
type: string
id:
type: integer
payment_method:
type: string
plan_id:
type: integer
plan_name:
type: string
price:
type: number
starts_at:
type: string
status:
type: string
type: object
domain.UserSummary:
properties:
active_users:
@ -2895,6 +2885,186 @@ paths:
summary: Update FAQ
tags:
- faqs
/api/v1/admin/roles/{role}/bulk-deactivate:
post:
consumes:
- application/json
description: Sets all platform users with the given users.role to DEACTIVATED
(except the caller) and all team_members with the given team_role to inactive.
Path :role may be a role key (e.g. INSTRUCTOR, ADMIN) or a decimal RBAC roles.id
from GET /api/v1/rbac/roles (resolved to RoleRecord.name uppercased). SUPER_ADMIN
cannot be bulk-deactivated. ADMIN platform users must use SUPER_ADMIN to bulk
change other platform ADMIN users (team_members with team_role ADMIN under
path ADMIN remain allowed). Empty body allowed; optionally pass exclude_team_member_id
to skip one team_members row (e.g. yourself).
parameters:
- description: Role key (INSTRUCTOR etc.) or RBAC roles.id (integer string)
in: path
name: role
required: true
type: string
- description: Optional exclusions
in: body
name: body
schema:
$ref: '#/definitions/domain.BulkAccountsByRoleRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
security:
- Bearer: []
summary: Bulk deactivate accounts by role (SUPER_ADMIN or ADMIN platform users
only)
tags:
- admin
/api/v1/admin/roles/{role}/bulk-reactivate:
post:
consumes:
- application/json
description: Sets all platform users with the given role from DEACTIVATED to
ACTIVE (except the caller) and all team_members with the given team_role from
inactive to active. Path :role may be a role key or decimal RBAC roles.id
(see bulk-deactivate). Path role must correspond to valid platform users.role
or team_members.team_role (after resolving id → name). SUPER_ADMIN cannot
be bulk changed. ADMIN callers cannot bulk change other platform ADMIN users
(team_members ADMIN under path ADMIN is allowed). Matches only users currently
DEACTIVATED and team rows currently inactive.
parameters:
- description: Role key (INSTRUCTOR etc.) or RBAC roles.id (integer string)
in: path
name: role
required: true
type: string
- description: Optional exclusions
in: body
name: body
schema:
$ref: '#/definitions/domain.BulkAccountsByRoleRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
security:
- Bearer: []
summary: Bulk reactivate accounts by role (SUPER_ADMIN or ADMIN platform users
only)
tags:
- admin
/api/v1/admin/users/{user_id}/lms-learning-activity:
get:
description: Returns programs, courses, modules, and lessons with completion
details and completed practices. Only persisted completion signals are included
(completed lessons, completed published practices, and rollup completion timestamps—not
partial or in-progress attempts).
parameters:
- description: Target user ID
in: path
name: user_id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
security:
- Bearer: []
summary: Get a user's nested LMS learning activity (admin)
tags:
- lms
/api/v1/admin/users/{user_id}/recent-activity:
get:
description: 'Reverse-chronological feed for profile UI: account joined plus
LMS completion milestones (lessons/modules/courses/programs). Optional practice
completions via include_practices. Does not include "started learning path"
unless you add persisted engagement events—the schema stores completions only.'
parameters:
- description: Target user ID
in: path
name: user_id
required: true
type: integer
- description: Max items after merge (default 40, max 120)
in: query
name: limit
type: integer
- description: Include completed LMS practices (more verbose)
in: query
name: include_practices
type: boolean
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
security:
- Bearer: []
summary: Recent activity timeline for a user (admin)
tags:
- lms
/api/v1/admin/users/deletion-requests:
get:
consumes:
@ -5072,7 +5242,9 @@ paths:
post:
consumes:
- application/json
description: Create a top-level LMS program
description: Create a top-level LMS program. Optional sort_order inserts at
that global ordering; omit it to append after the current highest sort_order.
Unique constraint applies to sort_order.
parameters:
- description: Program
in: body
@ -5207,7 +5379,9 @@ paths:
post:
consumes:
- application/json
description: Create a course under a program
description: Create a course under a program. Optional sort_order assigns position
within that program (siblings shifted); omit to append after the current highest
sort_order in the program.
parameters:
- description: Program ID
in: path
@ -8017,8 +8191,8 @@ paths:
get:
consumes:
- application/json
description: Get users with optional filters. Each user may include active_subscription
when they have a current ACTIVE, non-expired plan.
description: 'Get users with optional filters. Each user includes subscription_status:
ACTIVE, PENDING, or Unsubscribed.'
parameters:
- description: Role filter
in: query
@ -8044,10 +8218,23 @@ paths:
in: query
name: created_after
type: string
- description: Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)
- description: User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)
in: query
name: status
type: string
- description: Country filter (case-insensitive match on stored value)
in: query
name: country
type: string
- description: Region filter (case-insensitive match on stored value)
in: query
name: region
type: string
- description: 'Derived subscription filter: ACTIVE, PENDING, or Unsubscribed
(matches response subscription_status semantics)'
in: query
name: subscription_status
type: string
produces:
- application/json
responses:

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -365,6 +365,27 @@ func (q *Queries) GetExpiringSubscriptions(ctx context.Context) ([]GetExpiringSu
return items, nil
}
const GetSubscriptionDisplayStatusByUserID = `-- name: GetSubscriptionDisplayStatusByUserID :one
SELECT COALESCE(
(SELECT us.status::text FROM user_subscriptions us
WHERE us.user_id = $1
AND us.status = 'ACTIVE' AND us.expires_at > CURRENT_TIMESTAMP
ORDER BY us.expires_at DESC LIMIT 1),
(SELECT us.status::text FROM user_subscriptions us
WHERE us.user_id = $1
AND us.status = 'PENDING'
ORDER BY us.created_at DESC LIMIT 1),
'Unsubscribed'
)::text AS subscription_status
`
func (q *Queries) GetSubscriptionDisplayStatusByUserID(ctx context.Context, userID int64) (string, error) {
row := q.db.QueryRow(ctx, GetSubscriptionDisplayStatusByUserID, userID)
var subscription_status string
err := row.Scan(&subscription_status)
return subscription_status, err
}
const GetSubscriptionPlanByID = `-- name: GetSubscriptionPlanByID :one
SELECT id, name, description, duration_value, duration_unit, price, currency, is_active, created_at, updated_at FROM subscription_plans WHERE id = $1
`
@ -578,70 +599,42 @@ func (q *Queries) ListActiveSubscriptionPlans(ctx context.Context) ([]Subscripti
return items, nil
}
const ListActiveSubscriptionsByUserIDs = `-- name: ListActiveSubscriptionsByUserIDs :many
SELECT DISTINCT ON (us.user_id)
us.user_id,
us.id,
us.plan_id,
us.starts_at,
us.expires_at,
us.status,
us.auto_renew,
us.payment_method,
sp.name AS plan_name,
sp.duration_value,
sp.duration_unit,
sp.price,
sp.currency
FROM user_subscriptions us
JOIN subscription_plans sp ON sp.id = us.plan_id
WHERE us.user_id = ANY($1::bigint[])
AND us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
ORDER BY us.user_id, us.expires_at DESC
const ListSubscriptionDisplayStatusesByUserIDs = `-- name: ListSubscriptionDisplayStatusesByUserIDs :many
WITH input AS (
SELECT unnest($1::bigint[])::bigint AS user_id
)
SELECT
input.user_id,
COALESCE(
(SELECT us.status::text FROM user_subscriptions us
WHERE us.user_id = input.user_id
AND us.status = 'ACTIVE' AND us.expires_at > CURRENT_TIMESTAMP
ORDER BY us.expires_at DESC LIMIT 1),
(SELECT us.status::text FROM user_subscriptions us
WHERE us.user_id = input.user_id
AND us.status = 'PENDING'
ORDER BY us.created_at DESC LIMIT 1),
'Unsubscribed'
)::text AS subscription_status
FROM input
`
type ListActiveSubscriptionsByUserIDsRow struct {
type ListSubscriptionDisplayStatusesByUserIDsRow struct {
UserID int64 `json:"user_id"`
ID int64 `json:"id"`
PlanID int64 `json:"plan_id"`
StartsAt pgtype.Timestamptz `json:"starts_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
Status string `json:"status"`
AutoRenew bool `json:"auto_renew"`
PaymentMethod pgtype.Text `json:"payment_method"`
PlanName string `json:"plan_name"`
DurationValue int32 `json:"duration_value"`
DurationUnit string `json:"duration_unit"`
Price pgtype.Numeric `json:"price"`
Currency string `json:"currency"`
SubscriptionStatus string `json:"subscription_status"`
}
// One ACTIVE, non-expired row per user (latest expires_at wins), same rules as GetActiveSubscriptionByUserID.
func (q *Queries) ListActiveSubscriptionsByUserIDs(ctx context.Context, dollar_1 []int64) ([]ListActiveSubscriptionsByUserIDsRow, error) {
rows, err := q.db.Query(ctx, ListActiveSubscriptionsByUserIDs, dollar_1)
// Display status for admin user lists: ACTIVE (non-expired), else latest PENDING, else Unsubscribed.
func (q *Queries) ListSubscriptionDisplayStatusesByUserIDs(ctx context.Context, dollar_1 []int64) ([]ListSubscriptionDisplayStatusesByUserIDsRow, error) {
rows, err := q.db.Query(ctx, ListSubscriptionDisplayStatusesByUserIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListActiveSubscriptionsByUserIDsRow
var items []ListSubscriptionDisplayStatusesByUserIDsRow
for rows.Next() {
var i ListActiveSubscriptionsByUserIDsRow
if err := rows.Scan(
&i.UserID,
&i.ID,
&i.PlanID,
&i.StartsAt,
&i.ExpiresAt,
&i.Status,
&i.AutoRenew,
&i.PaymentMethod,
&i.PlanName,
&i.DurationValue,
&i.DurationUnit,
&i.Price,
&i.Currency,
); err != nil {
var i ListSubscriptionDisplayStatusesByUserIDsRow
if err := rows.Scan(&i.UserID, &i.SubscriptionStatus); err != nil {
return nil, err
}
items = append(items, i)

View File

@ -11,6 +11,60 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const BulkDeactivateTeamMembersByRole = `-- name: BulkDeactivateTeamMembersByRole :execrows
UPDATE team_members
SET
status = 'inactive',
updated_at = CURRENT_TIMESTAMP
WHERE
team_role = $1
AND (
$2::BIGINT IS NULL
OR id <> $2::BIGINT
)
AND status = 'active'
`
type BulkDeactivateTeamMembersByRoleParams struct {
TeamRole string `json:"team_role"`
ExcludeTeamMemberID pgtype.Int8 `json:"exclude_team_member_id"`
}
func (q *Queries) BulkDeactivateTeamMembersByRole(ctx context.Context, arg BulkDeactivateTeamMembersByRoleParams) (int64, error) {
result, err := q.db.Exec(ctx, BulkDeactivateTeamMembersByRole, arg.TeamRole, arg.ExcludeTeamMemberID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const BulkReactivateTeamMembersByRole = `-- name: BulkReactivateTeamMembersByRole :execrows
UPDATE team_members
SET
status = 'active',
updated_at = CURRENT_TIMESTAMP
WHERE
team_role = $1
AND (
$2::BIGINT IS NULL
OR id <> $2::BIGINT
)
AND status = 'inactive'
`
type BulkReactivateTeamMembersByRoleParams struct {
TeamRole string `json:"team_role"`
ExcludeTeamMemberID pgtype.Int8 `json:"exclude_team_member_id"`
}
func (q *Queries) BulkReactivateTeamMembersByRole(ctx context.Context, arg BulkReactivateTeamMembersByRoleParams) (int64, error) {
result, err := q.db.Exec(ctx, BulkReactivateTeamMembersByRole, arg.TeamRole, arg.ExcludeTeamMemberID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const CheckTeamMemberEmailExists = `-- name: CheckTeamMemberEmailExists :one
SELECT EXISTS (
SELECT 1 FROM team_members WHERE email = $1

View File

@ -11,6 +11,54 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const BulkDeactivateUsersByRole = `-- name: BulkDeactivateUsersByRole :execrows
UPDATE users
SET
status = 'DEACTIVATED',
updated_at = CURRENT_TIMESTAMP
WHERE
role = $1
AND id <> $2
AND status <> 'DEACTIVATED'
`
type BulkDeactivateUsersByRoleParams struct {
Role string `json:"role"`
ID int64 `json:"id"`
}
func (q *Queries) BulkDeactivateUsersByRole(ctx context.Context, arg BulkDeactivateUsersByRoleParams) (int64, error) {
result, err := q.db.Exec(ctx, BulkDeactivateUsersByRole, arg.Role, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const BulkReactivateUsersByRole = `-- name: BulkReactivateUsersByRole :execrows
UPDATE users
SET
status = 'ACTIVE',
updated_at = CURRENT_TIMESTAMP
WHERE
role = $1
AND id <> $2
AND status = 'DEACTIVATED'
`
type BulkReactivateUsersByRoleParams struct {
Role string `json:"role"`
ID int64 `json:"id"`
}
func (q *Queries) BulkReactivateUsersByRole(ctx context.Context, arg BulkReactivateUsersByRoleParams) (int64, error) {
result, err := q.db.Exec(ctx, BulkReactivateUsersByRole, arg.Role, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const CheckPhoneEmailExist = `-- name: CheckPhoneEmailExist :one
SELECT
EXISTS (
@ -386,9 +434,49 @@ WHERE
))
AND ($4::TIMESTAMPTZ IS NULL OR created_at >= $4::TIMESTAMPTZ)
AND ($5::TIMESTAMPTZ IS NULL OR created_at <= $5::TIMESTAMPTZ)
AND ($6::TEXT IS NULL OR LOWER(TRIM(COALESCE(country, ''))) = LOWER(TRIM($6::TEXT)))
AND ($7::TEXT IS NULL OR LOWER(TRIM(COALESCE(region, ''))) = LOWER(TRIM($7::TEXT)))
AND (
$8::TEXT IS NULL
OR (
$8::TEXT = 'ACTIVE'
AND EXISTS (
SELECT 1 FROM user_subscriptions us
WHERE us.user_id = users.id
AND us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
)
)
OR (
$8::TEXT = 'PENDING'
AND NOT EXISTS (
SELECT 1 FROM user_subscriptions us
WHERE us.user_id = users.id
AND us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
)
AND EXISTS (
SELECT 1 FROM user_subscriptions us
WHERE us.user_id = users.id AND us.status = 'PENDING'
)
)
OR (
$8::TEXT = 'Unsubscribed'
AND NOT EXISTS (
SELECT 1 FROM user_subscriptions us
WHERE us.user_id = users.id
AND us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
)
AND NOT EXISTS (
SELECT 1 FROM user_subscriptions us
WHERE us.user_id = users.id AND us.status = 'PENDING'
)
)
)
ORDER BY created_at DESC
LIMIT $7::INT
OFFSET $6::INT
LIMIT $10::INT
OFFSET $9::INT
`
type GetAllUsersParams struct {
@ -397,6 +485,9 @@ type GetAllUsersParams struct {
Query pgtype.Text `json:"query"`
CreatedAfter pgtype.Timestamptz `json:"created_after"`
CreatedBefore pgtype.Timestamptz `json:"created_before"`
Country pgtype.Text `json:"country"`
Region pgtype.Text `json:"region"`
SubscriptionStatus pgtype.Text `json:"subscription_status"`
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
@ -441,6 +532,9 @@ func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]Get
arg.Query,
arg.CreatedAfter,
arg.CreatedBefore,
arg.Country,
arg.Region,
arg.SubscriptionStatus,
arg.Offset,
arg.Limit,
)
@ -773,6 +867,19 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
return i, err
}
const GetUserCreatedAt = `-- name: GetUserCreatedAt :one
SELECT created_at
FROM users
WHERE id = $1
`
func (q *Queries) GetUserCreatedAt(ctx context.Context, id int64) (pgtype.Timestamptz, error) {
row := q.db.QueryRow(ctx, GetUserCreatedAt, id)
var created_at pgtype.Timestamptz
err := row.Scan(&created_at)
return created_at, err
}
const GetUserSummary = `-- name: GetUserSummary :one
SELECT
COUNT(*) AS total_users,

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

@ -36,6 +36,8 @@ type CreateCourseInput struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
// SortOrder within the program when set; omit to append after current max within program_id (uniqueness is per-program).
SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"`
}
type UpdateCourseInput struct {

View File

@ -11,11 +11,17 @@ type ExamPrepPractice struct {
StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID int64 `json:"question_set_id"`
PublishStatus PracticePublishStatus `json:"publish_status"`
QuickTips *string `json:"quick_tips,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// VisibleToLearners mirrors LMS practice visibility rules for subscribers.
func (p ExamPrepPractice) VisibleToLearners() bool {
return p.PublishStatus == PracticePublishPublished
}
// CreateExamPrepPracticeInput is the body for POST .../exam-prep/lessons/{lessonId}/practices (lesson from path).
type CreateExamPrepPracticeInput struct {
Title string `json:"title" validate:"required"`
@ -24,6 +30,7 @@ type CreateExamPrepPracticeInput struct {
PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID int64 `json:"question_set_id" validate:"required,gt=0"`
QuickTips *string `json:"quick_tips,omitempty"`
PublishStatus string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
}
type UpdateExamPrepPracticeInput struct {
@ -33,4 +40,5 @@ type UpdateExamPrepPracticeInput struct {
PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID *int64 `json:"question_set_id,omitempty"`
QuickTips *string `json:"quick_tips,omitempty"`
PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
}

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"`
Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"`
// SortOrder within the course when set; omit to append after current max within course_id (uniqueness is per-course).
SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"`
}
type UpdateModuleInput struct {

View File

@ -137,6 +137,8 @@ func ReceiverFromRole(role Role) NotificationRecieverSide {
return NotificationRecieverSideAdmin
case RoleStudent:
return NotificationRecieverSideCustomer
case RoleOpenLearner:
return NotificationRecieverSideCustomer
case RoleInstructor:
return NotificationRecieverSideCustomer
default:

View File

@ -1,6 +1,9 @@
package domain
import "time"
import (
"strings"
"time"
)
// ParentKind identifies which hierarchy entity owns a practice (exactly one).
type ParentKind string
@ -11,6 +14,31 @@ const (
ParentKindLesson ParentKind = "LESSON"
)
// PracticePublishStatus controls learner visibility for a practice shell (independent of question_set.status).
type PracticePublishStatus string
const (
PracticePublishDraft PracticePublishStatus = "DRAFT"
PracticePublishPublished PracticePublishStatus = "PUBLISHED"
)
// ParsePracticePublishStatusInput maps API input. Empty or unknown values default to PUBLISHED for backward compatibility.
func ParsePracticePublishStatusInput(raw string) PracticePublishStatus {
switch strings.TrimSpace(strings.ToUpper(raw)) {
case string(PracticePublishDraft):
return PracticePublishDraft
case string(PracticePublishPublished):
return PracticePublishPublished
default:
return PracticePublishPublished
}
}
// PracticePublishStatusFromDB maps persisted values into the domain type.
func PracticePublishStatusFromDB(raw string) PracticePublishStatus {
return ParsePracticePublishStatusInput(raw)
}
// Practice is question-set content (story, persona, tips) scoped to a course, module, or lesson.
type Practice struct {
ID int64 `json:"id"`
@ -21,11 +49,17 @@ type Practice struct {
StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID int64 `json:"question_set_id"`
PublishStatus PracticePublishStatus `json:"publish_status"`
QuickTips *string `json:"quick_tips,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// VisibleToLearners is true when the practice shell should appear in subscribed learner catalogs and progression.
func (p Practice) VisibleToLearners() bool {
return p.PublishStatus == PracticePublishPublished
}
type CreatePracticeInput struct {
ParentKind ParentKind `json:"parent_kind" validate:"required,oneof=COURSE MODULE LESSON"`
ParentID int64 `json:"parent_id" validate:"required,gt=0"`
@ -35,6 +69,8 @@ type CreatePracticeInput struct {
PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID int64 `json:"question_set_id" validate:"required,gt=0"`
QuickTips *string `json:"quick_tips,omitempty"`
// Omit or empty for backward compatibility defaults to PUBLISHED; set DRAFT to save hidden from learners until published.
PublishStatus string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
}
type UpdatePracticeInput struct {
@ -44,4 +80,5 @@ type UpdatePracticeInput struct {
PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID *int64 `json:"question_set_id,omitempty"`
QuickTips *string `json:"quick_tips,omitempty"`
PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
}

View File

@ -18,6 +18,8 @@ type CreateProgramInput struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
// SortOrder inserts at this global program order when set; omit to append after current max (sort_order uniqueness is enforced).
SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"`
}
type UpdateProgramInput struct {

View File

@ -6,19 +6,31 @@ const (
RoleSuperAdmin Role = "SUPER_ADMIN"
RoleAdmin Role = "ADMIN"
RoleStudent Role = "STUDENT"
// RoleOpenLearner can consume LMS content like a learner but without sequential prerequisite locking (step-by-step gates).
RoleOpenLearner Role = "OPEN_LEARNER"
RoleInstructor Role = "INSTRUCTOR"
RoleSupport Role = "SUPPORT"
)
func (r Role) IsValid() bool {
switch r {
case RoleSuperAdmin, RoleAdmin, RoleStudent, RoleInstructor, RoleSupport:
case RoleSuperAdmin, RoleAdmin, RoleStudent, RoleOpenLearner, RoleInstructor, RoleSupport:
return true
default:
return false
}
}
// UsesLMSSequentialGating is true when LMS APIs apply sequential prerequisite locks (403 when blocked).
func (r Role) UsesLMSSequentialGating() bool {
return r == RoleStudent
}
// IsCustomerLearnerRole is true for platform roles that sign in as customers and consume learner-facing LMS APIs.
func (r Role) IsCustomerLearnerRole() bool {
return r == RoleStudent || r == RoleOpenLearner
}
func (r Role) Value() string {
return string(r)
}

View File

@ -56,54 +56,6 @@ type UserSubscription struct {
Currency *string
}
// UserSubscriptionSummary is the active subscription attached to admin user list responses (GET /users).
type UserSubscriptionSummary struct {
ID int64 `json:"id"`
PlanID int64 `json:"plan_id"`
PlanName string `json:"plan_name"`
Status string `json:"status"`
StartsAt time.Time `json:"starts_at"`
ExpiresAt time.Time `json:"expires_at"`
AutoRenew bool `json:"auto_renew"`
PaymentMethod *string `json:"payment_method,omitempty"`
DurationValue int32 `json:"duration_value"`
DurationUnit string `json:"duration_unit"`
Price float64 `json:"price"`
Currency string `json:"currency"`
}
// Summary returns a copy safe for JSON embedding; nil if receiver is nil.
func (us *UserSubscription) Summary() *UserSubscriptionSummary {
if us == nil {
return nil
}
s := &UserSubscriptionSummary{
ID: us.ID,
PlanID: us.PlanID,
Status: us.Status,
StartsAt: us.StartsAt,
ExpiresAt: us.ExpiresAt,
AutoRenew: us.AutoRenew,
PaymentMethod: us.PaymentMethod,
}
if us.PlanName != nil {
s.PlanName = *us.PlanName
}
if us.DurationValue != nil {
s.DurationValue = *us.DurationValue
}
if us.DurationUnit != nil {
s.DurationUnit = *us.DurationUnit
}
if us.Price != nil {
s.Price = *us.Price
}
if us.Currency != nil {
s.Currency = *us.Currency
}
return s
}
type CreateSubscriptionPlanInput struct {
Name string
Description *string

View File

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

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 {
CreateExamPrepLessonPractice(ctx context.Context, lessonID int64, in domain.CreateExamPrepPracticeInput) (domain.ExamPrepPractice, error)
GetExamPrepLessonPracticeByID(ctx context.Context, id int64) (domain.ExamPrepPractice, error)
ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.ExamPrepPractice, int64, error)
TryGetExamPrepLessonPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (domain.ExamPrepPractice, bool, error)
ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepPractice, int64, error)
UpdateExamPrepLessonPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error)
DeleteExamPrepLessonPractice(ctx context.Context, id int64) error
}

View File

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

View File

@ -18,7 +18,8 @@ type SubscriptionStore interface {
CreateUserSubscription(ctx context.Context, input domain.CreateUserSubscriptionInput) (*domain.UserSubscription, error)
GetUserSubscriptionByID(ctx context.Context, id int64) (*domain.UserSubscription, error)
GetActiveSubscriptionByUserID(ctx context.Context, userID int64) (*domain.UserSubscription, error)
ListActiveSubscriptionsByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*domain.UserSubscription, error)
ListSubscriptionDisplayStatusesByUserIDs(ctx context.Context, userIDs []int64) (map[int64]string, error)
GetSubscriptionDisplayStatusByUserID(ctx context.Context, userID int64) (string, error)
GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error)
HasActiveSubscription(ctx context.Context, userID int64) (bool, error)
CancelUserSubscription(ctx context.Context, id int64) error

View File

@ -36,4 +36,6 @@ type TeamStore interface {
CreateTeamRefreshToken(ctx context.Context, memberID int64, token string, expiresAt, createdAt time.Time) error
GetTeamRefreshTokenByToken(ctx context.Context, token string) (domain.TeamRefreshToken, error)
RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error
BulkDeactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error)
BulkReactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error)
}

View File

@ -55,6 +55,9 @@ type UserStore interface {
status *string,
query *string,
createdBefore, createdAfter *time.Time,
country *string,
region *string,
subscriptionStatus *string,
limit, offset int32,
) ([]domain.User, int64, error)
ListAccountDeletionRequests(ctx context.Context, filter domain.AccountDeletionRequestFilter) ([]domain.AccountDeletionRequest, int64, error)
@ -82,6 +85,8 @@ type UserStore interface {
GetUserDeviceTokens(ctx context.Context, userID int64) ([]string, error)
DeactivateDevice(ctx context.Context, userID int64, deviceToken string) error
DeactivateAllUserDevices(ctx context.Context, userID int64) error
BulkDeactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error)
BulkReactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error)
}
type SmsGateway interface {
SendSMSOTP(ctx context.Context, phoneNumber, otp string) error

View File

@ -21,6 +21,7 @@ func examPrepPracticeFromListRow(r dbgen.ExamPrepListLessonPracticesByLessonIDRo
PersonaID: r.PersonaID,
QuestionSetID: r.QuestionSetID,
QuickTips: r.QuickTips,
PublishStatus: r.PublishStatus,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
})
@ -32,6 +33,7 @@ func examPrepPracticeToDomain(p dbgen.ExamPrepLessonPractice) domain.ExamPrepPra
LessonID: p.UnitModuleLessonID,
Title: p.Title,
QuestionSetID: p.QuestionSetID,
PublishStatus: domain.PracticePublishStatusFromDB(p.PublishStatus),
}
out.StoryDescription = fromPgText(p.StoryDescription)
out.StoryImage = fromPgText(p.StoryImage)
@ -46,6 +48,7 @@ func examPrepPracticeToDomain(p dbgen.ExamPrepLessonPractice) domain.ExamPrepPra
}
func (s *Store) CreateExamPrepLessonPractice(ctx context.Context, lessonID int64, in domain.CreateExamPrepPracticeInput) (domain.ExamPrepPractice, error) {
ps := domain.ParsePracticePublishStatusInput(in.PublishStatus)
p, err := s.queries.ExamPrepCreateLessonPractice(ctx, dbgen.ExamPrepCreateLessonPracticeParams{
UnitModuleLessonID: lessonID,
Title: in.Title,
@ -54,6 +57,7 @@ func (s *Store) CreateExamPrepLessonPractice(ctx context.Context, lessonID int64
PersonaID: int64PtrToPg8(in.PersonaID),
QuestionSetID: in.QuestionSetID,
QuickTips: toPgText(in.QuickTips),
PublishStatus: string(ps),
})
if err != nil {
return domain.ExamPrepPractice{}, err
@ -72,9 +76,22 @@ func (s *Store) GetExamPrepLessonPracticeByID(ctx context.Context, id int64) (do
return examPrepPracticeToDomain(p), nil
}
func (s *Store) ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) {
// TryGetExamPrepLessonPracticeByQuestionSetID returns false when no row exists.
func (s *Store) TryGetExamPrepLessonPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (domain.ExamPrepPractice, bool, error) {
p, err := s.queries.ExamPrepGetLessonPracticeByQuestionSetID(ctx, questionSetID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepPractice{}, false, nil
}
return domain.ExamPrepPractice{}, false, err
}
return examPrepPracticeToDomain(p), true, nil
}
func (s *Store) ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) {
rows, err := s.queries.ExamPrepListLessonPracticesByLessonID(ctx, dbgen.ExamPrepListLessonPracticesByLessonIDParams{
UnitModuleLessonID: lessonID,
PublishedOnly: publishedOnly,
Limit: limit,
Offset: offset,
})
@ -111,6 +128,7 @@ func (s *Store) UpdateExamPrepLessonPractice(ctx context.Context, id int64, inpu
PersonaID: optionalInt8UpdateID(input.PersonaID),
QuestionSetID: qs,
QuickTips: optionalTextUpdate(input.QuickTips),
PublishStatus: optionalPublishStatusUpdate(input.PublishStatus),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {

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) {
if input.SortOrder != nil {
q, tx, err := s.BeginTx(ctx)
if err != nil {
return domain.Course{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
target := int32(*input.SortOrder)
if _, err := tx.Exec(ctx,
`UPDATE courses SET sort_order = sort_order + 1 WHERE program_id = $1 AND sort_order >= $2`,
programID, target,
); err != nil {
return domain.Course{}, err
}
c, err := q.CreateCourse(ctx, dbgen.CreateCourseParams{
ProgramID: programID,
Name: input.Name,
Description: toPgText(input.Description),
Thumbnail: toPgText(input.Thumbnail),
SortOrder: pgtype.Int4{Int32: target, Valid: true},
})
if err != nil {
return domain.Course{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Course{}, err
}
return courseToDomain(c), nil
}
c, err := s.queries.CreateCourse(ctx, dbgen.CreateCourseParams{
ProgramID: programID,
Name: input.Name,
Description: toPgText(input.Description),
Thumbnail: toPgText(input.Thumbnail),
SortOrder: pgtype.Int4{Valid: false},
})
if err != nil {
return domain.Course{}, err
@ -105,18 +135,56 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, lim
}
func (s *Store) UpdateCourse(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error) {
sortParam := optionalInt4Update(input.SortOrder)
var nameText pgtype.Text
if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true}
} else {
nameText = pgtype.Text{Valid: false}
}
if input.SortOrder != nil {
cur, err := s.GetCourseByID(ctx, id)
if err != nil {
return domain.Course{}, err
}
oldPos := int32(cur.SortOrder)
newPos := int32(*input.SortOrder)
if oldPos != newPos {
q, tx, err := s.BeginTx(ctx)
if err != nil {
return domain.Course{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
if err := repositionCourseSortOrder(ctx, tx, cur.ProgramID, id, oldPos, newPos); err != nil {
return domain.Course{}, err
}
c, err := q.UpdateCourse(ctx, dbgen.UpdateCourseParams{
ID: id,
Name: nameText,
Description: optionalTextUpdate(input.Description),
Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: pgtype.Int4{Valid: false},
})
if err != nil {
return domain.Course{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Course{}, err
}
out := courseToDomain(c)
out.HasPractice = cur.HasPractice
return out, nil
}
sortParam = pgtype.Int4{Valid: false}
}
c, err := s.queries.UpdateCourse(ctx, dbgen.UpdateCourseParams{
ID: id,
Name: nameText,
Description: optionalTextUpdate(input.Description),
Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: optionalInt4Update(input.SortOrder),
SortOrder: sortParam,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {

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) {
sortParam := optionalInt4Update(input.SortOrder)
var titleText pgtype.Text
if input.Title != nil {
titleText = pgtype.Text{String: *input.Title, Valid: true}
} else {
titleText = pgtype.Text{Valid: false}
}
if input.SortOrder != nil {
cur, err := s.GetLessonByID(ctx, id)
if err != nil {
return domain.Lesson{}, err
}
oldPos := int32(cur.SortOrder)
newPos := int32(*input.SortOrder)
if oldPos != newPos {
q, tx, err := s.BeginTx(ctx)
if err != nil {
return domain.Lesson{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
if err := repositionLessonSortOrder(ctx, tx, cur.ModuleID, id, oldPos, newPos); err != nil {
return domain.Lesson{}, err
}
l, err := q.UpdateLesson(ctx, dbgen.UpdateLessonParams{
ID: id,
Title: titleText,
VideoUrl: optionalTextUpdate(input.VideoURL),
Thumbnail: optionalTextUpdate(input.Thumbnail),
Description: optionalTextUpdate(input.Description),
SortOrder: pgtype.Int4{Valid: false},
})
if err != nil {
return domain.Lesson{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Lesson{}, err
}
out := lessonToDomain(l)
out.HasPractice = cur.HasPractice
return out, nil
}
sortParam = pgtype.Int4{Valid: false}
}
l, err := s.queries.UpdateLesson(ctx, dbgen.UpdateLessonParams{
ID: id,
Title: titleText,
VideoUrl: optionalTextUpdate(input.VideoURL),
Thumbnail: optionalTextUpdate(input.Thumbnail),
Description: optionalTextUpdate(input.Description),
SortOrder: optionalInt4Update(input.SortOrder),
SortOrder: sortParam,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {

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) {
if input.SortOrder != nil {
q, tx, err := s.BeginTx(ctx)
if err != nil {
return domain.Module{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
target := int32(*input.SortOrder)
if _, err := tx.Exec(ctx,
`UPDATE modules SET sort_order = sort_order + 1 WHERE course_id = $1 AND sort_order >= $2`,
courseID, target,
); err != nil {
return domain.Module{}, err
}
m, err := q.CreateModule(ctx, dbgen.CreateModuleParams{
ProgramID: programID,
CourseID: courseID,
Name: input.Name,
Description: toPgText(input.Description),
Icon: toPgText(input.Icon),
SortOrder: pgtype.Int4{Int32: target, Valid: true},
})
if err != nil {
return domain.Module{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Module{}, err
}
return moduleToDomain(m), nil
}
m, err := s.queries.CreateModule(ctx, dbgen.CreateModuleParams{
ProgramID: programID,
CourseID: courseID,
Name: input.Name,
Description: toPgText(input.Description),
Icon: toPgText(input.Icon),
SortOrder: pgtype.Int4{Valid: false},
})
if err != nil {
return domain.Module{}, err
@ -107,69 +138,27 @@ func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, co
}
func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error) {
cur, err := s.GetModuleByID(ctx, id)
if err != nil {
return domain.Module{}, err
}
q, tx, err := s.BeginTx(ctx)
if err != nil {
return domain.Module{}, err
}
defer tx.Rollback(ctx)
var current dbgen.Module
err = tx.QueryRow(ctx, `
SELECT id, program_id, course_id, name, description, icon, sort_order, created_at, updated_at
FROM modules
WHERE id = $1
FOR UPDATE
`, id).Scan(
&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
}
defer func() { _ = tx.Rollback(ctx) }()
sortParam := optionalInt4Update(input.SortOrder)
if input.SortOrder != nil {
targetSort := int32(*input.SortOrder)
if targetSort != current.SortOrder {
var conflictID int64
err = tx.QueryRow(ctx, `
SELECT id
FROM modules
WHERE course_id = $1
AND sort_order = $2
AND id <> $3
FOR UPDATE
`, current.CourseID, targetSort, id).Scan(&conflictID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return domain.Module{}, err
}
if err == nil {
var tempSort int32
if err := tx.QueryRow(ctx, `
SELECT COALESCE(MIN(sort_order), 0) - 1
FROM modules
WHERE course_id = $1
`, current.CourseID).Scan(&tempSort); err != nil {
return domain.Module{}, err
}
if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order = $1 WHERE id = $2`, tempSort, id); err != nil {
return domain.Module{}, err
}
if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order = $1 WHERE id = $2`, current.SortOrder, conflictID); err != nil {
oldPos := int32(cur.SortOrder)
newPos := int32(*input.SortOrder)
if oldPos != newPos {
if err := repositionModuleSortOrder(ctx, tx, cur.CourseID, id, oldPos, newPos); err != nil {
return domain.Module{}, err
}
}
}
sortParam = pgtype.Int4{Valid: false}
}
var nameText pgtype.Text
@ -183,7 +172,7 @@ WHERE course_id = $1
Name: nameText,
Description: optionalTextUpdate(input.Description),
Icon: optionalTextUpdate(input.Icon),
SortOrder: optionalInt4Update(input.SortOrder),
SortOrder: sortParam,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@ -195,7 +184,9 @@ WHERE course_id = $1
if err := tx.Commit(ctx); err != nil {
return domain.Module{}, err
}
return moduleToDomain(m), nil
out := moduleToDomain(m)
out.HasPractice = cur.HasPractice
return out, nil
}
func (s *Store) DeleteModule(ctx context.Context, id int64) error {

View File

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

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 (
"context"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
)
@ -31,3 +32,8 @@ func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (d
ProgramIDs: programs,
}, nil
}
// ListUserLMSFlatLearningActivity returns flattened LMS activity rows for admin reporting (lesson + practice completions).
func (s *Store) ListUserLMSFlatLearningActivity(ctx context.Context, userID int64) ([]dbgen.ListUserLMSFlatLearningActivityByUserRow, error) {
return s.queries.ListUserLMSFlatLearningActivityByUser(ctx, userID)
}

View File

@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"strings"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
@ -28,10 +29,36 @@ func programToDomain(p dbgen.Program) domain.Program {
}
func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error) {
if input.SortOrder != nil {
q, tx, err := s.BeginTx(ctx)
if err != nil {
return domain.Program{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
target := int32(*input.SortOrder)
if _, err := tx.Exec(ctx, `UPDATE programs SET sort_order = sort_order + 1 WHERE sort_order >= $1`, target); err != nil {
return domain.Program{}, err
}
p, err := q.CreateProgram(ctx, dbgen.CreateProgramParams{
Name: input.Name,
Description: toPgText(input.Description),
Thumbnail: toPgText(input.Thumbnail),
SortOrder: pgtype.Int4{Int32: target, Valid: true},
})
if err != nil {
return domain.Program{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Program{}, err
}
return programToDomain(p), nil
}
p, err := s.queries.CreateProgram(ctx, dbgen.CreateProgramParams{
Name: input.Name,
Description: toPgText(input.Description),
Thumbnail: toPgText(input.Thumbnail),
SortOrder: pgtype.Int4{Valid: false},
})
if err != nil {
return domain.Program{}, err
@ -91,6 +118,19 @@ func optionalTextUpdate(val *string) pgtype.Text {
return pgtype.Text{String: *val, Valid: true}
}
func optionalPublishStatusUpdate(val *string) pgtype.Text {
if val == nil {
return pgtype.Text{Valid: false}
}
s := strings.TrimSpace(strings.ToUpper(*val))
switch s {
case string(domain.PracticePublishDraft), string(domain.PracticePublishPublished):
return pgtype.Text{String: s, Valid: true}
default:
return pgtype.Text{Valid: false}
}
}
func optionalInt4Update(v *int) pgtype.Int4 {
if v == nil {
return pgtype.Int4{Valid: false}
@ -99,6 +139,47 @@ func optionalInt4Update(v *int) pgtype.Int4 {
}
func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) {
sortParam := optionalInt4Update(input.SortOrder)
if input.SortOrder != nil {
cur, err := s.GetProgramByID(ctx, id)
if err != nil {
return domain.Program{}, err
}
oldPos := int32(cur.SortOrder)
newPos := int32(*input.SortOrder)
if oldPos != newPos {
q, tx, err := s.BeginTx(ctx)
if err != nil {
return domain.Program{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
if err := repositionProgramSortOrder(ctx, tx, id, oldPos, newPos); err != nil {
return domain.Program{}, err
}
var nameText pgtype.Text
if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true}
} else {
nameText = pgtype.Text{Valid: false}
}
p, err := q.UpdateProgram(ctx, dbgen.UpdateProgramParams{
ID: id,
Name: nameText,
Description: optionalTextUpdate(input.Description),
Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: pgtype.Int4{Valid: false},
})
if err != nil {
return domain.Program{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Program{}, err
}
return programToDomain(p), nil
}
sortParam = pgtype.Int4{Valid: false}
}
var nameText pgtype.Text
if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true}
@ -110,7 +191,7 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update
Name: nameText,
Description: optionalTextUpdate(input.Description),
Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: optionalInt4Update(input.SortOrder),
SortOrder: sortParam,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {

View File

@ -157,39 +157,25 @@ func (s *Store) GetActiveSubscriptionByUserID(ctx context.Context, userID int64)
}, nil
}
func (s *Store) ListActiveSubscriptionsByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*domain.UserSubscription, error) {
func (s *Store) ListSubscriptionDisplayStatusesByUserIDs(ctx context.Context, userIDs []int64) (map[int64]string, error) {
if len(userIDs) == 0 {
return map[int64]*domain.UserSubscription{}, nil
return map[int64]string{}, nil
}
rows, err := s.queries.ListActiveSubscriptionsByUserIDs(ctx, userIDs)
rows, err := s.queries.ListSubscriptionDisplayStatusesByUserIDs(ctx, userIDs)
if err != nil {
return nil, err
}
out := make(map[int64]*domain.UserSubscription, len(rows))
out := make(map[int64]string, len(rows))
for _, r := range rows {
dv := r.DurationValue
du := r.DurationUnit
pn := r.PlanName
cur := r.Currency
out[r.UserID] = &domain.UserSubscription{
ID: r.ID,
UserID: r.UserID,
PlanID: r.PlanID,
StartsAt: r.StartsAt.Time,
ExpiresAt: r.ExpiresAt.Time,
Status: r.Status,
AutoRenew: r.AutoRenew,
PaymentMethod: fromPgText(r.PaymentMethod),
PlanName: &pn,
DurationValue: &dv,
DurationUnit: &du,
Price: float64Ptr(fromPgNumeric(r.Price)),
Currency: &cur,
}
out[r.UserID] = r.SubscriptionStatus
}
return out, nil
}
func (s *Store) GetSubscriptionDisplayStatusByUserID(ctx context.Context, userID int64) (string, error) {
return s.queries.GetSubscriptionDisplayStatusByUserID(ctx, userID)
}
func (s *Store) GetUserSubscriptionHistory(ctx context.Context, userID int64, limit, offset int32) ([]domain.UserSubscription, error) {
subs, err := s.queries.GetUserSubscriptionHistory(ctx, dbgen.GetUserSubscriptionHistoryParams{
UserID: userID,

View File

@ -219,6 +219,28 @@ func (s *Store) DeleteTeamMember(ctx context.Context, memberID int64) error {
return s.queries.DeleteTeamMember(ctx, memberID)
}
func (s *Store) BulkDeactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) {
var ex pgtype.Int8
if excludeTeamMemberID != nil {
ex = pgtype.Int8{Int64: *excludeTeamMemberID, Valid: true}
}
return s.queries.BulkDeactivateTeamMembersByRole(ctx, dbgen.BulkDeactivateTeamMembersByRoleParams{
TeamRole: teamRole,
ExcludeTeamMemberID: ex,
})
}
func (s *Store) BulkReactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) {
var ex pgtype.Int8
if excludeTeamMemberID != nil {
ex = pgtype.Int8{Int64: *excludeTeamMemberID, Valid: true}
}
return s.queries.BulkReactivateTeamMembersByRole(ctx, dbgen.BulkReactivateTeamMembersByRoleParams{
TeamRole: teamRole,
ExcludeTeamMemberID: ex,
})
}
func (s *Store) CheckTeamMemberEmailExists(ctx context.Context, email string) (bool, error) {
return s.queries.CheckTeamMemberEmailExists(ctx, email)
}

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(
ctx context.Context,
user domain.User,
@ -347,6 +361,18 @@ func (s *Store) GetUserByID(
}, nil
}
// GetUserCreatedAt returns account created_at (used for timeline "joined" events).
func (s *Store) GetUserCreatedAt(ctx context.Context, userID int64) (time.Time, error) {
ts, err := s.queries.GetUserCreatedAt(ctx, userID)
if err != nil {
return time.Time{}, err
}
if !ts.Valid {
return time.Time{}, pgx.ErrNoRows
}
return ts.Time, nil
}
func (s *Store) GetUserByGoogleID(
ctx context.Context,
googleId string,
@ -414,6 +440,9 @@ func (s *Store) GetAllUsers(
status *string,
query *string,
createdBefore, createdAfter *time.Time,
country *string,
region *string,
subscriptionStatus *string,
limit, offset int32,
) ([]domain.User, int64, error) {
@ -442,12 +471,30 @@ func (s *Store) GetAllUsers(
createdBeforeParam = pgtype.Timestamptz{Time: *createdBefore, Valid: true}
}
var countryParam pgtype.Text
if country != nil && *country != "" {
countryParam = pgtype.Text{String: *country, Valid: true}
}
var regionParam pgtype.Text
if region != nil && *region != "" {
regionParam = pgtype.Text{String: *region, Valid: true}
}
var subscriptionStatusParam pgtype.Text
if subscriptionStatus != nil && *subscriptionStatus != "" {
subscriptionStatusParam = pgtype.Text{String: *subscriptionStatus, Valid: true}
}
params := dbgen.GetAllUsersParams{
Role: roleParam,
Status: statusParam,
Query: queryParam,
CreatedAfter: createdAfterParam,
CreatedBefore: createdBeforeParam,
Country: countryParam,
Region: regionParam,
SubscriptionStatus: subscriptionStatusParam,
Limit: pgtype.Int4{
Int32: limit,
Valid: true,

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)
}
func (s *Service) ListExamPrepPracticesByLesson(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) {
func (s *Service) ListExamPrepPracticesByLesson(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) {
if err := s.ensureLesson(ctx, lessonID); err != nil {
return nil, 0, err
}
@ -371,7 +371,7 @@ func (s *Service) ListExamPrepPracticesByLesson(ctx context.Context, lessonID in
if offset < 0 {
offset = 0
}
return s.store.ListExamPrepLessonPracticesByLessonID(ctx, lessonID, limit, offset)
return s.store.ListExamPrepLessonPracticesByLessonID(ctx, lessonID, publishedOnly, limit, offset)
}
func (s *Service) GetExamPrepPracticeByID(ctx context.Context, id int64) (domain.ExamPrepPractice, error) {
@ -385,6 +385,10 @@ func (s *Service) GetExamPrepPracticeByID(ctx context.Context, id int64) (domain
return p, nil
}
func (s *Service) TryGetExamPrepPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (domain.ExamPrepPractice, bool, error) {
return s.store.TryGetExamPrepLessonPracticeByQuestionSetID(ctx, questionSetID)
}
func (s *Service) UpdateExamPrepPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error) {
p, err := s.store.UpdateExamPrepLessonPractice(ctx, id, input)
if err != nil {

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

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

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"},
}
// defaultStudentLearnerPermissions is the learner consumption permission set shared by STUDENT and OPEN_LEARNER.
// LMS sequential prerequisite locking applies only to STUDENT in application handlers.
var defaultStudentLearnerPermissions = []string{
// Course browsing
"course_categories.list", "course_categories.get",
"courses.get", "courses.list_by_program",
"modules.get", "modules.list_by_course",
"lessons.get", "lessons.list_by_module", "lessons.complete",
"practices.get", "practices.list",
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
"videos.get", "videos.list_by_subcourse", "videos.list_published",
"learning_tree.get",
"programs.list", "programs.get",
"exam_prep.catalog_courses.list", "exam_prep.catalog_courses.get",
"exam_prep.units.list", "exam_prep.units.get",
"exam_prep.modules.list", "exam_prep.modules.get",
"exam_prep.lessons.list_by_module", "exam_prep.lessons.get",
"exam_prep.practices.list_by_lesson", "exam_prep.practices.get",
"lms.get_my_progress",
// Questions (read + attempt)
"questions.list", "questions.search", "questions.get",
"question_sets.list", "question_sets.list_by_owner", "question_sets.get",
"question_set_items.list",
"question_set_personas.list",
// Subscriptions & Payments (own)
"subscriptions.checkout", "subscriptions.get_mine", "subscriptions.history",
"subscriptions.status", "subscriptions.cancel", "subscriptions.set_auto_renew",
"payments.initiate", "payments.verify", "payments.list_mine", "payments.get", "payments.cancel",
"payments.direct_initiate", "payments.direct_verify_otp",
// User (self-service)
"users.update_self", "users.delete_self", "users.cancel_delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile",
// Notifications (own)
"notifications.ws_connect", "notifications.list_mine", "notifications.list_all",
"notifications.mark_read", "notifications.mark_all_read", "notifications.mark_unread", "notifications.mark_all_unread",
"notifications.delete_mine", "notifications.count_unread",
"notifications.test_push",
// Issues (own)
"issues.create", "issues.list_mine",
// Devices
"devices.register", "devices.unregister",
// Progress
"progress.start", "progress.update", "progress.complete", "progress.check_access", "progress.get_course",
// Sub-course Prerequisites (read)
"subcourse_prerequisites.list",
// Ratings
"ratings.submit", "ratings.list_by_target", "ratings.summary", "ratings.get_mine", "ratings.list_mine", "ratings.delete",
// Auth
"auth.logout",
}
// DefaultRolePermissions maps each system role to the permission keys it should
// have by default. This preserves the previous middleware behavior:
// - ADMIN: everything that was previously OnlyAdminAndAbove + SuperAdminOnly + all authenticated routes
// - STUDENT/INSTRUCTOR/SUPPORT: only self-service endpoints (profile, courses, progress, etc.)
// - STUDENT/OPEN_LEARNER/INSTRUCTOR/SUPPORT: only self-service endpoints (profile, courses, progress, etc.)
var DefaultRolePermissions = map[string][]string{
"ADMIN": {
// Course Management (full access)
@ -409,64 +470,9 @@ var DefaultRolePermissions = map[string][]string{
"internal.db.reset_reseed",
},
"STUDENT": {
// Course browsing
"course_categories.list", "course_categories.get",
"courses.get", "courses.list_by_program",
"modules.get", "modules.list_by_course",
"lessons.get", "lessons.list_by_module", "lessons.complete",
"practices.get", "practices.list",
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
"videos.get", "videos.list_by_subcourse", "videos.list_published",
"learning_tree.get",
"STUDENT": defaultStudentLearnerPermissions,
"programs.list", "programs.get",
"exam_prep.catalog_courses.list", "exam_prep.catalog_courses.get",
"exam_prep.units.list", "exam_prep.units.get",
"exam_prep.modules.list", "exam_prep.modules.get",
"exam_prep.lessons.list_by_module", "exam_prep.lessons.get",
"exam_prep.practices.list_by_lesson", "exam_prep.practices.get",
"lms.get_my_progress",
// Questions (read + attempt)
"questions.list", "questions.search", "questions.get",
"question_sets.list", "question_sets.list_by_owner", "question_sets.get",
"question_set_items.list",
"question_set_personas.list",
// Subscriptions & Payments (own)
"subscriptions.checkout", "subscriptions.get_mine", "subscriptions.history",
"subscriptions.status", "subscriptions.cancel", "subscriptions.set_auto_renew",
"payments.initiate", "payments.verify", "payments.list_mine", "payments.get", "payments.cancel",
"payments.direct_initiate", "payments.direct_verify_otp",
// User (self-service)
"users.update_self", "users.delete_self", "users.cancel_delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile",
// Notifications (own)
"notifications.ws_connect", "notifications.list_mine", "notifications.list_all",
"notifications.mark_read", "notifications.mark_all_read", "notifications.mark_unread", "notifications.mark_all_unread",
"notifications.delete_mine", "notifications.count_unread",
"notifications.test_push",
// Issues (own)
"issues.create", "issues.list_mine",
// Devices
"devices.register", "devices.unregister",
// Progress
"progress.start", "progress.update", "progress.complete", "progress.check_access", "progress.get_course",
// Sub-course Prerequisites (read)
"subcourse_prerequisites.list",
// Ratings
"ratings.submit", "ratings.list_by_target", "ratings.summary", "ratings.get_mine", "ratings.list_mine", "ratings.delete",
// Auth
"auth.logout",
},
"OPEN_LEARNER": defaultStudentLearnerPermissions,
"INSTRUCTOR": {
// Course browsing + management

View File

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

View File

@ -119,6 +119,14 @@ func (s *Service) GetTeamMemberStats(ctx context.Context) (domain.TeamMemberStat
return s.teamStore.CountTeamMembersByStatus(ctx)
}
func (s *Service) BulkDeactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) {
return s.teamStore.BulkDeactivateTeamMembersByRole(ctx, teamRole, excludeTeamMemberID)
}
func (s *Service) BulkReactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) {
return s.teamStore.BulkReactivateTeamMembersByRole(ctx, teamRole, excludeTeamMemberID)
}
func (s *Service) Login(ctx context.Context, req domain.TeamMemberLoginReq) (domain.TeamMember, error) {
member, err := s.teamStore.GetTeamMemberByEmail(ctx, req.Email)
if err != nil {

View File

@ -89,6 +89,21 @@ func (s *Service) GetAllUsers(
query = &filter.Query
}
var country *string
if filter.Country != "" {
country = &filter.Country
}
var region *string
if filter.Region != "" {
region = &filter.Region
}
var subscriptionStatus *string
if filter.SubscriptionStatus != "" {
subscriptionStatus = &filter.SubscriptionStatus
}
offset := int32(filter.Page * filter.PageSize)
return s.userStore.GetAllUsers(
@ -98,6 +113,9 @@ func (s *Service) GetAllUsers(
query,
before,
after,
country,
region,
subscriptionStatus,
int32(filter.PageSize),
offset,
)
@ -115,6 +133,14 @@ func (s *Service) UpdateUserStatus(ctx context.Context, req domain.UpdateUserSta
return s.userStore.UpdateUserStatus(ctx, req)
}
func (s *Service) BulkDeactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error) {
return s.userStore.BulkDeactivateUsersByRole(ctx, role, excludeUserID)
}
func (s *Service) BulkReactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error) {
return s.userStore.BulkReactivateUsersByRole(ctx, role, excludeUserID)
}
func (s *Service) GetUserById(ctx context.Context, id int64) (domain.User, error) {
return s.userStore.GetUserByID(ctx, id)

View File

@ -6,11 +6,14 @@ import (
"Yimaru-Backend/internal/web_server/response"
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
)
@ -379,3 +382,290 @@ func (h *Handler) UpdateAdmin(c *fiber.Ctx) error {
return response.WriteJSON(c, fiber.StatusOK, "Admin updated successfully", nil, nil)
}
// bulkAccountsRoleFromPath resolves admin bulk :role: decimal digits → rbac roles.id lookup (same ids as GET /api/v1/rbac/roles); otherwise uppercase role key.
func (h *Handler) bulkAccountsRoleFromPath(c *fiber.Ctx) (roleKey string, ok bool) {
raw := strings.TrimSpace(c.Params("role"))
if raw == "" {
_ = c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid role",
Error: "role path parameter is required",
})
return "", false
}
if rbacID, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil {
if rbacID <= 0 {
_ = c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid role",
Error: "numeric role path must be a positive RBAC roles.id (see GET /api/v1/rbac/roles)",
})
return "", false
}
rec, err := h.rbacSvc.GetRoleByID(c.Context(), rbacID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
_ = c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Invalid role",
Error: "RBAC role id not found",
})
return "", false
}
_ = c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "RBAC lookup failed",
Error: err.Error(),
})
return "", false
}
return strings.ToUpper(strings.TrimSpace(rec.Name)), true
}
return strings.ToUpper(raw), true
}
// BulkDeactivateAccountsByRole godoc
// @Summary Bulk deactivate accounts by role (SUPER_ADMIN or ADMIN platform users only)
// @Description Sets all platform users with the given users.role to DEACTIVATED (except the caller) and all team_members with the given team_role to inactive. Path :role may be a role key (e.g. INSTRUCTOR, ADMIN) or a decimal RBAC roles.id from GET /api/v1/rbac/roles (resolved to RoleRecord.name uppercased). SUPER_ADMIN cannot be bulk-deactivated. ADMIN platform users must use SUPER_ADMIN to bulk change other platform ADMIN users (team_members with team_role ADMIN under path ADMIN remain allowed). Empty body allowed; optionally pass exclude_team_member_id to skip one team_members row (e.g. yourself).
// @Tags admin
// @Accept json
// @Produce json
// @Security Bearer
// @Param role path string true "Role key (INSTRUCTOR etc.) or RBAC roles.id (integer string)"
// @Param body body domain.BulkAccountsByRoleRequest false "Optional exclusions"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 403 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/roles/{role}/bulk-deactivate [post]
func (h *Handler) BulkDeactivateAccountsByRole(c *fiber.Ctx) error {
callerRole, ok := c.Locals("role").(domain.Role)
if !ok {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Forbidden",
Error: "role not found in context",
})
}
if callerRole != domain.RoleSuperAdmin && callerRole != domain.RoleAdmin {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Forbidden",
Error: "only SUPER_ADMIN or ADMIN platform users may bulk deactivate by role",
})
}
actorID, ok := c.Locals("user_id").(int64)
if !ok || actorID <= 0 {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Unauthorized",
Error: "user id not found in context",
})
}
roleKey, rpOK := h.bulkAccountsRoleFromPath(c)
if !rpOK {
return nil
}
if roleKey == string(domain.RoleSuperAdmin) || roleKey == string(domain.TeamRoleSuperAdmin) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Refusing bulk deactivate",
Error: "SUPER_ADMIN cannot be bulk deactivated",
})
}
validUserRole := domain.Role(roleKey).IsValid()
validTeamRole := domain.TeamRole(roleKey).IsValid()
if !validUserRole && !validTeamRole {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid role",
Error: "role is not a valid platform users.role nor team_members.team_role",
})
}
// Non-super-admins cannot bulk change other platform ADMIN users (same role); team_members ADMIN is still allowed.
if callerRole != domain.RoleSuperAdmin && roleKey == string(domain.RoleAdmin) {
validUserRole = false
}
var req domain.BulkAccountsByRoleRequest
if len(c.Body()) > 0 {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid body",
Error: err.Error(),
})
}
}
if req.ExcludeTeamMemberID != nil && *req.ExcludeTeamMemberID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid exclude_team_member_id",
Error: "exclude_team_member_id must be positive when set",
})
}
var usersN, teamN int64
var err error
if validUserRole {
usersN, err = h.userSvc.BulkDeactivateUsersByRole(c.Context(), roleKey, actorID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Bulk user deactivation failed",
Error: err.Error(),
})
}
}
if validTeamRole {
teamN, err = h.teamSvc.BulkDeactivateTeamMembersByRole(c.Context(), roleKey, req.ExcludeTeamMemberID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Bulk team member deactivation failed",
Error: err.Error(),
})
}
}
out := domain.BulkDeactivateAccountsByRoleResult{
Role: roleKey,
UsersDeactivated: usersN,
TeamMembersDeactivated: teamN,
}
actorRole := string(callerRole)
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{
"role": roleKey,
"users_deactivated": usersN,
"team_members_deactivated": teamN,
"exclude_team_member_id": req.ExcludeTeamMemberID,
})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionUserUpdated, domain.ResourceUser, &actorID, fmt.Sprintf("Bulk deactivated role %s (%d users, %d team members)", roleKey, usersN, teamN), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Bulk deactivation completed",
Data: out,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// BulkReactivateAccountsByRole godoc
// @Summary Bulk reactivate accounts by role (SUPER_ADMIN or ADMIN platform users only)
// @Description Sets all platform users with the given role from DEACTIVATED to ACTIVE (except the caller) and all team_members with the given team_role from inactive to active. Path :role may be a role key or decimal RBAC roles.id (see bulk-deactivate). Path role must correspond to valid platform users.role or team_members.team_role (after resolving id → name). SUPER_ADMIN cannot be bulk changed. ADMIN callers cannot bulk change other platform ADMIN users (team_members ADMIN under path ADMIN is allowed). Matches only users currently DEACTIVATED and team rows currently inactive.
// @Tags admin
// @Accept json
// @Produce json
// @Security Bearer
// @Param role path string true "Role key (INSTRUCTOR etc.) or RBAC roles.id (integer string)"
// @Param body body domain.BulkAccountsByRoleRequest false "Optional exclusions"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 403 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/roles/{role}/bulk-reactivate [post]
func (h *Handler) BulkReactivateAccountsByRole(c *fiber.Ctx) error {
callerRole, ok := c.Locals("role").(domain.Role)
if !ok {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Forbidden",
Error: "role not found in context",
})
}
if callerRole != domain.RoleSuperAdmin && callerRole != domain.RoleAdmin {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Forbidden",
Error: "only SUPER_ADMIN or ADMIN platform users may bulk reactivate by role",
})
}
actorID, ok := c.Locals("user_id").(int64)
if !ok || actorID <= 0 {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Unauthorized",
Error: "user id not found in context",
})
}
roleKey, rpOK := h.bulkAccountsRoleFromPath(c)
if !rpOK {
return nil
}
if roleKey == string(domain.RoleSuperAdmin) || roleKey == string(domain.TeamRoleSuperAdmin) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Refusing bulk reactivate",
Error: "SUPER_ADMIN role cannot be bulk reactivated via this endpoint",
})
}
validUserRole := domain.Role(roleKey).IsValid()
validTeamRole := domain.TeamRole(roleKey).IsValid()
if !validUserRole && !validTeamRole {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid role",
Error: "role is not a valid platform users.role nor team_members.team_role",
})
}
// Non-super-admins cannot bulk change other platform ADMIN users; team_members ADMIN is still allowed.
if callerRole != domain.RoleSuperAdmin && roleKey == string(domain.RoleAdmin) {
validUserRole = false
}
var req domain.BulkAccountsByRoleRequest
if len(c.Body()) > 0 {
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid body",
Error: err.Error(),
})
}
}
if req.ExcludeTeamMemberID != nil && *req.ExcludeTeamMemberID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid exclude_team_member_id",
Error: "exclude_team_member_id must be positive when set",
})
}
var usersN, teamN int64
var err error
if validUserRole {
usersN, err = h.userSvc.BulkReactivateUsersByRole(c.Context(), roleKey, actorID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Bulk user reactivation failed",
Error: err.Error(),
})
}
}
if validTeamRole {
teamN, err = h.teamSvc.BulkReactivateTeamMembersByRole(c.Context(), roleKey, req.ExcludeTeamMemberID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Bulk team member reactivation failed",
Error: err.Error(),
})
}
}
out := domain.BulkReactivateAccountsByRoleResult{
Role: roleKey,
UsersReactivated: usersN,
TeamMembersReactivated: teamN,
}
actorRoleStr := string(callerRole)
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{
"role": roleKey,
"users_reactivated": usersN,
"team_members_reactivated": teamN,
"exclude_team_member_id": req.ExcludeTeamMemberID,
})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRoleStr, domain.ActionUserUpdated, domain.ResourceUser, &actorID, fmt.Sprintf("Bulk reactivated role %s (%d users, %d team members)", roleKey, usersN, teamN), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Bulk reactivation completed",
Data: out,
Success: true,
StatusCode: fiber.StatusOK,
})
}

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",
zap.Int("status_code", fiber.StatusForbidden),
zap.String("role", string(successRes.Role)),

View File

@ -13,7 +13,7 @@ import (
// CreateCourse godoc
// @Summary Create course
// @Description Create a course under a program
// @Description Create a course under a program. Optional sort_order assigns position within that program (siblings shifted); omit to append after the current highest sort_order in the program.
// @Tags courses
// @Accept json
// @Produce json

View File

@ -74,7 +74,8 @@ func (h *Handler) ListExamPrepPracticesByLesson(c *fiber.Ctx) error {
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
items, total, err := h.examPrepSvc.ListExamPrepPracticesByLesson(c.Context(), lessonID, int32(limit), int32(offset))
publishedOnly := !h.canManageExamPrepPractices(c)
items, total, err := h.examPrepSvc.ListExamPrepPracticesByLesson(c.Context(), lessonID, publishedOnly, int32(limit), int32(offset))
if err != nil {
if errors.Is(err, examprep.ErrLessonNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
@ -126,6 +127,9 @@ func (h *Handler) GetExamPrepPracticeByID(c *fiber.Ctx) error {
Error: err.Error(),
})
}
if !p.VisibleToLearners() && !h.canManageExamPrepPractices(c) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"})
}
return c.JSON(domain.Response{
Message: "Practice retrieved successfully",
Data: p,

View File

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

View File

@ -1,6 +1,9 @@
package handlers
import (
"errors"
"strconv"
"Yimaru-Backend/internal/domain"
"github.com/gofiber/fiber/v2"
@ -30,3 +33,110 @@ func (h *Handler) GetMyLMSProgress(c *fiber.Ctx) error {
StatusCode: fiber.StatusOK,
})
}
// AdminGetUserLMSLearningActivity godoc
// @Summary Get a user's nested LMS learning activity (admin)
// @Description Returns programs, courses, modules, and lessons with completion details and completed practices. Only persisted completion signals are included (completed lessons, completed published practices, and rollup completion timestamps—not partial or in-progress attempts).
// @Tags lms
// @Produce json
// @Security Bearer
// @Param user_id path int true "Target user ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/users/{user_id}/lms-learning-activity [get]
func (h *Handler) AdminGetUserLMSLearningActivity(c *fiber.Ctx) error {
targetIDStr := c.Params("user_id")
targetID, err := strconv.ParseInt(targetIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid user ID",
Error: err.Error(),
})
}
if targetID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid user ID",
Error: "user ID must be a positive integer",
})
}
tree, err := h.lmsProgressSvc.AdminUserLearningActivityTree(c.Context(), targetID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load LMS learning activity",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "LMS learning activity retrieved successfully",
Data: tree,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// AdminGetUserRecentActivity godoc
// @Summary Recent activity timeline for a user (admin)
// @Description Reverse-chronological feed for profile UI: account joined plus LMS completion milestones (lessons/modules/courses/programs). Optional practice completions via include_practices. Does not include "started learning path" unless you add persisted engagement events—the schema stores completions only.
// @Tags lms
// @Produce json
// @Security Bearer
// @Param user_id path int true "Target user ID"
// @Param limit query int false "Max items after merge (default 40, max 120)"
// @Param include_practices query bool false "Include completed LMS practices (more verbose)"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/users/{user_id}/recent-activity [get]
func (h *Handler) AdminGetUserRecentActivity(c *fiber.Ctx) error {
targetIDStr := c.Params("user_id")
targetID, err := strconv.ParseInt(targetIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid user ID",
Error: err.Error(),
})
}
if targetID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid user ID",
Error: "user ID must be a positive integer",
})
}
limit := 40
if ls := c.Query("limit"); ls != "" {
n, err := strconv.Atoi(ls)
if err != nil || n < 1 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid limit",
Error: "limit must be a positive integer",
})
}
limit = n
}
includePractices := c.Query("include_practices") == "true" || c.Query("include_practices") == "1"
feed, err := h.lmsProgressSvc.AdminUserRecentActivity(c.Context(), targetID, limit, includePractices)
if err != nil {
if errors.Is(err, domain.ErrUserNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "User not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load recent activity",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Recent activity retrieved successfully",
Data: feed,
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -76,7 +76,8 @@ func (h *Handler) ListPracticesByCourse(c *fiber.Ctx) error {
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
items, total, err := h.practiceSvc.ListByCourse(c.Context(), courseID, int32(limit), int32(offset))
publishedOnly := !h.canManageLMSPractices(c)
items, total, err := h.practiceSvc.ListByCourse(c.Context(), courseID, publishedOnly, int32(limit), int32(offset))
if err != nil {
if errors.Is(err, courses.ErrCourseNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Course not found", Error: err.Error()})
@ -107,7 +108,8 @@ func (h *Handler) ListPracticesByModule(c *fiber.Ctx) error {
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
items, total, err := h.practiceSvc.ListByModule(c.Context(), moduleID, int32(limit), int32(offset))
publishedOnly := !h.canManageLMSPractices(c)
items, total, err := h.practiceSvc.ListByModule(c.Context(), moduleID, publishedOnly, int32(limit), int32(offset))
if err != nil {
if errors.Is(err, modules.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Module not found", Error: err.Error()})
@ -138,7 +140,8 @@ func (h *Handler) ListPracticesByLesson(c *fiber.Ctx) error {
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
items, total, err := h.practiceSvc.ListByLesson(c.Context(), lessonID, int32(limit), int32(offset))
publishedOnly := !h.canManageLMSPractices(c)
items, total, err := h.practiceSvc.ListByLesson(c.Context(), lessonID, publishedOnly, int32(limit), int32(offset))
if err != nil {
if errors.Is(err, lessons.ErrLessonNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Lesson not found", Error: err.Error()})
@ -174,6 +177,9 @@ func (h *Handler) GetPractice(c *fiber.Ctx) error {
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load practice", Error: err.Error()})
}
if !p.VisibleToLearners() && !h.canManageLMSPractices(c) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"})
}
return c.JSON(domain.Response{Message: "Practice retrieved successfully", Data: p, Success: true, StatusCode: fiber.StatusOK})
}

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
// @Summary Create program
// @Description Create a top-level LMS program
// @Description Create a top-level LMS program. Optional sort_order inserts at that global ordering; omit it to append after the current highest sort_order. Unique constraint applies to sort_order.
// @Tags programs
// @Accept json
// @Produce json

View File

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

View File

@ -423,7 +423,7 @@ func (h *Handler) CheckUserPending(c *fiber.Ctx) error {
// GetAllUsers godoc
// @Summary Get all users
// @Description Get users with optional filters. Each user may include active_subscription when they have a current ACTIVE, non-expired plan.
// @Description Get users with optional filters. Each user includes subscription_status: ACTIVE, PENDING, or Unsubscribed.
// @Tags user
// @Accept json
// @Produce json
@ -433,7 +433,10 @@ func (h *Handler) CheckUserPending(c *fiber.Ctx) error {
// @Param page_size query int false "Page size"
// @Param created_before query string false "Created before (RFC3339)"
// @Param created_after query string false "Created after (RFC3339)"
// @Param status query string false "Status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)"
// @Param status query string false "User account status filter (ACTIVE, PENDING, SUSPENDED, DEACTIVATED)"
// @Param country query string false "Country filter (case-insensitive match on stored value)"
// @Param region query string false "Region filter (case-insensitive match on stored value)"
// @Param subscription_status query string false "Derived subscription filter: ACTIVE, PENDING, or Unsubscribed (matches response subscription_status semantics)"
// @Success 200 {object} response.APIResponse
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
@ -467,9 +470,27 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
createdAfter = domain.ValidTime{Value: parsed, Valid: true}
}
subscriptionStatusQuery := strings.TrimSpace(c.Query("subscription_status"))
var subscriptionStatusFilter string
if subscriptionStatusQuery != "" {
switch strings.ToUpper(subscriptionStatusQuery) {
case "ACTIVE":
subscriptionStatusFilter = "ACTIVE"
case "PENDING":
subscriptionStatusFilter = "PENDING"
case "UNSUBSCRIBED":
subscriptionStatusFilter = "Unsubscribed"
default:
return fiber.NewError(fiber.StatusBadRequest, `Invalid subscription_status; use ACTIVE, PENDING, or Unsubscribed`)
}
}
filter := domain.UserFilter{
Role: c.Query("role"),
Status: c.Query("status"),
Country: strings.TrimSpace(c.Query("country")),
Region: strings.TrimSpace(c.Query("region")),
SubscriptionStatus: subscriptionStatusFilter,
Page: int64(c.QueryInt("page", 1) - 1),
PageSize: int64(c.QueryInt("page_size", 10)),
Query: searchString.Value,
@ -503,9 +524,9 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
for i, u := range users {
userIDs[i] = u.ID
}
activeSubs, err := h.subscriptionsSvc.ListActiveSubscriptionsForUserIDs(c.Context(), userIDs)
subStatuses, err := h.subscriptionsSvc.ListSubscriptionDisplayStatusesForUserIDs(c.Context(), userIDs)
if err != nil {
h.mongoLoggerSvc.Error("failed to batch-load active subscriptions for user list",
h.mongoLoggerSvc.Error("failed to batch-load subscription display status for user list",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err),
zap.Time("timestamp", time.Now()))
@ -551,9 +572,11 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
if !u.BirthDay.IsZero() {
bd = u.BirthDay.Format("2006-01-02")
}
var activeSub *domain.UserSubscriptionSummary
if sub, ok := activeSubs[u.ID]; ok {
activeSub = sub.Summary()
var subStatus string
if s, ok := subStatuses[u.ID]; ok {
subStatus = s
} else {
subStatus = "Unsubscribed"
}
mapped = append(mapped, domain.UserProfileResponse{
@ -585,7 +608,7 @@ func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
PreferredLanguage: u.PreferredLanguage,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
ActiveSubscription: activeSub,
SubscriptionStatus: subStatus,
})
}
@ -1405,6 +1428,17 @@ func (h *Handler) GetUserProfile(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user profile:"+err.Error())
}
subscriptionStatus, err := h.subscriptionsSvc.GetSubscriptionDisplayStatusForUserID(c.Context(), user.ID)
if err != nil {
h.mongoLoggerSvc.Error("Failed to get subscription display status for profile",
zap.Int64("userID", userID),
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve subscription status:"+err.Error())
}
lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID)
if err != nil {
if err != authentication.ErrRefreshTokenNotFound {
@ -1448,6 +1482,7 @@ func (h *Handler) GetUserProfile(c *fiber.Ctx) error {
PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
SubscriptionStatus: subscriptionStatus,
}
return response.WriteJSON(c, fiber.StatusOK, "User profile retrieved successfully", res, nil)
}
@ -1502,6 +1537,17 @@ func (h *Handler) AdminProfile(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user profile:"+err.Error())
}
subscriptionStatus, err := h.subscriptionsSvc.GetSubscriptionDisplayStatusForUserID(c.Context(), user.ID)
if err != nil {
h.mongoLoggerSvc.Error("Failed to get subscription display status for admin profile",
zap.Int64("userID", userID),
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve subscription status:"+err.Error())
}
lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID)
if err != nil {
if err != authentication.ErrRefreshTokenNotFound {
@ -1537,6 +1583,7 @@ func (h *Handler) AdminProfile(c *fiber.Ctx) error {
PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
SubscriptionStatus: subscriptionStatus,
}
// Ensure birthday is included and formatted
if !user.BirthDay.IsZero() {
@ -1621,6 +1668,21 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get users: "+err.Error())
}
userIDs := make([]int64, len(users))
for i, u := range users {
userIDs[i] = u.ID
}
subStatuses, err := h.subscriptionsSvc.ListSubscriptionDisplayStatusesForUserIDs(c.Context(), userIDs)
if err != nil {
h.mongoLoggerSvc.Error("SearchUserByNameOrPhone - failed to load subscription status",
zap.Any("request", req),
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get subscription info: "+err.Error())
}
res := make([]domain.UserProfileResponse, 0, len(users))
for _, user := range users {
lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID)
@ -1637,6 +1699,11 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error {
lastLogin = &user.CreatedAt
}
subStatus := "Unsubscribed"
if s, ok := subStatuses[user.ID]; ok {
subStatus = s
}
// var orgID *int64
// if user.OrganizationID.Valid {
// orgID = &user.OrganizationID.Value
@ -1669,6 +1736,7 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error {
PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
SubscriptionStatus: subStatus,
})
}
@ -1711,6 +1779,17 @@ func (h *Handler) GetUserByID(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get user: "+err.Error())
}
subscriptionStatus, err := h.subscriptionsSvc.GetSubscriptionDisplayStatusForUserID(c.Context(), user.ID)
if err != nil {
h.mongoLoggerSvc.Error("Failed to get subscription display status for user by id",
zap.Int64("userID", userID),
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve subscription status: "+err.Error())
}
lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID)
if err != nil && err != authentication.ErrRefreshTokenNotFound {
h.mongoLoggerSvc.Error("Failed to get user last login",
@ -1765,6 +1844,7 @@ func (h *Handler) GetUserByID(c *fiber.Ctx) error {
PreferredLanguage: user.PreferredLanguage,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
SubscriptionStatus: subscriptionStatus,
}
return response.WriteJSON(c, fiber.StatusOK, "User retrieved successfully", res, nil)
@ -1853,7 +1933,7 @@ func (h *Handler) DeleteMyUserAccount(c *fiber.Ctx) error {
if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "Invalid authenticated role")
}
if role != domain.RoleStudent {
if !role.IsCustomerLearnerRole() {
return fiber.NewError(fiber.StatusForbidden, "Only learners can delete their own account using this endpoint")
}
@ -1906,7 +1986,7 @@ func (h *Handler) CancelMyUserAccountDeletion(c *fiber.Ctx) error {
if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "Invalid authenticated role")
}
if role != domain.RoleStudent {
if !role.IsCustomerLearnerRole() {
return fiber.NewError(fiber.StatusForbidden, "Only learners can cancel their own account deletion using this endpoint")
}

View File

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

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("/users", a.authMiddleware, a.RequirePermission("users.list"), h.GetAllUsers)
groupV1.Get("/admin/users/deletion-requests", a.authMiddleware, a.RequirePermission("users.deletion_requests.list"), h.ListAccountDeletionRequests)
groupV1.Get("/admin/users/:user_id/lms-learning-activity", a.authMiddleware, a.RequirePermission("progress.get_any_user"), h.AdminGetUserLMSLearningActivity)
groupV1.Get("/admin/users/:user_id/recent-activity", a.authMiddleware, a.RequirePermission("progress.get_any_user"), h.AdminGetUserRecentActivity)
groupV1.Get("/users/summary", a.authMiddleware, a.RequirePermission("users.summary"), h.GetUserSummary)
groupV1.Put("/user", a.authMiddleware, a.RequirePermission("users.update_self"), h.UpdateUser)
groupV1.Patch("/user/status", a.authMiddleware, a.RequirePermission("users.update_status"), h.UpdateUserStatus)
@ -303,6 +305,8 @@ func (a *App) initAppRoutes() {
groupV1.Get("/admin/:id", a.authMiddleware, a.RequirePermission("admins.get"), h.GetAdminByID)
groupV1.Post("/admin", a.authMiddleware, a.RequirePermission("admins.create"), h.CreateAdmin)
groupV1.Put("/admin/:id", a.authMiddleware, a.RequirePermission("admins.update"), h.UpdateAdmin)
groupV1.Post("/admin/roles/:role/bulk-deactivate", a.authMiddleware, h.BulkDeactivateAccountsByRole)
groupV1.Post("/admin/roles/:role/bulk-reactivate", a.authMiddleware, h.BulkReactivateAccountsByRole)
// Logs
groupV1.Get("/logs", a.authMiddleware, a.RequirePermission("logs.list"), handlers.GetLogsHandler(context.Background()))