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:
Yared Yemane 2026-05-26 03:27:54 -07:00
parent a719c0daca
commit afdd07d65d
13 changed files with 4816 additions and 200 deletions

View File

@ -117,6 +117,33 @@ INSERT INTO lms_user_program_progress (user_id, program_id)
ON CONFLICT (user_id, program_id) ON CONFLICT (user_id, program_id)
DO NOTHING; 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 -- name: CountLessonsInModule :one
SELECT SELECT
count(*)::int AS n count(*)::int AS n
@ -175,47 +202,95 @@ WHERE
-- name: ListLMSCompletedLessonIDsByUser :many -- name: ListLMSCompletedLessonIDsByUser :many
SELECT SELECT
ulp.lesson_id lp.lesson_id
FROM 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 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 ORDER BY
ulp.completed_at ASC, max(upp.completed_at) ASC,
ulp.lesson_id ASC; lp.lesson_id ASC;
-- name: ListLMSCompletedModuleIDsByUser :many -- name: ListLMSCompletedModuleIDsByUser :many
SELECT SELECT
ump.module_id lp.module_id
FROM 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 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 ORDER BY
ump.completed_at ASC, max(upp.completed_at) ASC,
ump.module_id ASC; lp.module_id ASC;
-- name: ListLMSCompletedCourseIDsByUser :many -- name: ListLMSCompletedCourseIDsByUser :many
SELECT SELECT
ucp.course_id lp.course_id
FROM 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 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 ORDER BY
ucp.completed_at ASC, max(upp.completed_at) ASC,
ucp.course_id ASC; lp.course_id ASC;
-- name: ListLMSCompletedProgramIDsByUser :many -- name: ListLMSCompletedProgramIDsByUser :many
SELECT SELECT
upp.program_id c.program_id
FROM 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 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 ORDER BY
upp.completed_at ASC, max(upp.completed_at) ASC,
upp.program_id ASC; c.program_id ASC;
-- Lesson-based progress within a course (all modules). -- Lesson-based progress within a course (all modules).
-- name: CountLessonsInCourse :one -- name: CountLessonsInCourse :one

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -119,6 +119,26 @@ func (q *Queries) CountPublishedPracticesInCourse(ctx context.Context, courseID
return n, err 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 const CountPublishedPracticesInModule = `-- name: CountPublishedPracticesInModule :one
SELECT SELECT
count(*)::int AS n count(*)::int AS n
@ -309,6 +329,34 @@ func (q *Queries) CountUserCompletedPublishedPracticesInCourse(ctx context.Conte
return n, err 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 const CountUserCompletedPublishedPracticesInModule = `-- name: CountUserCompletedPublishedPracticesInModule :one
SELECT SELECT
count(*)::int AS n count(*)::int AS n
@ -591,25 +639,37 @@ func (q *Queries) InsertUserProgramProgress(ctx context.Context, arg InsertUserP
const ListLMSCompletedCourseIDsByUser = `-- name: ListLMSCompletedCourseIDsByUser :many const ListLMSCompletedCourseIDsByUser = `-- name: ListLMSCompletedCourseIDsByUser :many
SELECT SELECT
ucp.course_id lp.course_id
FROM 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 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 ORDER BY
ucp.completed_at ASC, max(upp.completed_at) ASC,
ucp.course_id 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) rows, err := q.db.Query(ctx, ListLMSCompletedCourseIDsByUser, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []int64 var items []pgtype.Int8
for rows.Next() { for rows.Next() {
var course_id int64 var course_id pgtype.Int8
if err := rows.Scan(&course_id); err != nil { if err := rows.Scan(&course_id); err != nil {
return nil, err return nil, err
} }
@ -623,25 +683,37 @@ func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID in
const ListLMSCompletedLessonIDsByUser = `-- name: ListLMSCompletedLessonIDsByUser :many const ListLMSCompletedLessonIDsByUser = `-- name: ListLMSCompletedLessonIDsByUser :many
SELECT SELECT
ulp.lesson_id lp.lesson_id
FROM 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 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 ORDER BY
ulp.completed_at ASC, max(upp.completed_at) ASC,
ulp.lesson_id 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) rows, err := q.db.Query(ctx, ListLMSCompletedLessonIDsByUser, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []int64 var items []pgtype.Int8
for rows.Next() { for rows.Next() {
var lesson_id int64 var lesson_id pgtype.Int8
if err := rows.Scan(&lesson_id); err != nil { if err := rows.Scan(&lesson_id); err != nil {
return nil, err return nil, err
} }
@ -655,25 +727,37 @@ func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID in
const ListLMSCompletedModuleIDsByUser = `-- name: ListLMSCompletedModuleIDsByUser :many const ListLMSCompletedModuleIDsByUser = `-- name: ListLMSCompletedModuleIDsByUser :many
SELECT SELECT
ump.module_id lp.module_id
FROM 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 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 ORDER BY
ump.completed_at ASC, max(upp.completed_at) ASC,
ump.module_id 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) rows, err := q.db.Query(ctx, ListLMSCompletedModuleIDsByUser, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []int64 var items []pgtype.Int8
for rows.Next() { for rows.Next() {
var module_id int64 var module_id pgtype.Int8
if err := rows.Scan(&module_id); err != nil { if err := rows.Scan(&module_id); err != nil {
return nil, err return nil, err
} }
@ -687,14 +771,26 @@ func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID in
const ListLMSCompletedProgramIDsByUser = `-- name: ListLMSCompletedProgramIDsByUser :many const ListLMSCompletedProgramIDsByUser = `-- name: ListLMSCompletedProgramIDsByUser :many
SELECT SELECT
upp.program_id c.program_id
FROM 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 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 ORDER BY
upp.completed_at ASC, max(upp.completed_at) ASC,
upp.program_id ASC c.program_id ASC
` `
func (q *Queries) ListLMSCompletedProgramIDsByUser(ctx context.Context, userID int64) ([]int64, error) { func (q *Queries) ListLMSCompletedProgramIDsByUser(ctx context.Context, userID int64) ([]int64, error) {

View File

@ -214,6 +214,20 @@ type LmsUserProgramProgress struct {
CompletedAt pgtype.Timestamptz `json:"completed_at"` 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 { type Module struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ProgramID int64 `json:"program_id"` ProgramID int64 `json:"program_id"`

View File

@ -3,7 +3,9 @@ package domain
// LMSEntityAccess describes learner gating for a program, course, module, or lesson. // 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. // 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. // 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 entitys 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 { type LMSEntityAccess struct {
IsAccessible bool `json:"is_accessible"` IsAccessible bool `json:"is_accessible"`
IsCompleted bool `json:"is_completed"` IsCompleted bool `json:"is_completed"`
@ -11,10 +13,11 @@ type LMSEntityAccess struct {
CompletedCount int `json:"completed_count"` CompletedCount int `json:"completed_count"`
TotalCount int `json:"total_count"` TotalCount int `json:"total_count"`
ProgressPercent int `json:"progress_percent"` ProgressPercent int `json:"progress_percent"`
ProgressPercentPrecise float64 `json:"progress_percent_precise"`
} }
// LMSUserProgress lists entity IDs the authenticated user has fully completed // LMSUserProgress lists entity IDs the authenticated user has fully completed based on
// (lessons as marked complete; module/course/program when rollup conditions were met). // published practice completion in each LMS scope.
type LMSUserProgress struct { type LMSUserProgress struct {
LessonIDs []int64 `json:"lesson_ids"` LessonIDs []int64 `json:"lesson_ids"`
ModuleIDs []int64 `json:"module_ids"` ModuleIDs []int64 `json:"module_ids"`

View File

@ -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}) return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID})
} }
// LmsUserLessonProgressInModule returns combined completed/total counts for lessons + published practices in a module. // LmsUserPracticeProgressInLesson returns published practice completion counts scoped to a lesson.
func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) { func (s *Store) LmsUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) {
lessonTotal, err := s.queries.CountLessonsInModule(ctx, moduleID) lessonIDPG := toPgInt8(&lessonID)
total, err = s.queries.CountPublishedPracticesInLesson(ctx, lessonIDPG)
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
lessonCompleted, err := s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{ completed, err = s.queries.CountUserCompletedPublishedPracticesInLesson(ctx, dbgen.CountUserCompletedPublishedPracticesInLessonParams{
ModuleID: moduleID, LessonID: lessonIDPG,
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return 0, 0, err 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 { if err != nil {
return 0, 0, err return 0, 0, err
} }
practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{ completed, err = s.queries.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
ModuleID: toPgInt8(&moduleID), ModuleID: toPgInt8(&moduleID),
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
total = lessonTotal + practiceTotal
completed = lessonCompleted + practiceCompleted
return completed, total, nil 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) { 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 { if err != nil {
return 0, 0, err return 0, 0, err
} }
lessonCompleted, err := s.queries.CountUserCompletedLessonsInCourse(ctx, dbgen.CountUserCompletedLessonsInCourseParams{ completed, err = s.queries.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{
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{
CourseID: toPgInt8(&courseID), CourseID: toPgInt8(&courseID),
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
total = lessonTotal + practiceTotal
completed = lessonCompleted + practiceCompleted
return completed, total, nil 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) { 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 { if err != nil {
return 0, 0, err return 0, 0, err
} }
lessonCompleted, err := s.queries.CountUserCompletedLessonsInProgram(ctx, dbgen.CountUserCompletedLessonsInProgramParams{ completed, err = s.queries.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{
ProgramID: programID, ProgramID: programID,
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return 0, 0, err 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 return completed, total, nil
} }

View File

@ -7,8 +7,8 @@ import (
dbgen "Yimaru-Backend/gen/db" dbgen "Yimaru-Backend/gen/db"
) )
// CompleteLessonForUser records lesson completion and cascades completion upward when // CompleteLessonForUser records lesson completion for sequential lesson gating and
// both lesson and related practice requirements are satisfied. // re-evaluates higher-level practice-based rollups.
func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error { func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error {
q, tx, err := s.BeginTx(ctx) q, tx, err := s.BeginTx(ctx)
if err != nil { if err != nil {
@ -42,8 +42,8 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6
return nil return nil
} }
// CompletePracticeForUser records practice completion and cascades completion upward when // CompletePracticeForUser records practice completion and cascades practice-based
// both lesson and related practice requirements are satisfied. // completion upward when all published practices in scope are complete.
func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error { func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error {
q, tx, err := s.BeginTx(ctx) q, tx, err := s.BeginTx(ctx)
if err != nil { 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 { func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID int64, moduleID *int64, courseID, programID int64) error {
if moduleID != nil { 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)) modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(moduleID))
if err != nil { if err != nil {
return err return err
@ -133,9 +122,8 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user
return err return err
} }
moduleLessonsComplete := moduleLessonsTotal > 0 && moduleLessonsDone >= moduleLessonsTotal modulePracticesComplete := modulePracticesTotal > 0 && modulePracticesDone >= modulePracticesTotal
modulePracticesComplete := modulePracticesDone >= modulePracticesTotal if !modulePracticesComplete {
if !moduleLessonsComplete || !modulePracticesComplete {
return nil return nil
} }
@ -169,7 +157,7 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user
} }
courseModulesComplete := nMods > 0 && nDoneMods >= nMods courseModulesComplete := nMods > 0 && nDoneMods >= nMods
coursePracticesComplete := coursePracticesDone >= coursePracticesTotal coursePracticesComplete := coursePracticesTotal > 0 && coursePracticesDone >= coursePracticesTotal
if !courseModulesComplete || !coursePracticesComplete { if !courseModulesComplete || !coursePracticesComplete {
return nil return nil
} }
@ -203,7 +191,7 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user
} }
programCoursesComplete := nCr > 0 && nCrDone >= nCr programCoursesComplete := nCr > 0 && nCrDone >= nCr
programPracticesComplete := programPracticesDone >= programPracticesTotal programPracticesComplete := programPracticesTotal > 0 && programPracticesDone >= programPracticesTotal
if !programCoursesComplete || !programPracticesComplete { if !programCoursesComplete || !programPracticesComplete {
return nil return nil
} }

View File

@ -5,9 +5,11 @@ import (
dbgen "Yimaru-Backend/gen/db" dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain" "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) { func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (domain.LMSUserProgress, error) {
lessons, err := s.queries.ListLMSCompletedLessonIDsByUser(ctx, userID) lessons, err := s.queries.ListLMSCompletedLessonIDsByUser(ctx, userID)
if err != nil { if err != nil {
@ -26,13 +28,24 @@ func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (d
return domain.LMSUserProgress{}, err return domain.LMSUserProgress{}, err
} }
return domain.LMSUserProgress{ return domain.LMSUserProgress{
LessonIDs: lessons, LessonIDs: pgInt8IDsToInt64(lessons),
ModuleIDs: mods, ModuleIDs: pgInt8IDsToInt64(mods),
CourseIDs: courses, CourseIDs: pgInt8IDsToInt64(courses),
ProgramIDs: programs, ProgramIDs: programs,
}, nil }, 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). // ListUserLMSFlatLearningActivity returns flattened LMS activity rows for admin reporting (lesson + practice completions).
func (s *Store) ListUserLMSFlatLearningActivity(ctx context.Context, userID int64) ([]dbgen.ListUserLMSFlatLearningActivityByUserRow, error) { func (s *Store) ListUserLMSFlatLearningActivity(ctx context.Context, userID int64) ([]dbgen.ListUserLMSFlatLearningActivityByUserRow, error) {
return s.queries.ListUserLMSFlatLearningActivityByUser(ctx, userID) return s.queries.ListUserLMSFlatLearningActivityByUser(ctx, userID)

View File

@ -3,6 +3,7 @@ package lmsprogress
import ( import (
"context" "context"
"errors" "errors"
"math"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/repository" "Yimaru-Backend/internal/repository"
@ -151,14 +152,11 @@ func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, user
p.Access = nil p.Access = nil
return 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) comp, tot, err := s.store.LmsUserLessonProgressInProgram(ctx, userID, p.ID)
if err != nil { if err != nil {
return err return err
} }
done := lmsProgressComplete(comp, tot)
ok, reason := true, "" ok, reason := true, ""
if role.UsesLMSSequentialGating() { if role.UsesLMSSequentialGating() {
ok, reason, err = s.CanAccessProgram(ctx, userID, p.ID) 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 c.Access = nil
return 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) comp, tot, err := s.store.LmsUserLessonProgressInCourse(ctx, userID, c.ID)
if err != nil { if err != nil {
return err return err
} }
done := lmsProgressComplete(comp, tot)
ok, reason := true, "" ok, reason := true, ""
if role.UsesLMSSequentialGating() { if role.UsesLMSSequentialGating() {
ok, reason, err = s.CanAccessCourse(ctx, userID, c.ID) 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 m.Access = nil
return 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) comp, tot, err := s.store.LmsUserLessonProgressInModule(ctx, userID, m.ID)
if err != nil { if err != nil {
return err return err
} }
done := lmsProgressComplete(comp, tot)
ok, reason := true, "" ok, reason := true, ""
if role.UsesLMSSequentialGating() { if role.UsesLMSSequentialGating() {
ok, reason, err = s.CanAccessModule(ctx, userID, m.ID) 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 les.Access = nil
return 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 { if err != nil {
return err return err
} }
var comp, tot int32 done := lmsProgressComplete(comp, tot)
if done {
comp, tot = 1, 1
} else {
comp, tot = 0, 1
}
ok, reason := true, "" ok, reason := true, ""
if role.UsesLMSSequentialGating() { if role.UsesLMSSequentialGating() {
ok, reason, err = s.CanAccessLesson(ctx, userID, les.ID) 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 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 { 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{ return &domain.LMSEntityAccess{
IsAccessible: ok, IsAccessible: ok,
IsCompleted: done, IsCompleted: done,
@ -256,12 +247,13 @@ func buildLMSEntityAccess(ok bool, reason string, done bool, completed, total in
CompletedCount: c, CompletedCount: c,
TotalCount: t, TotalCount: t,
ProgressPercent: pct, ProgressPercent: pct,
ProgressPercentPrecise: pctPrecise,
} }
} }
// lmsProgressCounts maps DB lesson completion counts to UI fields. Percent is 0100; completed // lmsProgressCounts maps DB lesson completion counts to UI fields. Percent is 0100; completed
// and total are aligned with isCompleted when the entity is fully done. // 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) c, t = int(completed), int(total)
if t < 0 { if t < 0 {
t = 0 t = 0
@ -271,18 +263,22 @@ func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int)
} }
if isCompleted { if isCompleted {
if t > 0 { if t > 0 {
return t, t, 100 return t, t, 100, 100
} }
return c, t, 100 return c, t, 100, 100
} }
if t == 0 { if t == 0 {
return 0, 0, 0 return 0, 0, 0, 0
} }
pct = (c * 100) / t pct = (c * 100) / t
if pct > 100 { if pct > 100 {
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 { func reasonIf(ok bool, r string) string {

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

View File

@ -11,7 +11,7 @@ import (
// GetMyLMSProgress godoc // GetMyLMSProgress godoc
// @Summary Get my LMS completion history // @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 // @Tags lms
// @Produce json // @Produce json
// @Success 200 {object} domain.Response // @Success 200 {object} domain.Response