Yimaru-BackEnd/internal/services/lmsprogress/service.go
Yared Yemane afdd07d65d 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>
2026-05-26 03:27:54 -07:00

290 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.
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 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
}
comp, tot, err := s.store.LmsUserLessonProgressInProgram(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)
if err != nil {
return err
}
}
p.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
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
}
comp, tot, err := s.store.LmsUserLessonProgressInCourse(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)
if err != nil {
return err
}
}
c.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
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
}
comp, tot, err := s.store.LmsUserLessonProgressInModule(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)
if err != nil {
return err
}
}
m.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
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
}
comp, tot, err := s.store.LmsUserPracticeProgressInLesson(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)
if err != nil {
return err
}
}
les.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
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 {
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,
}
}
// 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) {
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
}