Yimaru-BackEnd/internal/services/lmsprogress/service.go
Yared Yemane 08a2886654 feat: optional dynamic question_text and OPEN_LEARNER completed access
Derive question_text from QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE stimulus for DYNAMIC questions so the top-level field is no longer required on create.

OPEN_LEARNER access responses now set is_accessible and is_completed to true on all LMS and exam-prep content, with full progress when totals exist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 09:48:33 -07:00

627 lines
19 KiB
Go
Raw Permalink 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 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: is_accessible and is_completed 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 = finalizeOpenLearnerAccess(role, 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 = finalizeOpenLearnerAccess(role, 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 = finalizeOpenLearnerAccess(role, 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 = finalizeOpenLearnerAccess(role, 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 = finalizeOpenLearnerAccess(role, 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 = finalizeOpenLearnerAccess(role, 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 = finalizeOpenLearnerAccess(role, 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 = finalizeOpenLearnerAccess(role, 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
}
var doneLessons int32
for _, lesson := range lessons {
practiceCount, err := s.store.LmsCountPublishedPracticesInLesson(ctx, lesson.ID)
if err != nil {
return 0, false, 0, 0, err
}
if practiceCount == 0 {
continue
}
total++
lessonFraction, _, _, _, err := s.lmsLessonProgress(ctx, userID, lesson.ID)
if err != nil {
return 0, false, 0, 0, err
}
if lessonFraction >= 1 {
doneLessons++
}
}
fraction, done, completed, total = practiceScopeFraction(doneLessons, total)
return fraction, done, completed, 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
}
var doneModules int32
for _, moduleID := range moduleIDs {
practiceCount, err := s.store.LmsCountPublishedPracticesInModule(ctx, moduleID)
if err != nil {
return 0, false, 0, 0, err
}
if practiceCount == 0 {
continue
}
total++
_, moduleDone, _, _, err := s.lmsModuleProgress(ctx, userID, moduleID)
if err != nil {
return 0, false, 0, 0, err
}
if moduleDone {
doneModules++
}
}
fraction, done, completed, total = practiceScopeFraction(doneModules, total)
return fraction, done, completed, 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
}
var doneCourses int32
for _, courseID := range courseIDs {
practiceCount, err := s.store.LmsCountPublishedPracticesInCourse(ctx, courseID)
if err != nil {
return 0, false, 0, 0, err
}
if practiceCount == 0 {
continue
}
total++
_, courseDone, _, _, err := s.lmsCourseProgress(ctx, userID, courseID)
if err != nil {
return 0, false, 0, 0, err
}
if courseDone {
doneCourses++
}
}
fraction, done, completed, total = practiceScopeFraction(doneCourses, total)
return fraction, done, completed, 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
}
var doneLessons int32
for _, lessonID := range lessonIDs {
practiceCount, err := s.store.ExamPrepCountPublishedPracticesInLesson(ctx, lessonID)
if err != nil {
return 0, false, 0, 0, err
}
if practiceCount == 0 {
continue
}
total++
lessonFraction, _, _, _, err := s.examPrepLessonProgress(ctx, userID, lessonID)
if err != nil {
return 0, false, 0, 0, err
}
if lessonFraction >= 1 {
doneLessons++
}
}
fraction, done, completed, total = practiceScopeFraction(doneLessons, total)
return fraction, done, completed, 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
}
var doneModules int32
for _, moduleID := range moduleIDs {
practiceCount, err := s.store.ExamPrepCountPublishedPracticesInModule(ctx, moduleID)
if err != nil {
return 0, false, 0, 0, err
}
if practiceCount == 0 {
continue
}
total++
_, moduleDone, _, _, err := s.examPrepModuleProgress(ctx, userID, moduleID)
if err != nil {
return 0, false, 0, 0, err
}
if moduleDone {
doneModules++
}
}
fraction, done, completed, total = practiceScopeFraction(doneModules, total)
return fraction, done, completed, 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
}
var doneUnits int32
for _, unitID := range unitIDs {
practiceCount, err := s.store.ExamPrepCountPublishedPracticesInUnit(ctx, unitID)
if err != nil {
return 0, false, 0, 0, err
}
if practiceCount == 0 {
continue
}
total++
_, unitDone, _, _, err := s.examPrepUnitProgress(ctx, userID, unitID)
if err != nil {
return 0, false, 0, 0, err
}
if unitDone {
doneUnits++
}
}
fraction, done, completed, total = practiceScopeFraction(doneUnits, total)
return fraction, done, completed, total, nil
}
// practiceScopeFraction returns done/total for entities that only count children with published practices.
func practiceScopeFraction(done, total int32) (fraction float64, complete bool, completed, totalOut int32) {
if total == 0 {
return 0, false, 0, 0
}
fraction = float64(done) / float64(total)
return fraction, fraction >= 1, done, total
}
func lmsProgressComplete(completed, total int32) bool {
return total > 0 && completed >= total
}
// finalizeOpenLearnerAccess forces OPEN_LEARNER access JSON to show every item as accessible and completed.
func finalizeOpenLearnerAccess(role domain.Role, access *domain.LMSEntityAccess) *domain.LMSEntityAccess {
if access == nil || role != domain.RoleOpenLearner {
return access
}
access.IsAccessible = true
access.IsCompleted = true
access.Reason = ""
if access.TotalCount > 0 {
access.CompletedCount = access.TotalCount
access.ProgressPercent = 100
access.ProgressPercentPrecise = 100
}
return access
}
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 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
}