Yimaru-BackEnd/internal/services/lmsprogress/service.go
Yared Yemane 7e61e34292 Add OPEN_LEARNER role without LMS sequential gating.
Migration 000061 inserts the RBAC role and demo user (openlearner@yimaru.com). STUDENT keeps sequential ApplyAccess and practice ordering; OPEN_LEARNER shares learner permissions and customer flows. Document the role in Swagger and point initial seed SQL at the migration for the demo account.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 10:26:25 -07:00

285 lines
8.1 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"
"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.UsesLMSSequentialGating() {
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.UsesLMSSequentialGating() {
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.UsesLMSSequentialGating() {
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.UsesLMSSequentialGating() {
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 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) {
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
}