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>
This commit is contained in:
Yared Yemane 2026-05-26 03:27:54 -07:00
parent a719c0daca
commit afdd07d65d
13 changed files with 4816 additions and 200 deletions

View File

@ -117,6 +117,33 @@ INSERT INTO lms_user_program_progress (user_id, program_id)
ON CONFLICT (user_id, program_id)
DO NOTHING;
-- name: CountPublishedPracticesInLesson :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.lesson_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED';
-- name: CountUserCompletedPublishedPracticesInLesson :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE
lp.lesson_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED';
-- name: CountLessonsInModule :one
SELECT
count(*)::int AS n
@ -175,47 +202,95 @@ WHERE
-- name: ListLMSCompletedLessonIDsByUser :many
SELECT
ulp.lesson_id
lp.lesson_id
FROM
lms_user_lesson_progress AS ulp
lms_practices AS lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
WHERE
ulp.user_id = $1
lp.lesson_id IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
GROUP BY
lp.lesson_id
HAVING
count(DISTINCT lp.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
ORDER BY
ulp.completed_at ASC,
ulp.lesson_id ASC;
max(upp.completed_at) ASC,
lp.lesson_id ASC;
-- name: ListLMSCompletedModuleIDsByUser :many
SELECT
ump.module_id
lp.module_id
FROM
lms_user_module_progress AS ump
lms_practices AS lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
WHERE
ump.user_id = $1
lp.module_id IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
GROUP BY
lp.module_id
HAVING
count(DISTINCT lp.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
ORDER BY
ump.completed_at ASC,
ump.module_id ASC;
max(upp.completed_at) ASC,
lp.module_id ASC;
-- name: ListLMSCompletedCourseIDsByUser :many
SELECT
ucp.course_id
lp.course_id
FROM
lms_user_course_progress AS ucp
lms_practices AS lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
WHERE
ucp.user_id = $1
lp.course_id IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
GROUP BY
lp.course_id
HAVING
count(DISTINCT lp.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
ORDER BY
ucp.completed_at ASC,
ucp.course_id ASC;
max(upp.completed_at) ASC,
lp.course_id ASC;
-- name: ListLMSCompletedProgramIDsByUser :many
SELECT
upp.program_id
c.program_id
FROM
lms_user_program_progress AS upp
lms_practices AS lp
INNER JOIN courses c ON c.id = lp.course_id
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
WHERE
upp.user_id = $1
qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
GROUP BY
c.program_id
HAVING
count(DISTINCT lp.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
ORDER BY
upp.completed_at ASC,
upp.program_id ASC;
max(upp.completed_at) ASC,
c.program_id ASC;
-- Lesson-based progress within a course (all modules).
-- name: CountLessonsInCourse :one

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -119,6 +119,26 @@ func (q *Queries) CountPublishedPracticesInCourse(ctx context.Context, courseID
return n, err
}
const CountPublishedPracticesInLesson = `-- name: CountPublishedPracticesInLesson :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.lesson_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
`
func (q *Queries) CountPublishedPracticesInLesson(ctx context.Context, lessonID pgtype.Int8) (int32, error) {
row := q.db.QueryRow(ctx, CountPublishedPracticesInLesson, lessonID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountPublishedPracticesInModule = `-- name: CountPublishedPracticesInModule :one
SELECT
count(*)::int AS n
@ -309,6 +329,34 @@ func (q *Queries) CountUserCompletedPublishedPracticesInCourse(ctx context.Conte
return n, err
}
const CountUserCompletedPublishedPracticesInLesson = `-- name: CountUserCompletedPublishedPracticesInLesson :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE
lp.lesson_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
`
type CountUserCompletedPublishedPracticesInLessonParams struct {
LessonID pgtype.Int8 `json:"lesson_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedPublishedPracticesInLesson(ctx context.Context, arg CountUserCompletedPublishedPracticesInLessonParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedPublishedPracticesInLesson, arg.LessonID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedPublishedPracticesInModule = `-- name: CountUserCompletedPublishedPracticesInModule :one
SELECT
count(*)::int AS n
@ -591,25 +639,37 @@ func (q *Queries) InsertUserProgramProgress(ctx context.Context, arg InsertUserP
const ListLMSCompletedCourseIDsByUser = `-- name: ListLMSCompletedCourseIDsByUser :many
SELECT
ucp.course_id
lp.course_id
FROM
lms_user_course_progress AS ucp
lms_practices AS lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
WHERE
ucp.user_id = $1
lp.course_id IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
GROUP BY
lp.course_id
HAVING
count(DISTINCT lp.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
ORDER BY
ucp.completed_at ASC,
ucp.course_id ASC
max(upp.completed_at) ASC,
lp.course_id ASC
`
func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID int64) ([]pgtype.Int8, error) {
rows, err := q.db.Query(ctx, ListLMSCompletedCourseIDsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
var items []pgtype.Int8
for rows.Next() {
var course_id int64
var course_id pgtype.Int8
if err := rows.Scan(&course_id); err != nil {
return nil, err
}
@ -623,25 +683,37 @@ func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID in
const ListLMSCompletedLessonIDsByUser = `-- name: ListLMSCompletedLessonIDsByUser :many
SELECT
ulp.lesson_id
lp.lesson_id
FROM
lms_user_lesson_progress AS ulp
lms_practices AS lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
WHERE
ulp.user_id = $1
lp.lesson_id IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
GROUP BY
lp.lesson_id
HAVING
count(DISTINCT lp.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
ORDER BY
ulp.completed_at ASC,
ulp.lesson_id ASC
max(upp.completed_at) ASC,
lp.lesson_id ASC
`
func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID int64) ([]pgtype.Int8, error) {
rows, err := q.db.Query(ctx, ListLMSCompletedLessonIDsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
var items []pgtype.Int8
for rows.Next() {
var lesson_id int64
var lesson_id pgtype.Int8
if err := rows.Scan(&lesson_id); err != nil {
return nil, err
}
@ -655,25 +727,37 @@ func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID in
const ListLMSCompletedModuleIDsByUser = `-- name: ListLMSCompletedModuleIDsByUser :many
SELECT
ump.module_id
lp.module_id
FROM
lms_user_module_progress AS ump
lms_practices AS lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
WHERE
ump.user_id = $1
lp.module_id IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
GROUP BY
lp.module_id
HAVING
count(DISTINCT lp.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
ORDER BY
ump.completed_at ASC,
ump.module_id ASC
max(upp.completed_at) ASC,
lp.module_id ASC
`
func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID int64) ([]pgtype.Int8, error) {
rows, err := q.db.Query(ctx, ListLMSCompletedModuleIDsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
var items []pgtype.Int8
for rows.Next() {
var module_id int64
var module_id pgtype.Int8
if err := rows.Scan(&module_id); err != nil {
return nil, err
}
@ -687,14 +771,26 @@ func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID in
const ListLMSCompletedProgramIDsByUser = `-- name: ListLMSCompletedProgramIDsByUser :many
SELECT
upp.program_id
c.program_id
FROM
lms_user_program_progress AS upp
lms_practices AS lp
INNER JOIN courses c ON c.id = lp.course_id
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
LEFT JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
AND upp.user_id = $1
AND upp.completed_at IS NOT NULL
WHERE
upp.user_id = $1
qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND lp.publish_status = 'PUBLISHED'
GROUP BY
c.program_id
HAVING
count(DISTINCT lp.question_set_id) > 0
AND count(DISTINCT upp.question_set_id) >= count(DISTINCT lp.question_set_id)
ORDER BY
upp.completed_at ASC,
upp.program_id ASC
max(upp.completed_at) ASC,
c.program_id ASC
`
func (q *Queries) ListLMSCompletedProgramIDsByUser(ctx context.Context, userID int64) ([]int64, error) {

View File

@ -214,6 +214,20 @@ type LmsUserProgramProgress struct {
CompletedAt pgtype.Timestamptz `json:"completed_at"`
}
type MobileAppVersion struct {
ID int64 `json:"id"`
Platform string `json:"platform"`
VersionName string `json:"version_name"`
VersionCode int32 `json:"version_code"`
UpdateType string `json:"update_type"`
ReleaseNotes pgtype.Text `json:"release_notes"`
StoreUrl pgtype.Text `json:"store_url"`
MinSupportedVersionCode pgtype.Int4 `json:"min_supported_version_code"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type Module struct {
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`

View File

@ -3,18 +3,21 @@ package domain
// LMSEntityAccess describes learner gating for a program, course, module, or lesson.
// 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 published practices vs total published practices in the
// entity's scope. progress_percent keeps the legacy whole-number value; use
// progress_percent_precise for decimal precision in learner UIs.
type LMSEntityAccess struct {
IsAccessible bool `json:"is_accessible"`
IsCompleted bool `json:"is_completed"`
Reason string `json:"reason,omitempty"`
CompletedCount int `json:"completed_count"`
TotalCount int `json:"total_count"`
ProgressPercent int `json:"progress_percent"`
IsAccessible bool `json:"is_accessible"`
IsCompleted bool `json:"is_completed"`
Reason string `json:"reason,omitempty"`
CompletedCount int `json:"completed_count"`
TotalCount int `json:"total_count"`
ProgressPercent int `json:"progress_percent"`
ProgressPercentPrecise float64 `json:"progress_percent_precise"`
}
// LMSUserProgress lists entity IDs the authenticated user has fully completed
// (lessons as marked complete; module/course/program when rollup conditions were met).
// LMSUserProgress lists entity IDs the authenticated user has fully completed based on
// published practice completion in each LMS scope.
type LMSUserProgress struct {
LessonIDs []int64 `json:"lesson_ids"`
ModuleIDs []int64 `json:"module_ids"`

View File

@ -38,89 +38,67 @@ func (s *Store) LmsUserHasLessonProgress(ctx context.Context, userID, lessonID i
return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID})
}
// LmsUserLessonProgressInModule returns combined completed/total counts for lessons + published practices in a module.
func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) {
lessonTotal, err := s.queries.CountLessonsInModule(ctx, moduleID)
// LmsUserPracticeProgressInLesson returns published practice completion counts scoped to a lesson.
func (s *Store) LmsUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) {
lessonIDPG := toPgInt8(&lessonID)
total, err = s.queries.CountPublishedPracticesInLesson(ctx, lessonIDPG)
if err != nil {
return 0, 0, err
}
lessonCompleted, err := s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
ModuleID: moduleID,
completed, err = s.queries.CountUserCompletedPublishedPracticesInLesson(ctx, dbgen.CountUserCompletedPublishedPracticesInLessonParams{
LessonID: lessonIDPG,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
practiceTotal, err := s.queries.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
return completed, total, nil
}
// LmsUserLessonProgressInModule returns published practice completion counts in a module.
func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) {
total, err = s.queries.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
if err != nil {
return 0, 0, err
}
practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
completed, err = s.queries.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
ModuleID: toPgInt8(&moduleID),
UserID: userID,
})
if err != nil {
return 0, 0, err
}
total = lessonTotal + practiceTotal
completed = lessonCompleted + practiceCompleted
return completed, total, nil
}
// LmsUserLessonProgressInCourse returns combined completed/total counts for lessons + published practices in a course.
// LmsUserLessonProgressInCourse returns published practice completion counts in a course.
func (s *Store) LmsUserLessonProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) {
lessonTotal, err := s.queries.CountLessonsInCourse(ctx, courseID)
total, err = s.queries.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
if err != nil {
return 0, 0, err
}
lessonCompleted, err := s.queries.CountUserCompletedLessonsInCourse(ctx, dbgen.CountUserCompletedLessonsInCourseParams{
CourseID: courseID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
practiceTotal, err := s.queries.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
if err != nil {
return 0, 0, err
}
practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{
completed, err = s.queries.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{
CourseID: toPgInt8(&courseID),
UserID: userID,
})
if err != nil {
return 0, 0, err
}
total = lessonTotal + practiceTotal
completed = lessonCompleted + practiceCompleted
return completed, total, nil
}
// LmsUserLessonProgressInProgram returns combined completed/total counts for lessons + published practices in a program.
// LmsUserLessonProgressInProgram returns published practice completion counts in a program.
func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, programID int64) (completed, total int32, err error) {
lessonTotal, err := s.queries.CountLessonsInProgram(ctx, programID)
total, err = s.queries.CountPublishedPracticesInProgram(ctx, programID)
if err != nil {
return 0, 0, err
}
lessonCompleted, err := s.queries.CountUserCompletedLessonsInProgram(ctx, dbgen.CountUserCompletedLessonsInProgramParams{
completed, err = s.queries.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{
ProgramID: programID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
practiceTotal, err := s.queries.CountPublishedPracticesInProgram(ctx, programID)
if err != nil {
return 0, 0, err
}
practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{
ProgramID: programID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
total = lessonTotal + practiceTotal
completed = lessonCompleted + practiceCompleted
return completed, total, nil
}

View File

@ -7,8 +7,8 @@ import (
dbgen "Yimaru-Backend/gen/db"
)
// CompleteLessonForUser records lesson completion and cascades completion upward when
// both lesson and related practice requirements are satisfied.
// CompleteLessonForUser records lesson completion for sequential lesson gating and
// re-evaluates higher-level practice-based rollups.
func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error {
q, tx, err := s.BeginTx(ctx)
if err != nil {
@ -42,8 +42,8 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6
return nil
}
// CompletePracticeForUser records practice completion and cascades completion upward when
// both lesson and related practice requirements are satisfied.
// CompletePracticeForUser records practice completion and cascades practice-based
// completion upward when all published practices in scope are complete.
func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error {
q, tx, err := s.BeginTx(ctx)
if err != nil {
@ -110,17 +110,6 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID int64, moduleID *int64, courseID, programID int64) error {
if moduleID != nil {
moduleLessonsTotal, err := q.CountLessonsInModule(ctx, *moduleID)
if err != nil {
return err
}
moduleLessonsDone, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
ModuleID: *moduleID,
UserID: userID,
})
if err != nil {
return err
}
modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(moduleID))
if err != nil {
return err
@ -133,9 +122,8 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user
return err
}
moduleLessonsComplete := moduleLessonsTotal > 0 && moduleLessonsDone >= moduleLessonsTotal
modulePracticesComplete := modulePracticesDone >= modulePracticesTotal
if !moduleLessonsComplete || !modulePracticesComplete {
modulePracticesComplete := modulePracticesTotal > 0 && modulePracticesDone >= modulePracticesTotal
if !modulePracticesComplete {
return nil
}
@ -169,7 +157,7 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user
}
courseModulesComplete := nMods > 0 && nDoneMods >= nMods
coursePracticesComplete := coursePracticesDone >= coursePracticesTotal
coursePracticesComplete := coursePracticesTotal > 0 && coursePracticesDone >= coursePracticesTotal
if !courseModulesComplete || !coursePracticesComplete {
return nil
}
@ -203,7 +191,7 @@ func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, user
}
programCoursesComplete := nCr > 0 && nCrDone >= nCr
programPracticesComplete := programPracticesDone >= programPracticesTotal
programPracticesComplete := programPracticesTotal > 0 && programPracticesDone >= programPracticesTotal
if !programCoursesComplete || !programPracticesComplete {
return nil
}

View File

@ -5,9 +5,11 @@ import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5/pgtype"
)
// GetLMSUserProgressSnapshot returns all completed lesson, module, course, and program IDs for a user.
// GetLMSUserProgressSnapshot returns practice-based completed lesson, module, course,
// and program IDs for a user.
func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (domain.LMSUserProgress, error) {
lessons, err := s.queries.ListLMSCompletedLessonIDsByUser(ctx, userID)
if err != nil {
@ -26,13 +28,24 @@ func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (d
return domain.LMSUserProgress{}, err
}
return domain.LMSUserProgress{
LessonIDs: lessons,
ModuleIDs: mods,
CourseIDs: courses,
LessonIDs: pgInt8IDsToInt64(lessons),
ModuleIDs: pgInt8IDsToInt64(mods),
CourseIDs: pgInt8IDsToInt64(courses),
ProgramIDs: programs,
}, nil
}
func pgInt8IDsToInt64(items []pgtype.Int8) []int64 {
out := make([]int64, 0, len(items))
for _, item := range items {
if !item.Valid {
continue
}
out = append(out, item.Int64)
}
return out
}
// ListUserLMSFlatLearningActivity returns flattened LMS activity rows for admin reporting (lesson + practice completions).
func (s *Store) ListUserLMSFlatLearningActivity(ctx context.Context, userID int64) ([]dbgen.ListUserLMSFlatLearningActivityByUserRow, error) {
return s.queries.ListUserLMSFlatLearningActivityByUser(ctx, userID)

View File

@ -3,6 +3,7 @@ package lmsprogress
import (
"context"
"errors"
"math"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/repository"
@ -151,14 +152,11 @@ func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, user
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
}
done := lmsProgressComplete(comp, tot)
ok, reason := true, ""
if role.UsesLMSSequentialGating() {
ok, reason, err = s.CanAccessProgram(ctx, userID, p.ID)
@ -176,14 +174,11 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI
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
}
done := lmsProgressComplete(comp, tot)
ok, reason := true, ""
if role.UsesLMSSequentialGating() {
ok, reason, err = s.CanAccessCourse(ctx, userID, c.ID)
@ -201,14 +196,11 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI
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
}
done := lmsProgressComplete(comp, tot)
ok, reason := true, ""
if role.UsesLMSSequentialGating() {
ok, reason, err = s.CanAccessModule(ctx, userID, m.ID)
@ -226,16 +218,11 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI
les.Access = nil
return nil
}
done, err := s.store.LmsUserHasLessonProgress(ctx, userID, les.ID)
comp, tot, err := s.store.LmsUserPracticeProgressInLesson(ctx, userID, les.ID)
if err != nil {
return err
}
var comp, tot int32
if done {
comp, tot = 1, 1
} else {
comp, tot = 0, 1
}
done := lmsProgressComplete(comp, tot)
ok, reason := true, ""
if role.UsesLMSSequentialGating() {
ok, reason, err = s.CanAccessLesson(ctx, userID, les.ID)
@ -247,21 +234,26 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI
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 := lmsProgressCounts(completed, total, done)
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,
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) {
func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int, pctPrecise float64) {
c, t = int(completed), int(total)
if t < 0 {
t = 0
@ -271,18 +263,22 @@ func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int)
}
if isCompleted {
if t > 0 {
return t, t, 100
return t, t, 100, 100
}
return c, t, 100
return c, t, 100, 100
}
if t == 0 {
return 0, 0, 0
return 0, 0, 0, 0
}
pct = (c * 100) / t
if pct > 100 {
pct = 100
}
return c, t, pct
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 {

View File

@ -0,0 +1,97 @@
package lmsprogress
import "testing"
func TestLMSProgressCounts(t *testing.T) {
tests := []struct {
name string
completed int32
total int32
isCompleted bool
wantCompleted int
wantTotal int
wantPercent int
wantPercentFloat float64
}{
{
name: "fractional progress rounds to two decimals",
completed: 1,
total: 3,
wantCompleted: 1,
wantTotal: 3,
wantPercent: 33,
wantPercentFloat: 33.33,
},
{
name: "larger fraction rounds precisely",
completed: 2,
total: 3,
wantCompleted: 2,
wantTotal: 3,
wantPercent: 66,
wantPercentFloat: 66.67,
},
{
name: "completed forces full progress",
completed: 2,
total: 3,
isCompleted: true,
wantCompleted: 3,
wantTotal: 3,
wantPercent: 100,
wantPercentFloat: 100,
},
{
name: "empty scope stays zeroed",
wantCompleted: 0,
wantTotal: 0,
wantPercent: 0,
wantPercentFloat: 0,
},
{
name: "negative counts are sanitized",
completed: -2,
total: -5,
wantCompleted: 0,
wantTotal: 0,
wantPercent: 0,
wantPercentFloat: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotCompleted, gotTotal, gotPercent, gotPercentFloat := lmsProgressCounts(tt.completed, tt.total, tt.isCompleted)
if gotCompleted != tt.wantCompleted || gotTotal != tt.wantTotal {
t.Fatalf("counts=(%d,%d), want (%d,%d)", gotCompleted, gotTotal, tt.wantCompleted, tt.wantTotal)
}
if gotPercent != tt.wantPercent {
t.Fatalf("progress_percent=%d, want %d", gotPercent, tt.wantPercent)
}
if gotPercentFloat != tt.wantPercentFloat {
t.Fatalf("progress_percent_precise=%v, want %v", gotPercentFloat, tt.wantPercentFloat)
}
})
}
}
func TestLMSProgressComplete(t *testing.T) {
tests := []struct {
name string
completed int32
total int32
want bool
}{
{name: "complete when all practices done", completed: 3, total: 3, want: true},
{name: "incomplete when practices remain", completed: 2, total: 3, want: false},
{name: "zero total is not completed", completed: 0, total: 0, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := lmsProgressComplete(tt.completed, tt.total); got != tt.want {
t.Fatalf("lmsProgressComplete(%d, %d)=%v, want %v", tt.completed, tt.total, got, tt.want)
}
})
}
}

View File

@ -11,7 +11,7 @@ import (
// GetMyLMSProgress godoc
// @Summary Get my LMS completion history
// @Description Returns completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id).
// @Description Returns practice-based completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id).
// @Tags lms
// @Produce json
// @Success 200 {object} domain.Response