Update learner progress to use practice completions only.
Remove lesson completion from learner progress percentages, access completion snapshots, and LMS rollups while keeping generated SQLC and Swagger artifacts in sync. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a719c0daca
commit
afdd07d65d
|
|
@ -117,6 +117,33 @@ INSERT INTO lms_user_program_progress (user_id, program_id)
|
|||
ON CONFLICT (user_id, program_id)
|
||||
DO NOTHING;
|
||||
|
||||
-- name: CountPublishedPracticesInLesson :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
WHERE
|
||||
lp.lesson_id = $1
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED';
|
||||
|
||||
-- name: CountUserCompletedPublishedPracticesInLesson :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
WHERE
|
||||
lp.lesson_id = $1
|
||||
AND upp.user_id = $2
|
||||
AND upp.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED';
|
||||
|
||||
-- name: CountLessonsInModule :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
|
|
@ -175,47 +202,95 @@ WHERE
|
|||
|
||||
-- name: ListLMSCompletedLessonIDsByUser :many
|
||||
SELECT
|
||||
ulp.lesson_id
|
||||
lp.lesson_id
|
||||
FROM
|
||||
lms_user_lesson_progress AS ulp
|
||||
lms_practices AS lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
AND upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
WHERE
|
||||
ulp.user_id = $1
|
||||
lp.lesson_id IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
GROUP BY
|
||||
lp.lesson_id
|
||||
HAVING
|
||||
count(DISTINCT lp.question_set_id) > 0
|
||||
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
|
||||
ORDER BY
|
||||
ulp.completed_at ASC,
|
||||
ulp.lesson_id ASC;
|
||||
max(upp.completed_at) ASC,
|
||||
lp.lesson_id ASC;
|
||||
|
||||
-- name: ListLMSCompletedModuleIDsByUser :many
|
||||
SELECT
|
||||
ump.module_id
|
||||
lp.module_id
|
||||
FROM
|
||||
lms_user_module_progress AS ump
|
||||
lms_practices AS lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
AND upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
WHERE
|
||||
ump.user_id = $1
|
||||
lp.module_id IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
GROUP BY
|
||||
lp.module_id
|
||||
HAVING
|
||||
count(DISTINCT lp.question_set_id) > 0
|
||||
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
|
||||
ORDER BY
|
||||
ump.completed_at ASC,
|
||||
ump.module_id ASC;
|
||||
max(upp.completed_at) ASC,
|
||||
lp.module_id ASC;
|
||||
|
||||
-- name: ListLMSCompletedCourseIDsByUser :many
|
||||
SELECT
|
||||
ucp.course_id
|
||||
lp.course_id
|
||||
FROM
|
||||
lms_user_course_progress AS ucp
|
||||
lms_practices AS lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
AND upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
WHERE
|
||||
ucp.user_id = $1
|
||||
lp.course_id IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
GROUP BY
|
||||
lp.course_id
|
||||
HAVING
|
||||
count(DISTINCT lp.question_set_id) > 0
|
||||
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
|
||||
ORDER BY
|
||||
ucp.completed_at ASC,
|
||||
ucp.course_id ASC;
|
||||
max(upp.completed_at) ASC,
|
||||
lp.course_id ASC;
|
||||
|
||||
-- name: ListLMSCompletedProgramIDsByUser :many
|
||||
SELECT
|
||||
upp.program_id
|
||||
c.program_id
|
||||
FROM
|
||||
lms_user_program_progress AS upp
|
||||
lms_practices AS lp
|
||||
INNER JOIN courses c ON c.id = lp.course_id
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
AND upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
GROUP BY
|
||||
c.program_id
|
||||
HAVING
|
||||
count(DISTINCT lp.question_set_id) > 0
|
||||
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
|
||||
ORDER BY
|
||||
upp.completed_at ASC,
|
||||
upp.program_id ASC;
|
||||
max(upp.completed_at) ASC,
|
||||
c.program_id ASC;
|
||||
|
||||
-- Lesson-based progress within a course (all modules).
|
||||
-- name: CountLessonsInCourse :one
|
||||
|
|
|
|||
1670
docs/docs.go
1670
docs/docs.go
File diff suppressed because it is too large
Load Diff
1670
docs/swagger.json
1670
docs/swagger.json
File diff suppressed because it is too large
Load Diff
1106
docs/swagger.yaml
1106
docs/swagger.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -119,6 +119,26 @@ func (q *Queries) CountPublishedPracticesInCourse(ctx context.Context, courseID
|
|||
return n, err
|
||||
}
|
||||
|
||||
const CountPublishedPracticesInLesson = `-- name: CountPublishedPracticesInLesson :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
WHERE
|
||||
lp.lesson_id = $1
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
func (q *Queries) CountPublishedPracticesInLesson(ctx context.Context, lessonID pgtype.Int8) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, CountPublishedPracticesInLesson, lessonID)
|
||||
var n int32
|
||||
err := row.Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
const CountPublishedPracticesInModule = `-- name: CountPublishedPracticesInModule :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
|
|
@ -309,6 +329,34 @@ func (q *Queries) CountUserCompletedPublishedPracticesInCourse(ctx context.Conte
|
|||
return n, err
|
||||
}
|
||||
|
||||
const CountUserCompletedPublishedPracticesInLesson = `-- name: CountUserCompletedPublishedPracticesInLesson :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
WHERE
|
||||
lp.lesson_id = $1
|
||||
AND upp.user_id = $2
|
||||
AND upp.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
type CountUserCompletedPublishedPracticesInLessonParams struct {
|
||||
LessonID pgtype.Int8 `json:"lesson_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CountUserCompletedPublishedPracticesInLesson(ctx context.Context, arg CountUserCompletedPublishedPracticesInLessonParams) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, CountUserCompletedPublishedPracticesInLesson, arg.LessonID, arg.UserID)
|
||||
var n int32
|
||||
err := row.Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
const CountUserCompletedPublishedPracticesInModule = `-- name: CountUserCompletedPublishedPracticesInModule :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
|
|
@ -591,25 +639,37 @@ func (q *Queries) InsertUserProgramProgress(ctx context.Context, arg InsertUserP
|
|||
|
||||
const ListLMSCompletedCourseIDsByUser = `-- name: ListLMSCompletedCourseIDsByUser :many
|
||||
SELECT
|
||||
ucp.course_id
|
||||
lp.course_id
|
||||
FROM
|
||||
lms_user_course_progress AS ucp
|
||||
lms_practices AS lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
AND upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
WHERE
|
||||
ucp.user_id = $1
|
||||
lp.course_id IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
GROUP BY
|
||||
lp.course_id
|
||||
HAVING
|
||||
count(DISTINCT lp.question_set_id) > 0
|
||||
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
|
||||
ORDER BY
|
||||
ucp.completed_at ASC,
|
||||
ucp.course_id ASC
|
||||
max(upp.completed_at) ASC,
|
||||
lp.course_id ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
|
||||
func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID int64) ([]pgtype.Int8, error) {
|
||||
rows, err := q.db.Query(ctx, ListLMSCompletedCourseIDsByUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []int64
|
||||
var items []pgtype.Int8
|
||||
for rows.Next() {
|
||||
var course_id int64
|
||||
var course_id pgtype.Int8
|
||||
if err := rows.Scan(&course_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -623,25 +683,37 @@ func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID in
|
|||
|
||||
const ListLMSCompletedLessonIDsByUser = `-- name: ListLMSCompletedLessonIDsByUser :many
|
||||
SELECT
|
||||
ulp.lesson_id
|
||||
lp.lesson_id
|
||||
FROM
|
||||
lms_user_lesson_progress AS ulp
|
||||
lms_practices AS lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
AND upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
WHERE
|
||||
ulp.user_id = $1
|
||||
lp.lesson_id IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
GROUP BY
|
||||
lp.lesson_id
|
||||
HAVING
|
||||
count(DISTINCT lp.question_set_id) > 0
|
||||
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
|
||||
ORDER BY
|
||||
ulp.completed_at ASC,
|
||||
ulp.lesson_id ASC
|
||||
max(upp.completed_at) ASC,
|
||||
lp.lesson_id ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
|
||||
func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID int64) ([]pgtype.Int8, error) {
|
||||
rows, err := q.db.Query(ctx, ListLMSCompletedLessonIDsByUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []int64
|
||||
var items []pgtype.Int8
|
||||
for rows.Next() {
|
||||
var lesson_id int64
|
||||
var lesson_id pgtype.Int8
|
||||
if err := rows.Scan(&lesson_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -655,25 +727,37 @@ func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID in
|
|||
|
||||
const ListLMSCompletedModuleIDsByUser = `-- name: ListLMSCompletedModuleIDsByUser :many
|
||||
SELECT
|
||||
ump.module_id
|
||||
lp.module_id
|
||||
FROM
|
||||
lms_user_module_progress AS ump
|
||||
lms_practices AS lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
AND upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
WHERE
|
||||
ump.user_id = $1
|
||||
lp.module_id IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
GROUP BY
|
||||
lp.module_id
|
||||
HAVING
|
||||
count(DISTINCT lp.question_set_id) > 0
|
||||
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
|
||||
ORDER BY
|
||||
ump.completed_at ASC,
|
||||
ump.module_id ASC
|
||||
max(upp.completed_at) ASC,
|
||||
lp.module_id ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
|
||||
func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID int64) ([]pgtype.Int8, error) {
|
||||
rows, err := q.db.Query(ctx, ListLMSCompletedModuleIDsByUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []int64
|
||||
var items []pgtype.Int8
|
||||
for rows.Next() {
|
||||
var module_id int64
|
||||
var module_id pgtype.Int8
|
||||
if err := rows.Scan(&module_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -687,14 +771,26 @@ func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID in
|
|||
|
||||
const ListLMSCompletedProgramIDsByUser = `-- name: ListLMSCompletedProgramIDsByUser :many
|
||||
SELECT
|
||||
upp.program_id
|
||||
c.program_id
|
||||
FROM
|
||||
lms_user_program_progress AS upp
|
||||
lms_practices AS lp
|
||||
INNER JOIN courses c ON c.id = lp.course_id
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
AND upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
AND lp.publish_status = 'PUBLISHED'
|
||||
GROUP BY
|
||||
c.program_id
|
||||
HAVING
|
||||
count(DISTINCT lp.question_set_id) > 0
|
||||
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
|
||||
ORDER BY
|
||||
upp.completed_at ASC,
|
||||
upp.program_id ASC
|
||||
max(upp.completed_at) ASC,
|
||||
c.program_id ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListLMSCompletedProgramIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
|
||||
|
|
|
|||
|
|
@ -214,6 +214,20 @@ type LmsUserProgramProgress struct {
|
|||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
}
|
||||
|
||||
type MobileAppVersion struct {
|
||||
ID int64 `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
VersionName string `json:"version_name"`
|
||||
VersionCode int32 `json:"version_code"`
|
||||
UpdateType string `json:"update_type"`
|
||||
ReleaseNotes pgtype.Text `json:"release_notes"`
|
||||
StoreUrl pgtype.Text `json:"store_url"`
|
||||
MinSupportedVersionCode pgtype.Int4 `json:"min_supported_version_code"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
ID int64 `json:"id"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ package domain
|
|||
// LMSEntityAccess describes learner gating for a program, course, module, or lesson.
|
||||
// Included for STUDENT and OPEN_LEARNER; omitted (nil) for staff roles in API responses.
|
||||
// OPEN_LEARNER always has is_accessible true; STUDENT may be false when prerequisites are unmet.
|
||||
// Progress fields count completed lessons vs total lessons in that entity’s scope (lesson: 0 or 1 of 1).
|
||||
// Progress fields count completed published practices vs total published practices in the
|
||||
// entity's scope. progress_percent keeps the legacy whole-number value; use
|
||||
// progress_percent_precise for decimal precision in learner UIs.
|
||||
type LMSEntityAccess struct {
|
||||
IsAccessible bool `json:"is_accessible"`
|
||||
IsCompleted bool `json:"is_completed"`
|
||||
|
|
@ -11,10 +13,11 @@ type LMSEntityAccess struct {
|
|||
CompletedCount int `json:"completed_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
ProgressPercent int `json:"progress_percent"`
|
||||
ProgressPercentPrecise float64 `json:"progress_percent_precise"`
|
||||
}
|
||||
|
||||
// LMSUserProgress lists entity IDs the authenticated user has fully completed
|
||||
// (lessons as marked complete; module/course/program when rollup conditions were met).
|
||||
// LMSUserProgress lists entity IDs the authenticated user has fully completed based on
|
||||
// published practice completion in each LMS scope.
|
||||
type LMSUserProgress struct {
|
||||
LessonIDs []int64 `json:"lesson_ids"`
|
||||
ModuleIDs []int64 `json:"module_ids"`
|
||||
|
|
|
|||
|
|
@ -38,89 +38,67 @@ func (s *Store) LmsUserHasLessonProgress(ctx context.Context, userID, lessonID i
|
|||
return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID})
|
||||
}
|
||||
|
||||
// LmsUserLessonProgressInModule returns combined completed/total counts for lessons + published practices in a module.
|
||||
func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) {
|
||||
lessonTotal, err := s.queries.CountLessonsInModule(ctx, moduleID)
|
||||
// LmsUserPracticeProgressInLesson returns published practice completion counts scoped to a lesson.
|
||||
func (s *Store) LmsUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) {
|
||||
lessonIDPG := toPgInt8(&lessonID)
|
||||
total, err = s.queries.CountPublishedPracticesInLesson(ctx, lessonIDPG)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
lessonCompleted, err := s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
|
||||
ModuleID: moduleID,
|
||||
completed, err = s.queries.CountUserCompletedPublishedPracticesInLesson(ctx, dbgen.CountUserCompletedPublishedPracticesInLessonParams{
|
||||
LessonID: lessonIDPG,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
practiceTotal, err := s.queries.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
|
||||
return completed, total, nil
|
||||
}
|
||||
|
||||
// LmsUserLessonProgressInModule returns published practice completion counts in a module.
|
||||
func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) {
|
||||
total, err = s.queries.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
|
||||
completed, err = s.queries.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
|
||||
ModuleID: toPgInt8(&moduleID),
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
total = lessonTotal + practiceTotal
|
||||
completed = lessonCompleted + practiceCompleted
|
||||
return completed, total, nil
|
||||
}
|
||||
|
||||
// LmsUserLessonProgressInCourse returns combined completed/total counts for lessons + published practices in a course.
|
||||
// LmsUserLessonProgressInCourse returns published practice completion counts in a course.
|
||||
func (s *Store) LmsUserLessonProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) {
|
||||
lessonTotal, err := s.queries.CountLessonsInCourse(ctx, courseID)
|
||||
total, err = s.queries.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
lessonCompleted, err := s.queries.CountUserCompletedLessonsInCourse(ctx, dbgen.CountUserCompletedLessonsInCourseParams{
|
||||
CourseID: courseID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
practiceTotal, err := s.queries.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{
|
||||
completed, err = s.queries.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{
|
||||
CourseID: toPgInt8(&courseID),
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
total = lessonTotal + practiceTotal
|
||||
completed = lessonCompleted + practiceCompleted
|
||||
return completed, total, nil
|
||||
}
|
||||
|
||||
// LmsUserLessonProgressInProgram returns combined completed/total counts for lessons + published practices in a program.
|
||||
// LmsUserLessonProgressInProgram returns published practice completion counts in a program.
|
||||
func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, programID int64) (completed, total int32, err error) {
|
||||
lessonTotal, err := s.queries.CountLessonsInProgram(ctx, programID)
|
||||
total, err = s.queries.CountPublishedPracticesInProgram(ctx, programID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
lessonCompleted, err := s.queries.CountUserCompletedLessonsInProgram(ctx, dbgen.CountUserCompletedLessonsInProgramParams{
|
||||
completed, err = s.queries.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{
|
||||
ProgramID: programID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
practiceTotal, err := s.queries.CountPublishedPracticesInProgram(ctx, programID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{
|
||||
ProgramID: programID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
total = lessonTotal + practiceTotal
|
||||
completed = lessonCompleted + practiceCompleted
|
||||
return completed, total, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import (
|
|||
dbgen "Yimaru-Backend/gen/db"
|
||||
)
|
||||
|
||||
// CompleteLessonForUser records lesson completion and cascades completion upward when
|
||||
// both lesson and related practice requirements are satisfied.
|
||||
// CompleteLessonForUser records lesson completion for sequential lesson gating and
|
||||
// re-evaluates higher-level practice-based rollups.
|
||||
func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error {
|
||||
q, tx, err := s.BeginTx(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -42,8 +42,8 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6
|
|||
return nil
|
||||
}
|
||||
|
||||
// CompletePracticeForUser records practice completion and cascades completion upward when
|
||||
// both lesson and related practice requirements are satisfied.
|
||||
// CompletePracticeForUser records practice completion and cascades practice-based
|
||||
// completion upward when all published practices in scope are complete.
|
||||
func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error {
|
||||
q, tx, err := s.BeginTx(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -110,17 +110,6 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
|
|||
|
||||
func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID int64, moduleID *int64, courseID, programID int64) error {
|
||||
if moduleID != nil {
|
||||
moduleLessonsTotal, err := q.CountLessonsInModule(ctx, *moduleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
moduleLessonsDone, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
|
||||
ModuleID: *moduleID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(moduleID))
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -133,9 +122,8 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user
|
|||
return err
|
||||
}
|
||||
|
||||
moduleLessonsComplete := moduleLessonsTotal > 0 && moduleLessonsDone >= moduleLessonsTotal
|
||||
modulePracticesComplete := modulePracticesDone >= modulePracticesTotal
|
||||
if !moduleLessonsComplete || !modulePracticesComplete {
|
||||
modulePracticesComplete := modulePracticesTotal > 0 && modulePracticesDone >= modulePracticesTotal
|
||||
if !modulePracticesComplete {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +157,7 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user
|
|||
}
|
||||
|
||||
courseModulesComplete := nMods > 0 && nDoneMods >= nMods
|
||||
coursePracticesComplete := coursePracticesDone >= coursePracticesTotal
|
||||
coursePracticesComplete := coursePracticesTotal > 0 && coursePracticesDone >= coursePracticesTotal
|
||||
if !courseModulesComplete || !coursePracticesComplete {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -203,7 +191,7 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user
|
|||
}
|
||||
|
||||
programCoursesComplete := nCr > 0 && nCrDone >= nCr
|
||||
programPracticesComplete := programPracticesDone >= programPracticesTotal
|
||||
programPracticesComplete := programPracticesTotal > 0 && programPracticesDone >= programPracticesTotal
|
||||
if !programCoursesComplete || !programPracticesComplete {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import (
|
|||
|
||||
dbgen "Yimaru-Backend/gen/db"
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// GetLMSUserProgressSnapshot returns all completed lesson, module, course, and program IDs for a user.
|
||||
// GetLMSUserProgressSnapshot returns practice-based completed lesson, module, course,
|
||||
// and program IDs for a user.
|
||||
func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (domain.LMSUserProgress, error) {
|
||||
lessons, err := s.queries.ListLMSCompletedLessonIDsByUser(ctx, userID)
|
||||
if err != nil {
|
||||
|
|
@ -26,13 +28,24 @@ func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (d
|
|||
return domain.LMSUserProgress{}, err
|
||||
}
|
||||
return domain.LMSUserProgress{
|
||||
LessonIDs: lessons,
|
||||
ModuleIDs: mods,
|
||||
CourseIDs: courses,
|
||||
LessonIDs: pgInt8IDsToInt64(lessons),
|
||||
ModuleIDs: pgInt8IDsToInt64(mods),
|
||||
CourseIDs: pgInt8IDsToInt64(courses),
|
||||
ProgramIDs: programs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pgInt8IDsToInt64(items []pgtype.Int8) []int64 {
|
||||
out := make([]int64, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !item.Valid {
|
||||
continue
|
||||
}
|
||||
out = append(out, item.Int64)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListUserLMSFlatLearningActivity returns flattened LMS activity rows for admin reporting (lesson + practice completions).
|
||||
func (s *Store) ListUserLMSFlatLearningActivity(ctx context.Context, userID int64) ([]dbgen.ListUserLMSFlatLearningActivityByUserRow, error) {
|
||||
return s.queries.ListUserLMSFlatLearningActivityByUser(ctx, userID)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package lmsprogress
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"Yimaru-Backend/internal/repository"
|
||||
|
|
@ -151,14 +152,11 @@ func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, user
|
|||
p.Access = nil
|
||||
return nil
|
||||
}
|
||||
done, err := s.store.LmsUserHasProgramProgress(ctx, userID, p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
comp, tot, err := s.store.LmsUserLessonProgressInProgram(ctx, userID, p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
done := lmsProgressComplete(comp, tot)
|
||||
ok, reason := true, ""
|
||||
if role.UsesLMSSequentialGating() {
|
||||
ok, reason, err = s.CanAccessProgram(ctx, userID, p.ID)
|
||||
|
|
@ -176,14 +174,11 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI
|
|||
c.Access = nil
|
||||
return nil
|
||||
}
|
||||
done, err := s.store.LmsUserHasCourseProgress(ctx, userID, c.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
comp, tot, err := s.store.LmsUserLessonProgressInCourse(ctx, userID, c.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
done := lmsProgressComplete(comp, tot)
|
||||
ok, reason := true, ""
|
||||
if role.UsesLMSSequentialGating() {
|
||||
ok, reason, err = s.CanAccessCourse(ctx, userID, c.ID)
|
||||
|
|
@ -201,14 +196,11 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI
|
|||
m.Access = nil
|
||||
return nil
|
||||
}
|
||||
done, err := s.store.LmsUserHasModuleProgress(ctx, userID, m.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
comp, tot, err := s.store.LmsUserLessonProgressInModule(ctx, userID, m.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
done := lmsProgressComplete(comp, tot)
|
||||
ok, reason := true, ""
|
||||
if role.UsesLMSSequentialGating() {
|
||||
ok, reason, err = s.CanAccessModule(ctx, userID, m.ID)
|
||||
|
|
@ -226,16 +218,11 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI
|
|||
les.Access = nil
|
||||
return nil
|
||||
}
|
||||
done, err := s.store.LmsUserHasLessonProgress(ctx, userID, les.ID)
|
||||
comp, tot, err := s.store.LmsUserPracticeProgressInLesson(ctx, userID, les.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var comp, tot int32
|
||||
if done {
|
||||
comp, tot = 1, 1
|
||||
} else {
|
||||
comp, tot = 0, 1
|
||||
}
|
||||
done := lmsProgressComplete(comp, tot)
|
||||
ok, reason := true, ""
|
||||
if role.UsesLMSSequentialGating() {
|
||||
ok, reason, err = s.CanAccessLesson(ctx, userID, les.ID)
|
||||
|
|
@ -247,8 +234,12 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI
|
|||
return nil
|
||||
}
|
||||
|
||||
func lmsProgressComplete(completed, total int32) bool {
|
||||
return total > 0 && completed >= total
|
||||
}
|
||||
|
||||
func buildLMSEntityAccess(ok bool, reason string, done bool, completed, total int32) *domain.LMSEntityAccess {
|
||||
c, t, pct := lmsProgressCounts(completed, total, done)
|
||||
c, t, pct, pctPrecise := lmsProgressCounts(completed, total, done)
|
||||
return &domain.LMSEntityAccess{
|
||||
IsAccessible: ok,
|
||||
IsCompleted: done,
|
||||
|
|
@ -256,12 +247,13 @@ func buildLMSEntityAccess(ok bool, reason string, done bool, completed, total in
|
|||
CompletedCount: c,
|
||||
TotalCount: t,
|
||||
ProgressPercent: pct,
|
||||
ProgressPercentPrecise: pctPrecise,
|
||||
}
|
||||
}
|
||||
|
||||
// lmsProgressCounts maps DB lesson completion counts to UI fields. Percent is 0–100; completed
|
||||
// and total are aligned with isCompleted when the entity is fully done.
|
||||
func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int) {
|
||||
func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int, pctPrecise float64) {
|
||||
c, t = int(completed), int(total)
|
||||
if t < 0 {
|
||||
t = 0
|
||||
|
|
@ -271,18 +263,22 @@ func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int)
|
|||
}
|
||||
if isCompleted {
|
||||
if t > 0 {
|
||||
return t, t, 100
|
||||
return t, t, 100, 100
|
||||
}
|
||||
return c, t, 100
|
||||
return c, t, 100, 100
|
||||
}
|
||||
if t == 0 {
|
||||
return 0, 0, 0
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
pct = (c * 100) / t
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
return c, t, pct
|
||||
pctPrecise = math.Round((float64(c)*10000)/float64(t)) / 100
|
||||
if pctPrecise > 100 {
|
||||
pctPrecise = 100
|
||||
}
|
||||
return c, t, pct, pctPrecise
|
||||
}
|
||||
|
||||
func reasonIf(ok bool, r string) string {
|
||||
|
|
|
|||
97
internal/services/lmsprogress/service_test.go
Normal file
97
internal/services/lmsprogress/service_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package lmsprogress
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLMSProgressCounts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
completed int32
|
||||
total int32
|
||||
isCompleted bool
|
||||
wantCompleted int
|
||||
wantTotal int
|
||||
wantPercent int
|
||||
wantPercentFloat float64
|
||||
}{
|
||||
{
|
||||
name: "fractional progress rounds to two decimals",
|
||||
completed: 1,
|
||||
total: 3,
|
||||
wantCompleted: 1,
|
||||
wantTotal: 3,
|
||||
wantPercent: 33,
|
||||
wantPercentFloat: 33.33,
|
||||
},
|
||||
{
|
||||
name: "larger fraction rounds precisely",
|
||||
completed: 2,
|
||||
total: 3,
|
||||
wantCompleted: 2,
|
||||
wantTotal: 3,
|
||||
wantPercent: 66,
|
||||
wantPercentFloat: 66.67,
|
||||
},
|
||||
{
|
||||
name: "completed forces full progress",
|
||||
completed: 2,
|
||||
total: 3,
|
||||
isCompleted: true,
|
||||
wantCompleted: 3,
|
||||
wantTotal: 3,
|
||||
wantPercent: 100,
|
||||
wantPercentFloat: 100,
|
||||
},
|
||||
{
|
||||
name: "empty scope stays zeroed",
|
||||
wantCompleted: 0,
|
||||
wantTotal: 0,
|
||||
wantPercent: 0,
|
||||
wantPercentFloat: 0,
|
||||
},
|
||||
{
|
||||
name: "negative counts are sanitized",
|
||||
completed: -2,
|
||||
total: -5,
|
||||
wantCompleted: 0,
|
||||
wantTotal: 0,
|
||||
wantPercent: 0,
|
||||
wantPercentFloat: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotCompleted, gotTotal, gotPercent, gotPercentFloat := lmsProgressCounts(tt.completed, tt.total, tt.isCompleted)
|
||||
if gotCompleted != tt.wantCompleted || gotTotal != tt.wantTotal {
|
||||
t.Fatalf("counts=(%d,%d), want (%d,%d)", gotCompleted, gotTotal, tt.wantCompleted, tt.wantTotal)
|
||||
}
|
||||
if gotPercent != tt.wantPercent {
|
||||
t.Fatalf("progress_percent=%d, want %d", gotPercent, tt.wantPercent)
|
||||
}
|
||||
if gotPercentFloat != tt.wantPercentFloat {
|
||||
t.Fatalf("progress_percent_precise=%v, want %v", gotPercentFloat, tt.wantPercentFloat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLMSProgressComplete(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
completed int32
|
||||
total int32
|
||||
want bool
|
||||
}{
|
||||
{name: "complete when all practices done", completed: 3, total: 3, want: true},
|
||||
{name: "incomplete when practices remain", completed: 2, total: 3, want: false},
|
||||
{name: "zero total is not completed", completed: 0, total: 0, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := lmsProgressComplete(tt.completed, tt.total); got != tt.want {
|
||||
t.Fatalf("lmsProgressComplete(%d, %d)=%v, want %v", tt.completed, tt.total, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ import (
|
|||
|
||||
// GetMyLMSProgress godoc
|
||||
// @Summary Get my LMS completion history
|
||||
// @Description Returns completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id).
|
||||
// @Description Returns practice-based completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id).
|
||||
// @Tags lms
|
||||
// @Produce json
|
||||
// @Success 200 {object} domain.Response
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user