Require lesson and practice completion for LMS rollups.

Update lesson and practice completion flows to cascade module/course/program progress only when both lesson completion and related published practice completion criteria are met, and align progress counters with the new rule.

Made-with: Cursor
This commit is contained in:
Yared Yemane 2026-04-28 09:56:53 -07:00
parent 8c116f4a0b
commit 9027b65011
6 changed files with 485 additions and 66 deletions

View File

@ -246,3 +246,95 @@ FROM
WHERE
c.program_id = $1
AND ulp.user_id = $2;
-- Published practices in a module (module-level and lesson-level practices should carry module_id).
-- name: CountPublishedPracticesInModule :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.module_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED';
-- name: CountUserCompletedPublishedPracticesInModule :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.module_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED';
-- name: CountPublishedPracticesInCourse :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.course_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED';
-- name: CountUserCompletedPublishedPracticesInCourse :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.course_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED';
-- name: CountPublishedPracticesInProgram :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN courses c ON c.id = lp.course_id
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
c.program_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED';
-- name: CountUserCompletedPublishedPracticesInProgram :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN courses c ON c.id = lp.course_id
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
c.program_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED';
-- name: GetPracticeScopeByQuestionSetID :one
SELECT
id,
course_id,
module_id,
lesson_id
FROM
lms_practices
WHERE
question_set_id = $1
ORDER BY
id DESC
LIMIT 1;

View File

@ -7,6 +7,8 @@ package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CountCoursesInProgram = `-- name: CountCoursesInProgram :one
@ -94,6 +96,65 @@ func (q *Queries) CountModulesInCourse(ctx context.Context, courseID int64) (int
return n, err
}
const CountPublishedPracticesInCourse = `-- name: CountPublishedPracticesInCourse :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.course_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
`
func (q *Queries) CountPublishedPracticesInCourse(ctx context.Context, courseID pgtype.Int8) (int32, error) {
row := q.db.QueryRow(ctx, CountPublishedPracticesInCourse, courseID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountPublishedPracticesInModule = `-- name: CountPublishedPracticesInModule :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.module_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
`
// Published practices in a module (module-level and lesson-level practices should carry module_id).
func (q *Queries) CountPublishedPracticesInModule(ctx context.Context, moduleID pgtype.Int8) (int32, error) {
row := q.db.QueryRow(ctx, CountPublishedPracticesInModule, moduleID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountPublishedPracticesInProgram = `-- name: CountPublishedPracticesInProgram :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN courses c ON c.id = lp.course_id
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
c.program_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
`
func (q *Queries) CountPublishedPracticesInProgram(ctx context.Context, programID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountPublishedPracticesInProgram, programID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedCoursesInProgram = `-- name: CountUserCompletedCoursesInProgram :one
SELECT
count(*)::int AS n
@ -212,6 +273,122 @@ func (q *Queries) CountUserCompletedModulesInCourse(ctx context.Context, arg Cou
return n, err
}
const CountUserCompletedPublishedPracticesInCourse = `-- name: CountUserCompletedPublishedPracticesInCourse :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.course_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
`
type CountUserCompletedPublishedPracticesInCourseParams struct {
CourseID pgtype.Int8 `json:"course_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedPublishedPracticesInCourse(ctx context.Context, arg CountUserCompletedPublishedPracticesInCourseParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedPublishedPracticesInCourse, arg.CourseID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedPublishedPracticesInModule = `-- name: CountUserCompletedPublishedPracticesInModule :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.module_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
`
type CountUserCompletedPublishedPracticesInModuleParams struct {
ModuleID pgtype.Int8 `json:"module_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedPublishedPracticesInModule(ctx context.Context, arg CountUserCompletedPublishedPracticesInModuleParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedPublishedPracticesInModule, arg.ModuleID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedPublishedPracticesInProgram = `-- name: CountUserCompletedPublishedPracticesInProgram :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN courses c ON c.id = lp.course_id
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
c.program_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
`
type CountUserCompletedPublishedPracticesInProgramParams struct {
ProgramID int64 `json:"program_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedPublishedPracticesInProgram(ctx context.Context, arg CountUserCompletedPublishedPracticesInProgramParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedPublishedPracticesInProgram, arg.ProgramID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const GetPracticeScopeByQuestionSetID = `-- name: GetPracticeScopeByQuestionSetID :one
SELECT
id,
course_id,
module_id,
lesson_id
FROM
lms_practices
WHERE
question_set_id = $1
ORDER BY
id DESC
LIMIT 1
`
type GetPracticeScopeByQuestionSetIDRow struct {
ID int64 `json:"id"`
CourseID pgtype.Int8 `json:"course_id"`
ModuleID pgtype.Int8 `json:"module_id"`
LessonID pgtype.Int8 `json:"lesson_id"`
}
func (q *Queries) GetPracticeScopeByQuestionSetID(ctx context.Context, questionSetID int64) (GetPracticeScopeByQuestionSetIDRow, error) {
row := q.db.QueryRow(ctx, GetPracticeScopeByQuestionSetID, questionSetID)
var i GetPracticeScopeByQuestionSetIDRow
err := row.Scan(
&i.ID,
&i.CourseID,
&i.ModuleID,
&i.LessonID,
)
return i, err
}
const GetPreviousCourseInProgram = `-- name: GetPreviousCourseInProgram :one
SELECT
c2.id, c2.program_id, c2.name, c2.description, c2.thumbnail, c2.created_at, c2.updated_at, c2.sort_order

View File

@ -38,50 +38,89 @@ func (s *Store) LmsUserHasLessonProgress(ctx context.Context, userID, lessonID i
return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID})
}
// LmsUserLessonProgressInModule returns completed and total lesson counts in a module (for progress UI).
// 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) {
total, err = s.queries.CountLessonsInModule(ctx, moduleID)
lessonTotal, err := s.queries.CountLessonsInModule(ctx, moduleID)
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
lessonCompleted, err := s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
ModuleID: moduleID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
return completed, total, nil
}
// LmsUserLessonProgressInCourse returns completed and total lesson counts in a course (all modules).
func (s *Store) LmsUserLessonProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) {
total, err = s.queries.CountLessonsInCourse(ctx, courseID)
practiceTotal, err := s.queries.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedLessonsInCourse(ctx, dbgen.CountUserCompletedLessonsInCourseParams{
practiceCompleted, 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.
func (s *Store) LmsUserLessonProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) {
lessonTotal, err := s.queries.CountLessonsInCourse(ctx, 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
}
return completed, total, nil
}
// LmsUserLessonProgressInProgram returns completed and total lesson counts in a program (all courses).
func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, programID int64) (completed, total int32, err error) {
total, err = s.queries.CountLessonsInProgram(ctx, programID)
practiceTotal, err := s.queries.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedLessonsInProgram(ctx, dbgen.CountUserCompletedLessonsInProgramParams{
practiceCompleted, 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.
func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, programID int64) (completed, total int32, err error) {
lessonTotal, err := s.queries.CountLessonsInProgram(ctx, programID)
if err != nil {
return 0, 0, err
}
lessonCompleted, err := s.queries.CountUserCompletedLessonsInProgram(ctx, dbgen.CountUserCompletedLessonsInProgramParams{
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 to module, course, and program when the user
// has fully completed the preceding scope. Runs in a single transaction.
// CompleteLessonForUser records lesson completion and cascades completion upward when
// both lesson and related practice requirements are satisfied.
func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error {
q, tx, err := s.BeginTx(ctx)
if err != nil {
@ -31,56 +31,162 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6
if err != nil {
return err
}
nLess, err := q.CountLessonsInModule(ctx, lesson.ModuleID)
if err != nil {
if err := s.cascadeLMSCompletion(ctx, q, userID, mod.ID, crs.ID, crs.ProgramID); err != nil {
return err
}
nDoneLess, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
ModuleID: lesson.ModuleID,
UserID: userID,
})
if err != nil {
return err
}
if nLess > 0 && nDoneLess >= nLess {
if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: mod.ID}); err != nil {
return err
}
nMods, err := q.CountModulesInCourse(ctx, mod.CourseID)
if err != nil {
return err
}
nDoneMods, err := q.CountUserCompletedModulesInCourse(ctx, dbgen.CountUserCompletedModulesInCourseParams{
CourseID: mod.CourseID,
UserID: userID,
})
if err != nil {
return err
}
if nMods > 0 && nDoneMods >= nMods {
if err := q.InsertUserCourseProgress(ctx, dbgen.InsertUserCourseProgressParams{UserID: userID, CourseID: crs.ID}); err != nil {
return err
}
nCr, err := q.CountCoursesInProgram(ctx, crs.ProgramID)
if err != nil {
return err
}
nCrDone, err := q.CountUserCompletedCoursesInProgram(ctx, dbgen.CountUserCompletedCoursesInProgramParams{
ProgramID: crs.ProgramID,
UserID: userID,
})
if err != nil {
return err
}
if nCr > 0 && nCrDone >= nCr {
if err := q.InsertUserProgramProgress(ctx, dbgen.InsertUserProgramProgressParams{UserID: userID, ProgramID: crs.ProgramID}); err != nil {
return err
}
}
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
// CompletePracticeForUser records practice completion and cascades completion upward when
// both lesson and related practice requirements are satisfied.
func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error {
q, tx, err := s.BeginTx(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := q.MarkPracticeCompleted(ctx, dbgen.MarkPracticeCompletedParams{
UserID: userID,
QuestionSetID: questionSetID,
}); err != nil {
return err
}
scope, err := q.GetPracticeScopeByQuestionSetID(ctx, questionSetID)
if err != nil {
return err
}
if !scope.ModuleID.Valid {
return fmt.Errorf("practice %d is not linked to a module", questionSetID)
}
mod, err := q.GetModuleByID(ctx, scope.ModuleID.Int64)
if err != nil {
return err
}
crs, err := q.GetCourseByID(ctx, mod.CourseID)
if err != nil {
return err
}
if err := s.cascadeLMSCompletion(ctx, q, userID, mod.ID, crs.ID, crs.ProgramID); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID, moduleID, courseID, programID int64) error {
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
}
modulePracticesDone, err := q.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
ModuleID: toPgInt8(&moduleID),
UserID: userID,
})
if err != nil {
return err
}
moduleLessonsComplete := moduleLessonsTotal > 0 && moduleLessonsDone >= moduleLessonsTotal
modulePracticesComplete := modulePracticesDone >= modulePracticesTotal
if !moduleLessonsComplete || !modulePracticesComplete {
return nil
}
if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: moduleID}); err != nil {
return err
}
nMods, err := q.CountModulesInCourse(ctx, courseID)
if err != nil {
return err
}
nDoneMods, err := q.CountUserCompletedModulesInCourse(ctx, dbgen.CountUserCompletedModulesInCourseParams{
CourseID: courseID,
UserID: userID,
})
if err != nil {
return err
}
coursePracticesTotal, err := q.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
if err != nil {
return err
}
coursePracticesDone, err := q.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{
CourseID: toPgInt8(&courseID),
UserID: userID,
})
if err != nil {
return err
}
courseModulesComplete := nMods > 0 && nDoneMods >= nMods
coursePracticesComplete := coursePracticesDone >= coursePracticesTotal
if !courseModulesComplete || !coursePracticesComplete {
return nil
}
if err := q.InsertUserCourseProgress(ctx, dbgen.InsertUserCourseProgressParams{UserID: userID, CourseID: courseID}); err != nil {
return err
}
nCr, err := q.CountCoursesInProgram(ctx, programID)
if err != nil {
return err
}
nCrDone, err := q.CountUserCompletedCoursesInProgram(ctx, dbgen.CountUserCompletedCoursesInProgramParams{
ProgramID: programID,
UserID: userID,
})
if err != nil {
return err
}
programPracticesTotal, err := q.CountPublishedPracticesInProgram(ctx, programID)
if err != nil {
return err
}
programPracticesDone, err := q.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{
ProgramID: programID,
UserID: userID,
})
if err != nil {
return err
}
programCoursesComplete := nCr > 0 && nCrDone >= nCr
programPracticesComplete := programPracticesDone >= programPracticesTotal
if !programCoursesComplete || !programPracticesComplete {
return nil
}
if err := q.InsertUserProgramProgress(ctx, dbgen.InsertUserProgramProgressParams{UserID: userID, ProgramID: programID}); err != nil {
return err
}
return nil
}

View File

@ -31,6 +31,11 @@ func (s *Service) CompleteLessonForUser(ctx context.Context, userID, lessonID in
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)

View File

@ -1317,7 +1317,7 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
})
}
if err := h.questionsSvc.MarkPracticeCompleted(c.Context(), userID, set.ID); err != nil {
if err := h.lmsProgressSvc.CompletePracticeForUser(c.Context(), userID, set.ID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to complete practice",
Error: err.Error(),