Fix hierarchical learner progress percentage rollups.

Compute program/course/module/lesson progress using lesson-completion rollups from completed practices, with direct module/course practice completion forcing parent completion as required.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-27 23:57:21 -07:00
parent 8eaac9206e
commit 474bf3282a
4 changed files with 441 additions and 24 deletions

View File

@ -570,6 +570,62 @@ WHERE
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED';
-- Published practices directly attached to module_id (not via lesson_id).
-- name: CountPublishedDirectPracticesInModule :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.module_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED';
-- name: CountUserCompletedPublishedDirectPracticesInModule :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.module_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';
-- Published practices directly attached to course_id (not via module_id/lesson_id).
-- name: CountPublishedDirectPracticesInCourse :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.course_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED';
-- name: CountUserCompletedPublishedDirectPracticesInCourse :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.course_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: GetPracticeScopeByQuestionSetID :one
SELECT
id,

View File

@ -99,6 +99,48 @@ func (q *Queries) CountModulesInCourse(ctx context.Context, courseID int64) (int
return n, err
}
const CountPublishedDirectPracticesInCourse = `-- name: CountPublishedDirectPracticesInCourse :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.course_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
`
// Published practices directly attached to course_id (not via module_id/lesson_id).
func (q *Queries) CountPublishedDirectPracticesInCourse(ctx context.Context, courseID pgtype.Int8) (int32, error) {
row := q.db.QueryRow(ctx, CountPublishedDirectPracticesInCourse, courseID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountPublishedDirectPracticesInModule = `-- name: CountPublishedDirectPracticesInModule :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.module_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
`
// Published practices directly attached to module_id (not via lesson_id).
func (q *Queries) CountPublishedDirectPracticesInModule(ctx context.Context, moduleID pgtype.Int8) (int32, error) {
row := q.db.QueryRow(ctx, CountPublishedDirectPracticesInModule, moduleID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountPublishedPracticesInCourse = `-- name: CountPublishedPracticesInCourse :one
SELECT
count(*)::int AS n
@ -349,6 +391,62 @@ func (q *Queries) CountUserCompletedModulesInCourse(ctx context.Context, arg Cou
return n, err
}
const CountUserCompletedPublishedDirectPracticesInCourse = `-- name: CountUserCompletedPublishedDirectPracticesInCourse :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.course_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 CountUserCompletedPublishedDirectPracticesInCourseParams struct {
CourseID pgtype.Int8 `json:"course_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedPublishedDirectPracticesInCourse(ctx context.Context, arg CountUserCompletedPublishedDirectPracticesInCourseParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedPublishedDirectPracticesInCourse, arg.CourseID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedPublishedDirectPracticesInModule = `-- name: CountUserCompletedPublishedDirectPracticesInModule :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.module_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 CountUserCompletedPublishedDirectPracticesInModuleParams struct {
ModuleID pgtype.Int8 `json:"module_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedPublishedDirectPracticesInModule(ctx context.Context, arg CountUserCompletedPublishedDirectPracticesInModuleParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedPublishedDirectPracticesInModule, arg.ModuleID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedPublishedPracticesInCourse = `-- name: CountUserCompletedPublishedPracticesInCourse :one
SELECT
count(*)::int AS n

View File

@ -102,3 +102,35 @@ func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, prog
}
return completed, total, nil
}
func (s *Store) LmsUserDirectPracticeProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) {
moduleIDPG := toPgInt8(&moduleID)
total, err = s.queries.CountPublishedDirectPracticesInModule(ctx, moduleIDPG)
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedPublishedDirectPracticesInModule(ctx, dbgen.CountUserCompletedPublishedDirectPracticesInModuleParams{
ModuleID: moduleIDPG,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
return completed, total, nil
}
func (s *Store) LmsUserDirectPracticeProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) {
courseIDPG := toPgInt8(&courseID)
total, err = s.queries.CountPublishedDirectPracticesInCourse(ctx, courseIDPG)
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedPublishedDirectPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedDirectPracticesInCourseParams{
CourseID: courseIDPG,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
return completed, total, nil
}

View File

@ -152,11 +152,10 @@ func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, user
p.Access = nil
return nil
}
comp, tot, err := s.store.LmsUserLessonProgressInProgram(ctx, userID, p.ID)
fraction, done, comp, tot, err := s.lmsProgramProgress(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)
@ -164,7 +163,7 @@ func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, user
return err
}
}
p.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
p.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)
return nil
}
@ -174,11 +173,10 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI
c.Access = nil
return nil
}
comp, tot, err := s.store.LmsUserLessonProgressInCourse(ctx, userID, c.ID)
fraction, done, comp, tot, err := s.lmsCourseProgress(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)
@ -186,7 +184,7 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI
return err
}
}
c.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
c.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)
return nil
}
@ -196,11 +194,10 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI
m.Access = nil
return nil
}
comp, tot, err := s.store.LmsUserLessonProgressInModule(ctx, userID, m.ID)
fraction, done, comp, tot, err := s.lmsModuleProgress(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)
@ -208,7 +205,7 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI
return err
}
}
m.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
m.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)
return nil
}
@ -218,11 +215,10 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI
les.Access = nil
return nil
}
comp, tot, err := s.store.LmsUserPracticeProgressInLesson(ctx, userID, les.ID)
fraction, done, comp, tot, err := s.lmsLessonProgress(ctx, userID, les.ID)
if err != nil {
return err
}
done := lmsProgressComplete(comp, tot)
ok, reason := true, ""
if role.UsesLMSSequentialGating() {
ok, reason, err = s.CanAccessLesson(ctx, userID, les.ID)
@ -230,7 +226,7 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI
return err
}
}
les.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
les.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)
return nil
}
@ -240,12 +236,11 @@ func (s *Service) ApplyExamPrepAccessCatalogCourse(ctx context.Context, role dom
cc.Access = nil
return nil
}
comp, tot, err := s.store.ExamPrepUserPracticeProgressInCatalogCourse(ctx, userID, cc.ID)
fraction, done, comp, tot, err := s.examPrepCatalogCourseProgress(ctx, userID, cc.ID)
if err != nil {
return err
}
done := lmsProgressComplete(comp, tot)
cc.Access = buildLMSEntityAccess(true, "", done, comp, tot)
cc.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)
return nil
}
@ -255,12 +250,11 @@ func (s *Service) ApplyExamPrepAccessUnit(ctx context.Context, role domain.Role,
u.Access = nil
return nil
}
comp, tot, err := s.store.ExamPrepUserPracticeProgressInUnit(ctx, userID, u.ID)
fraction, done, comp, tot, err := s.examPrepUnitProgress(ctx, userID, u.ID)
if err != nil {
return err
}
done := lmsProgressComplete(comp, tot)
u.Access = buildLMSEntityAccess(true, "", done, comp, tot)
u.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)
return nil
}
@ -270,12 +264,11 @@ func (s *Service) ApplyExamPrepAccessModule(ctx context.Context, role domain.Rol
m.Access = nil
return nil
}
comp, tot, err := s.store.ExamPrepUserPracticeProgressInModule(ctx, userID, m.ID)
fraction, done, comp, tot, err := s.examPrepModuleProgress(ctx, userID, m.ID)
if err != nil {
return err
}
done := lmsProgressComplete(comp, tot)
m.Access = buildLMSEntityAccess(true, "", done, comp, tot)
m.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)
return nil
}
@ -285,15 +278,218 @@ func (s *Service) ApplyExamPrepAccessLesson(ctx context.Context, role domain.Rol
les.Access = nil
return nil
}
comp, tot, err := s.store.ExamPrepUserPracticeProgressInLesson(ctx, userID, les.ID)
fraction, done, comp, tot, err := s.examPrepLessonProgress(ctx, userID, les.ID)
if err != nil {
return err
}
done := lmsProgressComplete(comp, tot)
les.Access = buildLMSEntityAccess(true, "", done, comp, tot)
les.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)
return nil
}
func (s *Service) lmsLessonProgress(ctx context.Context, userID, lessonID int64) (fraction float64, done bool, completed, total int32, err error) {
completed, total, err = s.store.LmsUserPracticeProgressInLesson(ctx, userID, lessonID)
if err != nil {
return 0, false, 0, 0, err
}
// Lesson is complete once any published practice in that lesson is completed.
if total > 0 && completed > 0 {
return 1, true, 1, 1, nil
}
return 0, false, 0, 1, nil
}
func (s *Service) lmsModuleProgress(ctx context.Context, userID, moduleID int64) (fraction float64, done bool, completed, total int32, err error) {
directDone, directErr := s.hasCompletedDirectModulePractice(ctx, userID, moduleID)
if directErr != nil {
return 0, false, 0, 0, directErr
}
if directDone {
return 1, true, 1, 1, nil
}
lessons, _, err := s.store.ListLessonsByModuleID(ctx, moduleID, true, 10000, 0)
if err != nil {
return 0, false, 0, 0, err
}
if len(lessons) == 0 {
return 0, false, 0, 0, nil
}
var doneLessons int32
for _, lesson := range lessons {
lessonFraction, _, _, _, err := s.lmsLessonProgress(ctx, userID, lesson.ID)
if err != nil {
return 0, false, 0, 0, err
}
if lessonFraction >= 1 {
doneLessons++
}
}
total = int32(len(lessons))
fraction = float64(doneLessons) / float64(total)
return fraction, fraction >= 1, doneLessons, total, nil
}
func (s *Service) lmsCourseProgress(ctx context.Context, userID, courseID int64) (fraction float64, done bool, completed, total int32, err error) {
directDone, directErr := s.hasCompletedDirectCoursePractice(ctx, userID, courseID)
if directErr != nil {
return 0, false, 0, 0, directErr
}
if directDone {
return 1, true, 1, 1, nil
}
moduleIDs, err := s.store.ListModuleIDsByCourse(ctx, courseID)
if err != nil {
return 0, false, 0, 0, err
}
if len(moduleIDs) == 0 {
return 0, false, 0, 0, nil
}
var sum float64
var fullDone int32
for _, moduleID := range moduleIDs {
moduleFraction, _, _, _, err := s.lmsModuleProgress(ctx, userID, moduleID)
if err != nil {
return 0, false, 0, 0, err
}
sum += moduleFraction
if moduleFraction >= 1 {
fullDone++
}
}
total = int32(len(moduleIDs))
fraction = sum / float64(total)
return fraction, fraction >= 1, fullDone, total, nil
}
func (s *Service) lmsProgramProgress(ctx context.Context, userID, programID int64) (fraction float64, done bool, completed, total int32, err error) {
courseIDs, err := s.store.ListCourseIDsByProgram(ctx, programID)
if err != nil {
return 0, false, 0, 0, err
}
if len(courseIDs) == 0 {
return 0, false, 0, 0, nil
}
var sum float64
var fullDone int32
for _, courseID := range courseIDs {
courseFraction, _, _, _, err := s.lmsCourseProgress(ctx, userID, courseID)
if err != nil {
return 0, false, 0, 0, err
}
sum += courseFraction
if courseFraction >= 1 {
fullDone++
}
}
total = int32(len(courseIDs))
fraction = sum / float64(total)
return fraction, fraction >= 1, fullDone, total, nil
}
func (s *Service) hasCompletedDirectModulePractice(ctx context.Context, userID, moduleID int64) (bool, error) {
completed, total, err := s.store.LmsUserDirectPracticeProgressInModule(ctx, userID, moduleID)
if err != nil {
return false, err
}
return total > 0 && completed > 0, nil
}
func (s *Service) hasCompletedDirectCoursePractice(ctx context.Context, userID, courseID int64) (bool, error) {
completed, total, err := s.store.LmsUserDirectPracticeProgressInCourse(ctx, userID, courseID)
if err != nil {
return false, err
}
return total > 0 && completed > 0, nil
}
func (s *Service) examPrepLessonProgress(ctx context.Context, userID, lessonID int64) (fraction float64, done bool, completed, total int32, err error) {
completed, total, err = s.store.ExamPrepUserPracticeProgressInLesson(ctx, userID, lessonID)
if err != nil {
return 0, false, 0, 0, err
}
if total > 0 && completed > 0 {
return 1, true, 1, 1, nil
}
return 0, false, 0, 1, nil
}
func (s *Service) examPrepModuleProgress(ctx context.Context, userID, moduleID int64) (fraction float64, done bool, completed, total int32, err error) {
lessonIDs, err := s.store.ListExamPrepUnitModuleLessonIDsByUnitModule(ctx, moduleID)
if err != nil {
return 0, false, 0, 0, err
}
if len(lessonIDs) == 0 {
return 0, false, 0, 0, nil
}
var doneLessons int32
for _, lessonID := range lessonIDs {
lessonFraction, _, _, _, err := s.examPrepLessonProgress(ctx, userID, lessonID)
if err != nil {
return 0, false, 0, 0, err
}
if lessonFraction >= 1 {
doneLessons++
}
}
total = int32(len(lessonIDs))
fraction = float64(doneLessons) / float64(total)
return fraction, fraction >= 1, doneLessons, total, nil
}
func (s *Service) examPrepUnitProgress(ctx context.Context, userID, unitID int64) (fraction float64, done bool, completed, total int32, err error) {
moduleIDs, err := s.store.ListExamPrepUnitModuleIDsByUnit(ctx, unitID)
if err != nil {
return 0, false, 0, 0, err
}
if len(moduleIDs) == 0 {
return 0, false, 0, 0, nil
}
var sum float64
var fullDone int32
for _, moduleID := range moduleIDs {
moduleFraction, _, _, _, err := s.examPrepModuleProgress(ctx, userID, moduleID)
if err != nil {
return 0, false, 0, 0, err
}
sum += moduleFraction
if moduleFraction >= 1 {
fullDone++
}
}
total = int32(len(moduleIDs))
fraction = sum / float64(total)
return fraction, fraction >= 1, fullDone, total, nil
}
func (s *Service) examPrepCatalogCourseProgress(ctx context.Context, userID, catalogCourseID int64) (fraction float64, done bool, completed, total int32, err error) {
unitIDs, err := s.store.ListExamPrepUnitIDsByCatalogCourse(ctx, catalogCourseID)
if err != nil {
return 0, false, 0, 0, err
}
if len(unitIDs) == 0 {
return 0, false, 0, 0, nil
}
var sum float64
var fullDone int32
for _, unitID := range unitIDs {
unitFraction, _, _, _, err := s.examPrepUnitProgress(ctx, userID, unitID)
if err != nil {
return 0, false, 0, 0, err
}
sum += unitFraction
if unitFraction >= 1 {
fullDone++
}
}
total = int32(len(unitIDs))
fraction = sum / float64(total)
return fraction, fraction >= 1, fullDone, total, nil
}
func lmsProgressComplete(completed, total int32) bool {
return total > 0 && completed >= total
}
@ -311,6 +507,41 @@ func buildLMSEntityAccess(ok bool, reason string, done bool, completed, total in
}
}
func buildLMSEntityAccessFromFraction(ok bool, reason string, done bool, completed, total int32, fraction float64) *domain.LMSEntityAccess {
c := int(completed)
t := int(total)
if c < 0 {
c = 0
}
if t < 0 {
t = 0
}
if done && t > 0 {
c = t
fraction = 1
}
if fraction < 0 {
fraction = 0
}
if fraction > 1 {
fraction = 1
}
pctPrecise := math.Round(fraction*10000) / 100
pct := int(pctPrecise)
if pct > 100 {
pct = 100
}
return &domain.LMSEntityAccess{
IsAccessible: ok,
IsCompleted: done,
Reason: reasonIf(ok, reason),
CompletedCount: c,
TotalCount: t,
ProgressPercent: pct,
ProgressPercentPrecise: pctPrecise,
}
}
// 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.
func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int, pctPrecise float64) {