diff --git a/db/query/lms_progress.sql b/db/query/lms_progress.sql index ddc70c6..6c6b98c 100644 --- a/db/query/lms_progress.sql +++ b/db/query/lms_progress.sql @@ -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, diff --git a/gen/db/lms_progress.sql.go b/gen/db/lms_progress.sql.go index 08b5133..b02001e 100644 --- a/gen/db/lms_progress.sql.go +++ b/gen/db/lms_progress.sql.go @@ -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 diff --git a/internal/repository/lms_access.go b/internal/repository/lms_access.go index 75c9ce4..b6680be 100644 --- a/internal/repository/lms_access.go +++ b/internal/repository/lms_access.go @@ -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 +} diff --git a/internal/services/lmsprogress/service.go b/internal/services/lmsprogress/service.go index d8e1033..ad46909 100644 --- a/internal/services/lmsprogress/service.go +++ b/internal/services/lmsprogress/service.go @@ -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 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, pctPrecise float64) {