package lmsprogress import ( "context" "errors" "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. 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 } has, err := s.store.LmsUserHasLessonProgress(ctx, userID, prev.ID) if err != nil { return false, "", err } if !has { return false, errPrevLesson, nil } return true, "", nil } // ApplyAccessProgram sets p.Access for a learner. Non-learners: clears Access to omit from JSON. func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error { if role != domain.RoleStudent { p.Access = nil return nil } ok, reason, err := s.CanAccessProgram(ctx, userID, p.ID) if err != nil { return err } done, err := s.store.LmsUserHasProgramProgress(ctx, userID, p.ID) if err != nil { return err } comp, tot, err := s.store.LmsUserLessonProgressInProgram(ctx, userID, p.ID) if err != nil { return err } c, t, pct := lmsProgressCounts(comp, tot, done) p.Access = &domain.LMSEntityAccess{ IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), CompletedCount: c, TotalCount: t, ProgressPercent: pct, } return nil } // ApplyAccessCourse sets c.Access for a learner. func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userID int64, c *domain.Course) error { if role != domain.RoleStudent { c.Access = nil return nil } ok, reason, err := s.CanAccessCourse(ctx, userID, c.ID) if err != nil { return err } done, err := s.store.LmsUserHasCourseProgress(ctx, userID, c.ID) if err != nil { return err } comp, tot, err := s.store.LmsUserLessonProgressInCourse(ctx, userID, c.ID) if err != nil { return err } cc, tt, pct := lmsProgressCounts(comp, tot, done) c.Access = &domain.LMSEntityAccess{ IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), CompletedCount: cc, TotalCount: tt, ProgressPercent: pct, } return nil } // ApplyAccessModule sets m.Access for a learner. func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userID int64, m *domain.Module) error { if role != domain.RoleStudent { m.Access = nil return nil } ok, reason, err := s.CanAccessModule(ctx, userID, m.ID) if err != nil { return err } done, err := s.store.LmsUserHasModuleProgress(ctx, userID, m.ID) if err != nil { return err } comp, tot, err := s.store.LmsUserLessonProgressInModule(ctx, userID, m.ID) if err != nil { return err } cc, tt, pct := lmsProgressCounts(comp, tot, done) m.Access = &domain.LMSEntityAccess{ IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), CompletedCount: cc, TotalCount: tt, ProgressPercent: pct, } return nil } // ApplyAccessLesson sets l.Access for a learner. func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userID int64, les *domain.Lesson) error { if role != domain.RoleStudent { les.Access = nil return nil } ok, reason, err := s.CanAccessLesson(ctx, userID, les.ID) if err != nil { return err } done, err := s.store.LmsUserHasLessonProgress(ctx, userID, les.ID) if err != nil { return err } var comp, tot int32 if done { comp, tot = 1, 1 } else { comp, tot = 0, 1 } c, t, pct := lmsProgressCounts(comp, tot, done) les.Access = &domain.LMSEntityAccess{ IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), CompletedCount: c, TotalCount: t, ProgressPercent: pct, } return nil } // 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) { 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 } return c, t, 100 } if t == 0 { return 0, 0, 0 } pct = (c * 100) / t if pct > 100 { pct = 100 } return c, t, pct } func reasonIf(ok bool, r string) string { if ok { return "" } return r }