Include access metadata for OPEN_LEARNER with is_accessible always true

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>
This commit is contained in:
Yared Yemane 2026-05-22 05:45:44 -07:00
parent 79851d31b3
commit 0ad7f094cf
2 changed files with 51 additions and 41 deletions

View File

@ -1,7 +1,8 @@
package domain package domain
// LMSEntityAccess describes learner gating for a program, course, module, or lesson. // LMSEntityAccess describes learner gating for a program, course, module, or lesson.
// It is omitted (nil) for non-learner roles in API responses. // Included for STUDENT and OPEN_LEARNER; omitted (nil) for staff roles in API responses.
// OPEN_LEARNER always has is_accessible true; STUDENT may be false when prerequisites are unmet.
// Progress fields count completed lessons vs total lessons in that entitys scope (lesson: 0 or 1 of 1). // Progress fields count completed lessons vs total lessons in that entitys scope (lesson: 0 or 1 of 1).
type LMSEntityAccess struct { type LMSEntityAccess struct {
IsAccessible bool `json:"is_accessible"` IsAccessible bool `json:"is_accessible"`

View File

@ -144,16 +144,13 @@ func (s *Service) CanAccessLesson(ctx context.Context, userID, lessonID int64) (
return true, "", nil return true, "", nil
} }
// ApplyAccessProgram sets p.Access for a learner. Non-learners: clears Access to omit from JSON. // 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 { func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error {
if !role.UsesLMSSequentialGating() { if !role.IsCustomerLearnerRole() {
p.Access = nil p.Access = nil
return 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) done, err := s.store.LmsUserHasProgramProgress(ctx, userID, p.ID)
if err != nil { if err != nil {
return err return err
@ -162,24 +159,23 @@ func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, user
if err != nil { if err != nil {
return err return err
} }
c, t, pct := lmsProgressCounts(comp, tot, done) ok, reason := true, ""
p.Access = &domain.LMSEntityAccess{ if role.UsesLMSSequentialGating() {
IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), ok, reason, err = s.CanAccessProgram(ctx, userID, p.ID)
CompletedCount: c, TotalCount: t, ProgressPercent: pct, if err != nil {
return err
}
} }
p.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
return nil return nil
} }
// ApplyAccessCourse sets c.Access for a learner. // ApplyAccessCourse sets c.Access for learner roles.
func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userID int64, c *domain.Course) error { func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userID int64, c *domain.Course) error {
if !role.UsesLMSSequentialGating() { if !role.IsCustomerLearnerRole() {
c.Access = nil c.Access = nil
return 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) done, err := s.store.LmsUserHasCourseProgress(ctx, userID, c.ID)
if err != nil { if err != nil {
return err return err
@ -188,24 +184,23 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI
if err != nil { if err != nil {
return err return err
} }
cc, tt, pct := lmsProgressCounts(comp, tot, done) ok, reason := true, ""
c.Access = &domain.LMSEntityAccess{ if role.UsesLMSSequentialGating() {
IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), ok, reason, err = s.CanAccessCourse(ctx, userID, c.ID)
CompletedCount: cc, TotalCount: tt, ProgressPercent: pct, if err != nil {
return err
}
} }
c.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
return nil return nil
} }
// ApplyAccessModule sets m.Access for a learner. // ApplyAccessModule sets m.Access for learner roles.
func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userID int64, m *domain.Module) error { func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userID int64, m *domain.Module) error {
if !role.UsesLMSSequentialGating() { if !role.IsCustomerLearnerRole() {
m.Access = nil m.Access = nil
return 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) done, err := s.store.LmsUserHasModuleProgress(ctx, userID, m.ID)
if err != nil { if err != nil {
return err return err
@ -214,24 +209,23 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI
if err != nil { if err != nil {
return err return err
} }
cc, tt, pct := lmsProgressCounts(comp, tot, done) ok, reason := true, ""
m.Access = &domain.LMSEntityAccess{ if role.UsesLMSSequentialGating() {
IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), ok, reason, err = s.CanAccessModule(ctx, userID, m.ID)
CompletedCount: cc, TotalCount: tt, ProgressPercent: pct, if err != nil {
return err
}
} }
m.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
return nil return nil
} }
// ApplyAccessLesson sets l.Access for a learner. // ApplyAccessLesson sets l.Access for learner roles.
func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userID int64, les *domain.Lesson) error { func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userID int64, les *domain.Lesson) error {
if !role.UsesLMSSequentialGating() { if !role.IsCustomerLearnerRole() {
les.Access = nil les.Access = nil
return 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) done, err := s.store.LmsUserHasLessonProgress(ctx, userID, les.ID)
if err != nil { if err != nil {
return err return err
@ -242,14 +236,29 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI
} else { } else {
comp, tot = 0, 1 comp, tot = 0, 1
} }
c, t, pct := lmsProgressCounts(comp, tot, done) ok, reason := true, ""
les.Access = &domain.LMSEntityAccess{ if role.UsesLMSSequentialGating() {
IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), ok, reason, err = s.CanAccessLesson(ctx, userID, les.ID)
CompletedCount: c, TotalCount: t, ProgressPercent: pct, if err != nil {
return err
}
} }
les.Access = buildLMSEntityAccess(ok, reason, done, comp, tot)
return nil 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 0100; completed // 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. // and total are aligned with isCompleted when the entity is fully done.
func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int) { func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int) {