Keeps the same response shape as STUDENT while skipping sequential locks; progress fields are still populated for completion UI. Co-authored-by: Cursor <cursoragent@cursor.com>
294 lines
8.2 KiB
Go
294 lines
8.2 KiB
Go
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 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
|
||
}
|
||
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
|
||
}
|
||
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
|
||
}
|
||
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
|
||
}
|
||
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
|
||
}
|
||
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
|
||
}
|
||
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
|
||
}
|
||
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
|
||
}
|
||
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 buildLMSEntityAccess(ok bool, reason string, done bool, completed, total int32) *domain.LMSEntityAccess {
|
||
c, t, pct := lmsProgressCounts(completed, total, done)
|
||
return &domain.LMSEntityAccess{
|
||
IsAccessible: ok,
|
||
IsCompleted: done,
|
||
Reason: reasonIf(ok, reason),
|
||
CompletedCount: c,
|
||
TotalCount: t,
|
||
ProgressPercent: pct,
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|