package lmsprogress import ( "context" "errors" "math" "Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/repository" "github.com/jackc/pgx/v5" ) const ( errPrevProgram = "Complete the previous program before accessing this one." errPrevCourse = "Complete the previous course in this program first." errPrevModule = "Complete the previous module in this course first." errPrevLesson = "Complete the previous lesson in this module first." ) // Service enforces sequential LMS access for learners and records lesson progress. type Service struct { store *repository.Store } func NewService(store *repository.Store) *Service { return &Service{store: store} } // CompleteLessonForUser records lesson completion and rolls up to module, course, and program when applicable. func (s *Service) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error { return s.store.CompleteLessonForUser(ctx, userID, lessonID) } // CompletePracticeForUser records practice completion and rolls up to module, course, and program when applicable. func (s *Service) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error { return s.store.CompletePracticeForUser(ctx, userID, questionSetID) } // GetMyProgress returns completed lesson, module, course, and program IDs for the user. func (s *Service) GetMyProgress(ctx context.Context, userID int64) (domain.LMSUserProgress, error) { return s.store.GetLMSUserProgressSnapshot(ctx, userID) } // CanAccessProgram returns whether the user may use content under this program (previous program must be fully completed if any). func (s *Service) CanAccessProgram(ctx context.Context, userID, programID int64) (ok bool, reason string, err error) { if _, err := s.store.GetProgramByID(ctx, programID); err != nil { return false, "", err } prev, err := s.store.LmsGetPreviousProgram(ctx, programID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return true, "", nil } return false, "", err } has, err := s.store.LmsUserHasProgramProgress(ctx, userID, prev.ID) if err != nil { return false, "", err } if !has { return false, errPrevProgram, nil } return true, "", nil } // CanAccessCourse requires the parent program to be accessible and the previous course in the program to be completed. func (s *Service) CanAccessCourse(ctx context.Context, userID, courseID int64) (ok bool, reason string, err error) { c, err := s.store.GetCourseByID(ctx, courseID) if err != nil { return false, "", err } ok, reason, err = s.CanAccessProgram(ctx, userID, c.ProgramID) if err != nil || !ok { return ok, reason, err } prev, err := s.store.LmsGetPreviousCourseInProgram(ctx, courseID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return true, "", nil } return false, "", err } has, err := s.store.LmsUserHasCourseProgress(ctx, userID, prev.ID) if err != nil { return false, "", err } if !has { return false, errPrevCourse, nil } return true, "", nil } // CanAccessModule requires the course (and its program chain) to be accessible and the previous module in the course to be completed. func (s *Service) CanAccessModule(ctx context.Context, userID, moduleID int64) (ok bool, reason string, err error) { m, err := s.store.GetModuleByID(ctx, moduleID) if err != nil { return false, "", err } ok, reason, err = s.CanAccessCourse(ctx, userID, m.CourseID) if err != nil || !ok { return ok, reason, err } prev, err := s.store.LmsGetPreviousModuleInCourse(ctx, moduleID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return true, "", nil } return false, "", err } has, err := s.store.LmsUserHasModuleProgress(ctx, userID, prev.ID) if err != nil { return false, "", err } if !has { return false, errPrevModule, nil } return true, "", nil } // CanAccessLesson requires the module chain to be accessible and the previous lesson in the module // to be completed based on published practice completion in that lesson. func (s *Service) CanAccessLesson(ctx context.Context, userID, lessonID int64) (ok bool, reason string, err error) { lesson, err := s.store.GetLessonByID(ctx, lessonID) if err != nil { return false, "", err } ok, reason, err = s.CanAccessModule(ctx, userID, lesson.ModuleID) if err != nil || !ok { return ok, reason, err } prev, err := s.store.LmsGetPreviousLessonInModule(ctx, lessonID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return true, "", nil } return false, "", err } // Lesson unlock for STUDENT now follows practice completion, not deprecated lesson-complete writes. prevCompletedPractices, prevTotalPractices, err := s.store.LmsUserPracticeProgressInLesson(ctx, userID, prev.ID) if err != nil { return false, "", err } if !lmsProgressComplete(prevCompletedPractices, prevTotalPractices) { return false, errPrevLesson, nil } return true, "", nil } // ApplyAccessProgram sets p.Access for learner roles. Staff roles omit Access from JSON. // STUDENT: is_accessible reflects sequential prerequisites; OPEN_LEARNER: always true. func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error { if !role.IsCustomerLearnerRole() { p.Access = nil return nil } fraction, done, comp, tot, err := s.lmsProgramProgress(ctx, userID, p.ID) if err != nil { return err } ok, reason := true, "" if role.UsesLMSSequentialGating() { ok, reason, err = s.CanAccessProgram(ctx, userID, p.ID) if err != nil { return err } } p.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction) return nil } // ApplyAccessCourse sets c.Access for learner roles. func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userID int64, c *domain.Course) error { if !role.IsCustomerLearnerRole() { c.Access = nil return nil } fraction, done, comp, tot, err := s.lmsCourseProgress(ctx, userID, c.ID) if err != nil { return err } ok, reason := true, "" if role.UsesLMSSequentialGating() { ok, reason, err = s.CanAccessCourse(ctx, userID, c.ID) if err != nil { return err } } c.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction) return nil } // ApplyAccessModule sets m.Access for learner roles. func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userID int64, m *domain.Module) error { if !role.IsCustomerLearnerRole() { m.Access = nil return nil } fraction, done, comp, tot, err := s.lmsModuleProgress(ctx, userID, m.ID) if err != nil { return err } ok, reason := true, "" if role.UsesLMSSequentialGating() { ok, reason, err = s.CanAccessModule(ctx, userID, m.ID) if err != nil { return err } } m.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction) return nil } // ApplyAccessLesson sets l.Access for learner roles. func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userID int64, les *domain.Lesson) error { if !role.IsCustomerLearnerRole() { les.Access = nil return nil } fraction, done, comp, tot, err := s.lmsLessonProgress(ctx, userID, les.ID) if err != nil { return err } ok, reason := true, "" if role.UsesLMSSequentialGating() { ok, reason, err = s.CanAccessLesson(ctx, userID, les.ID) if err != nil { return err } } les.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction) return nil } // ApplyExamPrepAccessCatalogCourse sets progress on an exam-prep catalog course for learner roles. func (s *Service) ApplyExamPrepAccessCatalogCourse(ctx context.Context, role domain.Role, userID int64, cc *domain.ExamPrepCatalogCourse) error { if !role.IsCustomerLearnerRole() { cc.Access = nil return nil } fraction, done, comp, tot, err := s.examPrepCatalogCourseProgress(ctx, userID, cc.ID) if err != nil { return err } cc.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction) return nil } // ApplyExamPrepAccessUnit sets progress on an exam-prep unit for learner roles. func (s *Service) ApplyExamPrepAccessUnit(ctx context.Context, role domain.Role, userID int64, u *domain.ExamPrepUnit) error { if !role.IsCustomerLearnerRole() { u.Access = nil return nil } fraction, done, comp, tot, err := s.examPrepUnitProgress(ctx, userID, u.ID) if err != nil { return err } u.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction) return nil } // ApplyExamPrepAccessModule sets progress on an exam-prep module for learner roles. func (s *Service) ApplyExamPrepAccessModule(ctx context.Context, role domain.Role, userID int64, m *domain.ExamPrepModule) error { if !role.IsCustomerLearnerRole() { m.Access = nil return nil } fraction, done, comp, tot, err := s.examPrepModuleProgress(ctx, userID, m.ID) if err != nil { return err } m.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction) return nil } // ApplyExamPrepAccessLesson sets progress on an exam-prep lesson for learner roles. func (s *Service) ApplyExamPrepAccessLesson(ctx context.Context, role domain.Role, userID int64, les *domain.ExamPrepLesson) error { if !role.IsCustomerLearnerRole() { les.Access = nil return nil } fraction, done, comp, tot, err := s.examPrepLessonProgress(ctx, userID, les.ID) if err != nil { return err } 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 } func buildLMSEntityAccess(ok bool, reason string, done bool, completed, total int32) *domain.LMSEntityAccess { c, t, pct, pctPrecise := lmsProgressCounts(completed, total, done) return &domain.LMSEntityAccess{ IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), CompletedCount: c, TotalCount: t, ProgressPercent: pct, ProgressPercentPrecise: pctPrecise, } } 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) { c, t = int(completed), int(total) if t < 0 { t = 0 } if c < 0 { c = 0 } if isCompleted { if t > 0 { return t, t, 100, 100 } return c, t, 100, 100 } if t == 0 { return 0, 0, 0, 0 } pct = (c * 100) / t if pct > 100 { pct = 100 } 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 { if ok { return "" } return r }