diff --git a/BackendAcademy/src/app.module.ts b/BackendAcademy/src/app.module.ts index 4855c2d20..780eb1a56 100644 --- a/BackendAcademy/src/app.module.ts +++ b/BackendAcademy/src/app.module.ts @@ -17,6 +17,7 @@ import { SocialModule } from './social/social.module'; import { OnboardingModule } from './onboarding/onboarding.module'; import { LessonModule } from './lessons/lesson.module'; import { TaskModule } from './tasks/task.module'; +import { ProgressModule } from './courses/progress/progress.module'; import { AppConfigModule } from './config/config.module'; import { ContractsModule } from './contracts/contracts.module'; import { SearchModule } from './search/search.module'; @@ -47,6 +48,7 @@ import { SessionsModule } from './sessions/sessions.module'; OnboardingModule, LessonModule, TaskModule, + ProgressModule, SearchModule, PaymentsModule, SessionsModule, diff --git a/BackendAcademy/src/courses/index.ts b/BackendAcademy/src/courses/index.ts index f4a7b3363..fed20916e 100644 --- a/BackendAcademy/src/courses/index.ts +++ b/BackendAcademy/src/courses/index.ts @@ -5,3 +5,19 @@ export { CourseLevel } from './interfaces/course-level.enum'; export { ICourse, ILesson, ITask } from './interfaces/course.interface'; export { CreateCourseDto } from './dto/create-course.dto'; export { UpdateCourseDto } from './dto/update-course.dto'; +export { ProgressModule } from './progress/progress.module'; +export { ProgressService } from './progress/progress.service'; +export { ProgressController } from './progress/progress.controller'; +export { + RegisterCourseProgressDto, +} from './progress/dto/register-course-progress.dto'; +export { + RecordLessonCompletionDto, + RecordTaskCompletionDto, +} from './progress/dto/record-completion.dto'; +export { + CourseProgressStatus, + ICourseSnapshot, + IOverallSnapshot, + IProgressSnapshot, +} from './progress/interfaces/progress-snapshot.interface'; diff --git a/BackendAcademy/src/courses/progress/dto/record-completion.dto.ts b/BackendAcademy/src/courses/progress/dto/record-completion.dto.ts new file mode 100644 index 000000000..966973ef2 --- /dev/null +++ b/BackendAcademy/src/courses/progress/dto/record-completion.dto.ts @@ -0,0 +1,27 @@ +import { IsInt, IsOptional, IsString, Min } from 'class-validator'; + +export class RecordLessonCompletionDto { + @IsString() + lessonId: string; + + /** + * XP earned for this completion. Defaults to 0 if not provided. + */ + @IsOptional() + @IsInt() + @Min(0) + xpEarned?: number; +} + +export class RecordTaskCompletionDto { + @IsString() + taskId: string; + + /** + * XP earned for this completion. Defaults to 0 if not provided. + */ + @IsOptional() + @IsInt() + @Min(0) + xpEarned?: number; +} diff --git a/BackendAcademy/src/courses/progress/dto/register-course-progress.dto.ts b/BackendAcademy/src/courses/progress/dto/register-course-progress.dto.ts new file mode 100644 index 000000000..c43541cbc --- /dev/null +++ b/BackendAcademy/src/courses/progress/dto/register-course-progress.dto.ts @@ -0,0 +1,24 @@ +import { IsInt, IsOptional, IsString, Min } from 'class-validator'; + +export class RegisterCourseProgressDto { + @IsString() + courseId: string; + + /** + * Total number of lessons the learner needs to complete for this course. + * Optional - pass an explicit integer (including 0) to set; omitting the + * key on a re-register call keeps the previously registered value. + */ + @IsOptional() + @IsInt() + @Min(0) + totalLessons?: number; + + /** + * Total number of tasks the learner needs to complete for this course. + */ + @IsOptional() + @IsInt() + @Min(0) + totalTasks?: number; +} diff --git a/BackendAcademy/src/courses/progress/interfaces/progress-snapshot.interface.ts b/BackendAcademy/src/courses/progress/interfaces/progress-snapshot.interface.ts new file mode 100644 index 000000000..28bd7eedd --- /dev/null +++ b/BackendAcademy/src/courses/progress/interfaces/progress-snapshot.interface.ts @@ -0,0 +1,55 @@ +import { CourseLevel } from '../../interfaces/course-level.enum'; + +/** + * Aggregated progress for a single course within a learner's snapshot. + * `totalLessons` / `totalTasks` are populated when the course is registered + * with the progress service; until then we fall back to the raw counts + * relative to what has been completed. + */ +export interface ICourseSnapshot { + courseId: string; + title: string; + level: CourseLevel; + learningPathId: string; + status: CourseProgressStatus; + completionPercent: number; + lessonsCompleted: number; + totalLessons: number; + tasksCompleted: number; + totalTasks: number; + xpEarned: number; + xpAvailable: number; + startedAt: Date; + lastActivityAt: Date | null; + completedAt: Date | null; + /** + * Hint for UI "continue where you left off" affordances. Returns the + * first lesson id the learner recorded as completed for this course. + * Uses JavaScript Set insertion order, so when learners complete + * lessons sequentially it matches the chronological first; + * intentionally NOT a lesson-index lookup. + */ + firstCompletedLessonId: string | null; +} + +export interface IOverallSnapshot { + totalXp: number; + coursesCompleted: number; + coursesInProgress: number; + lessonsCompleted: number; + tasksCompleted: number; + lastActiveAt: Date | null; +} + +export interface IProgressSnapshot { + userId: string; + generatedAt: Date; + overall: IOverallSnapshot; + courses: ICourseSnapshot[]; +} + +export enum CourseProgressStatus { + NOT_STARTED = 'not_started', + IN_PROGRESS = 'in_progress', + COMPLETED = 'completed', +} diff --git a/BackendAcademy/src/courses/progress/progress.controller.ts b/BackendAcademy/src/courses/progress/progress.controller.ts new file mode 100644 index 000000000..015b45e6f --- /dev/null +++ b/BackendAcademy/src/courses/progress/progress.controller.ts @@ -0,0 +1,108 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + ParseUUIDPipe, + Post, + Put, +} from '@nestjs/common'; +import { RegisterCourseProgressDto } from './dto/register-course-progress.dto'; +import { + RecordLessonCompletionDto, + RecordTaskCompletionDto, +} from './dto/record-completion.dto'; +import { + ICourseSnapshot, + IProgressSnapshot, +} from './interfaces/progress-snapshot.interface'; +import { ProgressService } from './progress.service'; + +@Controller('courses/progress') +export class ProgressController { + constructor(private readonly progressService: ProgressService) {} + + /** + * GET /courses/progress/snapshot/:userId + * Returns the aggregated snapshot covering every course the learner has + * touched, sorted by most recently active. + */ + @Get('snapshot/:userId') + async getSnapshot( + @Param('userId', ParseUUIDPipe) userId: string, + ): Promise { + return this.progressService.getSnapshot(userId); + } + + /** + * GET /courses/progress/snapshot/:userId/course/:courseId + * Returns the snapshot scoped to a single course, or 404 when the learner + * has no progress row for the course yet. + */ + @Get('snapshot/:userId/course/:courseId') + async getCourseSnapshot( + @Param('userId', ParseUUIDPipe) userId: string, + @Param('courseId', ParseUUIDPipe) courseId: string, + ): Promise { + const snapshot = await this.progressService.getCourseSnapshot(userId, courseId); + if (!snapshot) { + throw new NotFoundException( + `No progress recorded for user ${userId} on course ${courseId}`, + ); + } + return snapshot; + } + + /** + * POST /courses/progress/:userId/courses + * Register a learner's enrollment in a course, optionally with the known + * lesson / task totals so completion percentages are meaningful. + */ + @Post(':userId/courses') + async registerCourse( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: RegisterCourseProgressDto, + ) { + return this.progressService.registerCourse(userId, dto); + } + + /** + * PUT /courses/progress/:userId/courses/:courseId/lessons + * Record that the learner completed a lesson in the given course. + */ + @Put(':userId/courses/:courseId/lessons') + async completeLesson( + @Param('userId', ParseUUIDPipe) userId: string, + @Param('courseId', ParseUUIDPipe) courseId: string, + @Body() dto: RecordLessonCompletionDto, + ) { + return this.progressService.recordLessonCompletion(userId, courseId, dto); + } + + /** + * PUT /courses/progress/:userId/courses/:courseId/tasks + * Record that the learner completed a task in the given course. + */ + @Put(':userId/courses/:courseId/tasks') + async completeTask( + @Param('userId', ParseUUIDPipe) userId: string, + @Param('courseId', ParseUUIDPipe) courseId: string, + @Body() dto: RecordTaskCompletionDto, + ) { + return this.progressService.recordTaskCompletion(userId, courseId, dto); + } + + /** + * DELETE /courses/progress/:userId + * Wipe the learner's snapshot. Primarily intended for tests and admin ops. + */ + @Delete(':userId') + @HttpCode(HttpStatus.NO_CONTENT) + async reset(@Param('userId', ParseUUIDPipe) userId: string): Promise { + await this.progressService.resetLearner(userId); + } +} diff --git a/BackendAcademy/src/courses/progress/progress.module.ts b/BackendAcademy/src/courses/progress/progress.module.ts new file mode 100644 index 000000000..145389da8 --- /dev/null +++ b/BackendAcademy/src/courses/progress/progress.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { CourseModule } from '../course.module'; +import { ProgressController } from './progress.controller'; +import { ProgressService } from './progress.service'; + +@Module({ + imports: [CourseModule], + controllers: [ProgressController], + providers: [ProgressService], + exports: [ProgressService], +}) +export class ProgressModule {} diff --git a/BackendAcademy/src/courses/progress/progress.service.spec.ts b/BackendAcademy/src/courses/progress/progress.service.spec.ts new file mode 100644 index 000000000..b509b782d --- /dev/null +++ b/BackendAcademy/src/courses/progress/progress.service.spec.ts @@ -0,0 +1,360 @@ +import { NotFoundException } from '@nestjs/common'; +import { CourseService } from '../course.service'; +import { CourseLevel } from '../interfaces/course-level.enum'; +import { RegisterCourseProgressDto } from './dto/register-course-progress.dto'; +import { + RecordLessonCompletionDto, + RecordTaskCompletionDto, +} from './dto/record-completion.dto'; +import { + CourseProgressStatus, +} from './interfaces/progress-snapshot.interface'; +import { ProgressService } from './progress.service'; + +/** + * Build a fresh CourseService and seed two known courses so tests can + * snapshot progress against stable ids. Course ids come from + * courseService.create() because CreateCourseDto doesn't accept an id. + */ +async function buildServices() { + const courseService = new CourseService(); + const service = new ProgressService(courseService); + const x = await courseService.create({ + title: 'Rust Basics', + description: 'A starter course', + level: CourseLevel.BEGINNER, + order: 1, + learningPathId: 'path-rust-basics', + duration: 60, + xpReward: 200, + }); + const y = await courseService.create({ + title: 'Rust Web3', + description: 'Web3 specialization', + level: CourseLevel.WEB3, + order: 2, + learningPathId: 'path-rust-web3', + duration: 90, + xpReward: 400, + }); + return { service, courseService, courseX: x.id, courseY: y.id }; +} + +const USER_A = '00000000-0000-4000-8000-0000000000aa'; +const USER_B = '00000000-0000-4000-8000-0000000000bb'; + +describe('ProgressService', () => { + let service: ProgressService; + let courseService: CourseService; + let courseX: string; + let courseY: string; + + beforeEach(async () => { + ({ service, courseService, courseX, courseY } = await buildServices()); + }); + + it('returns an empty snapshot for an unknown learner', async () => { + const snapshot = await service.getSnapshot(USER_A); + + expect(snapshot.userId).toBe(USER_A); + expect(snapshot.courses).toEqual([]); + expect(snapshot.overall).toEqual({ + totalXp: 0, + coursesCompleted: 0, + coursesInProgress: 0, + lessonsCompleted: 0, + tasksCompleted: 0, + lastActiveAt: null, + }); + expect(snapshot.generatedAt).toBeInstanceOf(Date); + }); + + it('registerCourse() throws NotFoundException when the course does not exist', async () => { + const dto: RegisterCourseProgressDto = { + courseId: 'not-a-real-course', + totalLessons: 5, + }; + + await expect(service.registerCourse(USER_A, dto)).rejects.toThrow( + NotFoundException, + ); + }); + + it('registerCourse() stores totals and seeds a placeholder progress row', async () => { + const record = await service.registerCourse(USER_A, { + courseId: courseX, + totalLessons: 4, + totalTasks: 2, + }); + + expect(record.courseId).toBe(courseX); + expect(record.totalLessons).toBe(4); + expect(record.totalTasks).toBe(2); + expect(record.completedLessonIds.size).toBe(0); + expect(record.completedAt).toBeNull(); + }); + + it('registerCourse() preserves completions on re-registration; explicit values overwrite', async () => { + await service.registerCourse(USER_A, { courseId: courseX, totalLessons: 4 }); + await service.recordLessonCompletion(USER_A, courseX, { + lessonId: 'lesson-1', + xpEarned: 20, + }); + + // An explicit totalTasks=3 on a second call fills the previously-empty + // tasks total; an explicit totalLessons=0 is a *valid* value meaning + // "no lessons required" and overwrites the prior 4. The critical + // guarantee is that prior completions survive the re-register. + const updated = await service.registerCourse(USER_A, { + courseId: courseX, + totalLessons: 0, + totalTasks: 3, + }); + + expect(updated.totalLessons).toBe(0); + expect(updated.totalTasks).toBe(3); + expect(updated.completedLessonIds.has('lesson-1')).toBe(true); + }); + + it('omitting a total on re-register keeps the previously registered value', async () => { + await service.registerCourse(USER_A, { + courseId: courseX, + totalLessons: 4, + totalTasks: 2, + }); + + // Total tasks is omitted -> prior value (2) is kept. Total lessons is + // undefined -> prior value (4) is kept. + const updated = await service.registerCourse(USER_A, { + courseId: courseX, + }); + + expect(updated.totalLessons).toBe(4); + expect(updated.totalTasks).toBe(2); + }); + + it('recordLessonCompletion() is idempotent for repeated lesson ids', async () => { + await service.registerCourse(USER_A, { courseId: courseX, totalLessons: 3 }); + const dto: RecordLessonCompletionDto = { lessonId: 'lesson-1', xpEarned: 50 }; + + await service.recordLessonCompletion(USER_A, courseX, dto); + const second = await service.recordLessonCompletion(USER_A, courseX, dto); + + expect(second.completedLessonIds.size).toBe(1); + expect(second.xpEarned).toBe(50); + }); + + it('recordTaskCompletion() accumulates XP per distinct task id', async () => { + await service.registerCourse(USER_A, { + courseId: courseX, + totalLessons: 2, + totalTasks: 3, + }); + + await service.recordTaskCompletion(USER_A, courseX, { + taskId: 'task-1', + xpEarned: 10, + }); + await service.recordTaskCompletion(USER_A, courseX, { + taskId: 'task-2', + xpEarned: 15, + }); + await service.recordTaskCompletion(USER_A, courseX, { + taskId: 'task-1', + xpEarned: 10, + }); + + const snapshot = await service.getCourseSnapshot(USER_A, courseX); + expect(snapshot?.xpEarned).toBe(25); + expect(snapshot?.tasksCompleted).toBe(2); + }); + + it('auto-registers a course when completion is recorded before registerCourse', async () => { + const record = await service.recordLessonCompletion(USER_A, courseX, { + lessonId: 'lesson-1', + xpEarned: 5, + }); + + expect(record.courseId).toBe(courseX); + expect(record.totalLessons).toBe(0); + expect(record.completedLessonIds.has('lesson-1')).toBe(true); + }); + + it('throws when recording completion for an unknown course', async () => { + await expect( + service.recordLessonCompletion(USER_A, 'bogus-course-id', { + lessonId: 'l1', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('marks a course as COMPLETED only when lesson and task targets are both met', async () => { + await service.registerCourse(USER_A, { + courseId: courseX, + totalLessons: 2, + totalTasks: 1, + }); + + await service.recordLessonCompletion(USER_A, courseX, { + lessonId: 'l1', + xpEarned: 0, + }); + await service.recordLessonCompletion(USER_A, courseX, { + lessonId: 'l2', + xpEarned: 0, + }); + let snapshot = await service.getCourseSnapshot(USER_A, courseX); + expect(snapshot?.status).toBe(CourseProgressStatus.IN_PROGRESS); + + await service.recordTaskCompletion(USER_A, courseX, { + taskId: 't1', + xpEarned: 0, + }); + snapshot = await service.getCourseSnapshot(USER_A, courseX); + expect(snapshot?.status).toBe(CourseProgressStatus.COMPLETED); + expect(snapshot?.completedAt).toBeInstanceOf(Date); + }); + + it('returns IN_PROGRESS for a partially completed course', async () => { + await service.registerCourse(USER_A, { courseId: courseX, totalLessons: 5 }); + await service.recordLessonCompletion(USER_A, courseX, { + lessonId: 'l1', + xpEarned: 10, + }); + + const snapshot = await service.getCourseSnapshot(USER_A, courseX); + expect(snapshot?.status).toBe(CourseProgressStatus.IN_PROGRESS); + // (1/5) * 0.7 weighted + 0 tasks = 0.14 / 0.7 = 0.2 -> 20% + expect(snapshot?.completionPercent).toBe(20); + }); + + it('stays NOT_STARTED when no lessons/tasks have been recorded', async () => { + await service.registerCourse(USER_A, { + courseId: courseX, + totalLessons: 5, + totalTasks: 3, + }); + + const snapshot = await service.getCourseSnapshot(USER_A, courseX); + expect(snapshot?.status).toBe(CourseProgressStatus.NOT_STARTED); + }); + + it('emits a 0% completion when no totals are registered', async () => { + await service.recordLessonCompletion(USER_A, courseX, { lessonId: 'l1' }); + + const snapshot = await service.getCourseSnapshot(USER_A, courseX); + expect(snapshot?.completionPercent).toBe(0); + }); + + it('aggregates overall stats across multiple courses', async () => { + // Course X: complete everything + await service.registerCourse(USER_A, { + courseId: courseX, + totalLessons: 1, + totalTasks: 1, + }); + await service.recordLessonCompletion(USER_A, courseX, { + lessonId: 'l1', + xpEarned: 25, + }); + await service.recordTaskCompletion(USER_A, courseX, { + taskId: 't1', + xpEarned: 25, + }); + + // Course Y: partial + await service.registerCourse(USER_A, { courseId: courseY, totalLessons: 4 }); + await service.recordLessonCompletion(USER_A, courseY, { + lessonId: 'l1', + xpEarned: 10, + }); + + const snapshot = await service.getSnapshot(USER_A); + + expect(snapshot.overall.totalXp).toBe(60); + expect(snapshot.overall.coursesCompleted).toBe(1); + expect(snapshot.overall.coursesInProgress).toBe(1); + expect(snapshot.overall.lessonsCompleted).toBe(2); + expect(snapshot.overall.tasksCompleted).toBe(1); + expect(snapshot.overall.lastActiveAt).toBeInstanceOf(Date); + + expect(snapshot.courses).toHaveLength(2); + }); + + it('skips courses whose underlying course record was deleted', async () => { + await service.registerCourse(USER_A, { courseId: courseX, totalLessons: 1 }); + await courseService.remove(courseX); + + const snapshot = await service.getSnapshot(USER_A); + expect(snapshot.courses).toEqual([]); + expect(snapshot.overall.coursesInProgress).toBe(0); + }); + + it('getCourseSnapshot() returns null for a course the learner never touched', async () => { + expect(await service.getCourseSnapshot(USER_A, courseX)).toBeNull(); + }); + + it('isolates progress between learners', async () => { + await service.registerCourse(USER_A, { courseId: courseX, totalLessons: 1 }); + await service.recordLessonCompletion(USER_A, courseX, { + lessonId: 'l1', + xpEarned: 5, + }); + + const a = await service.getSnapshot(USER_A); + const b = await service.getSnapshot(USER_B); + + expect(a.overall.totalXp).toBe(5); + expect(a.courses).toHaveLength(1); + expect(b.courses).toHaveLength(0); + expect(b.overall.totalXp).toBe(0); + }); + + it('resetLearner() clears the learner state', async () => { + await service.registerCourse(USER_A, { courseId: courseX }); + await service.recordLessonCompletion(USER_A, courseX, { lessonId: 'l1' }); + + expect(await service.resetLearner(USER_A)).toBe(true); + const snapshot = await service.getSnapshot(USER_A); + expect(snapshot.courses).toEqual([]); + }); + + it('exposes the expected xpAvailable from course.xpReward', async () => { + await service.registerCourse(USER_A, { courseId: courseX }); + const snap = await service.getCourseSnapshot(USER_A, courseX); + expect(snap?.xpAvailable).toBe(200); + }); + + it('pushes untouched courses to the end of the snapshot', async () => { + // Register an untouched course first. + await service.registerCourse(USER_A, { courseId: courseY, totalLessons: 5 }); + + await new Promise((resolve) => setTimeout(resolve, 5)); + // Then register and touch a second course. + await service.registerCourse(USER_A, { courseId: courseX, totalLessons: 5 }); + await service.recordLessonCompletion(USER_A, courseX, { lessonId: 'l-x' }); + + const snapshot = await service.getSnapshot(USER_A); + const orderedIds = snapshot.courses.map((c) => c.courseId); + + // courseX was touched (first), courseY was not touched (last). + expect(orderedIds[0]).toBe(courseX); + expect(orderedIds[orderedIds.length - 1]).toBe(courseY); + }); + + it('exposes firstCompletedLessonId hint from prior lesson completions', async () => { + await service.registerCourse(USER_A, { courseId: courseX }); + await service.recordLessonCompletion(USER_A, courseX, { + lessonId: 'l-first', + }); + + const snap = await service.getCourseSnapshot(USER_A, courseX); + expect(snap?.firstCompletedLessonId).toBe('l-first'); + + // When no lessons are completed the hint is null. + await service.resetLearner(USER_A); + await service.registerCourse(USER_A, { courseId: courseX }); + const fresh = await service.getCourseSnapshot(USER_A, courseX); + expect(fresh?.firstCompletedLessonId).toBeNull(); + }); +}); diff --git a/BackendAcademy/src/courses/progress/progress.service.ts b/BackendAcademy/src/courses/progress/progress.service.ts new file mode 100644 index 000000000..f29e541fa --- /dev/null +++ b/BackendAcademy/src/courses/progress/progress.service.ts @@ -0,0 +1,366 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CourseEntity } from '../course.entity'; +import { CourseService } from '../course.service'; +import { RegisterCourseProgressDto } from './dto/register-course-progress.dto'; +import { + RecordLessonCompletionDto, + RecordTaskCompletionDto, +} from './dto/record-completion.dto'; +import { + CourseProgressStatus, + ICourseSnapshot, + IOverallSnapshot, + IProgressSnapshot, +} from './interfaces/progress-snapshot.interface'; + +/** + * Weight applied to lesson completion and task completion when deriving + * the `completionPercent` field on the snapshot. Lessons are weighted + * higher because they represent the primary learning surface; tasks are + * treated as supplementary assessments. + */ +const LESSON_COMPLETION_WEIGHT = 0.7; +const TASK_COMPLETION_WEIGHT = 0.3; + +interface CourseProgressRecord { + courseId: string; + totalLessons: number; + totalTasks: number; + completedLessonIds: Set; + completedTaskIds: Set; + xpEarned: number; + startedAt: Date; + lastActivityAt: Date | null; + completedAt: Date | null; +} + +interface LearnerProgressRecord { + userId: string; + courses: Map; +} + +/** + * In-memory store for learner progress snapshots, scoped to the courses + * module. Uses CourseService to hydrate course metadata (title, level, + * learningPathId, xpReward) on the snapshot. The service is intentionally + * self-contained so the courses feature remains easy to evolve in isolation. + */ +@Injectable() +export class ProgressService { + private readonly learners: Map = new Map(); + + constructor(private readonly courseService: CourseService) {} + + /** + * Register that a learner is enrolled in a course and (optionally) lock + * in the totals used to compute completion percentages. + * + * Behaviour for re-registration: + * - `startedAt` is preserved. + * - `totalLessons` / `totalTasks` are only updated when the call site + * supplies an explicit value (including `0`). Omitting a field keeps + * the previously registered value. + * - Previously recorded lesson/task completions are NEVER cleared. + */ + async registerCourse( + userId: string, + dto: RegisterCourseProgressDto, + ): Promise { + const course = await this.courseService.findById(dto.courseId); + if (!course) { + throw new NotFoundException(`Course ${dto.courseId} not found`); + } + + const learner = this.ensureLearner(userId); + const existing = learner.courses.get(dto.courseId); + + if (existing) { + if (dto.totalLessons !== undefined) existing.totalLessons = dto.totalLessons; + if (dto.totalTasks !== undefined) existing.totalTasks = dto.totalTasks; + return existing; + } + + const record: CourseProgressRecord = { + courseId: dto.courseId, + totalLessons: dto.totalLessons ?? 0, + totalTasks: dto.totalTasks ?? 0, + completedLessonIds: new Set(), + completedTaskIds: new Set(), + xpEarned: 0, + startedAt: new Date(), + lastActivityAt: null, + completedAt: null, + }; + + learner.courses.set(dto.courseId, record); + return record; + } + + /** + * Record a lesson completion for (user, course). Repeating the same + * lesson id is a no-op for counters/xp but always refreshes the + * lastActivityAt timestamp. + */ + async recordLessonCompletion( + userId: string, + courseId: string, + dto: RecordLessonCompletionDto, + ): Promise { + const record = await this.ensureCourseRecord(userId, courseId); + + const xp = dto.xpEarned ?? 0; + if (!record.completedLessonIds.has(dto.lessonId)) { + record.completedLessonIds.add(dto.lessonId); + record.xpEarned += xp; + } + record.lastActivityAt = new Date(); + this.maybeMarkCompleted(record); + return record; + } + + /** + * Record a task completion for (user, course). + */ + async recordTaskCompletion( + userId: string, + courseId: string, + dto: RecordTaskCompletionDto, + ): Promise { + const record = await this.ensureCourseRecord(userId, courseId); + + const xp = dto.xpEarned ?? 0; + if (!record.completedTaskIds.has(dto.taskId)) { + record.completedTaskIds.add(dto.taskId); + record.xpEarned += xp; + } + record.lastActivityAt = new Date(); + this.maybeMarkCompleted(record); + return record; + } + + /** + * Compute the full snapshot for a learner, aggregating per-course state + * into overall totals and resolving course metadata via CourseService. + */ + async getSnapshot(userId: string): Promise { + const learner = this.learners.get(userId); + + const overall: IOverallSnapshot = { + totalXp: 0, + coursesCompleted: 0, + coursesInProgress: 0, + lessonsCompleted: 0, + tasksCompleted: 0, + lastActiveAt: null, + }; + + if (!learner) { + return { + userId, + generatedAt: new Date(), + overall, + courses: [], + }; + } + + const courses: ICourseSnapshot[] = []; + let latestActivity: Date | null = null; + + for (const record of learner.courses.values()) { + const course = await this.courseService.findById(record.courseId); + if (!course) { + continue; + } + + const status = this.computeStatus(record); + const completionPercent = this.computeCompletionPercent(record); + + if (status === CourseProgressStatus.COMPLETED) { + overall.coursesCompleted += 1; + } else if ( + record.completedLessonIds.size > 0 || + record.completedTaskIds.size > 0 + ) { + overall.coursesInProgress += 1; + } + + overall.totalXp += record.xpEarned; + overall.lessonsCompleted += record.completedLessonIds.size; + overall.tasksCompleted += record.completedTaskIds.size; + + if (record.lastActivityAt) { + if (!latestActivity || record.lastActivityAt > latestActivity) { + latestActivity = record.lastActivityAt; + } + } + + courses.push(this.buildCourseSnapshot(record, course, status, completionPercent)); + } + + overall.lastActiveAt = latestActivity; + courses.sort((a, b) => { + // Push untouched (NOT_STARTED) courses to the end so consumers see + // active work first. Among touched courses, sort by most recent + // activity; ties fall back to a stable id-based comparator. + const aUntouched = a.lastActivityAt == null; + const bUntouched = b.lastActivityAt == null; + if (aUntouched !== bUntouched) { + return aUntouched ? 1 : -1; + } + const aTs = a.lastActivityAt?.getTime() ?? 0; + const bTs = b.lastActivityAt?.getTime() ?? 0; + if (bTs !== aTs) return bTs - aTs; + return a.courseId.localeCompare(b.courseId); + }); + + return { + userId, + generatedAt: new Date(), + overall, + courses, + }; + } + + /** + * Compute the snapshot for a single (user, course) pair. Returns null when + * the learner has no progress row for that course so callers can distinguish + * "no progress yet" from "progress exists". + */ + async getCourseSnapshot( + userId: string, + courseId: string, + ): Promise { + const learner = this.learners.get(userId); + const record = learner?.courses.get(courseId); + if (!record) return null; + + const course = await this.courseService.findById(courseId); + if (!course) return null; + + const status = this.computeStatus(record); + const completionPercent = this.computeCompletionPercent(record); + return this.buildCourseSnapshot(record, course, status, completionPercent); + } + + /** + * Remove all progress for a learner. Useful for tests and admin tooling. + */ + async resetLearner(userId: string): Promise { + return this.learners.delete(userId); + } + + // ------------------ internals ------------------ + + private ensureLearner(userId: string): LearnerProgressRecord { + let learner = this.learners.get(userId); + if (!learner) { + learner = { userId, courses: new Map() }; + this.learners.set(userId, learner); + } + return learner; + } + + private async ensureCourseRecord( + userId: string, + courseId: string, + ): Promise { + const learner = this.ensureLearner(userId); + const existing = learner.courses.get(courseId); + if (existing) return existing; + + // Auto-register so completion recordings never require a separate + // register call. Totals stay at 0 until the caller provides them. + return this.registerCourse(userId, { courseId }); + } + + private maybeMarkCompleted(record: CourseProgressRecord): void { + if (record.totalLessons === 0 && record.totalTasks === 0) { + // No totals registered - we can't know if it's "completed". + record.completedAt = null; + return; + } + + const lessonsTargetMet = + record.totalLessons === 0 || + record.completedLessonIds.size >= record.totalLessons; + const tasksTargetMet = + record.totalTasks === 0 || + record.completedTaskIds.size >= record.totalTasks; + + if (lessonsTargetMet && tasksTargetMet && !record.completedAt) { + record.completedAt = new Date(); + } else if (!lessonsTargetMet || !tasksTargetMet) { + record.completedAt = null; + } + } + + private computeStatus(record: CourseProgressRecord): CourseProgressStatus { + if (record.completedAt) return CourseProgressStatus.COMPLETED; + if ( + record.completedLessonIds.size > 0 || + record.completedTaskIds.size > 0 + ) { + return CourseProgressStatus.IN_PROGRESS; + } + return CourseProgressStatus.NOT_STARTED; + } + + private computeCompletionPercent(record: CourseProgressRecord): number { + const weightLesson = record.totalLessons > 0 ? LESSON_COMPLETION_WEIGHT : 0; + const weightTask = record.totalTasks > 0 ? TASK_COMPLETION_WEIGHT : 0; + const totalWeight = weightLesson + weightTask; + + // If neither weight is known we report 0% rather than dividing by zero. + if (totalWeight === 0) return 0; + + const lessonPart = + record.totalLessons > 0 + ? record.completedLessonIds.size / record.totalLessons + : 0; + const taskPart = + record.totalTasks > 0 + ? record.completedTaskIds.size / record.totalTasks + : 0; + + const ratio = (lessonPart * weightLesson + taskPart * weightTask) / totalWeight; + return Math.max(0, Math.min(100, Math.round(ratio * 100))); + } + + private buildCourseSnapshot( + record: CourseProgressRecord, + course: CourseEntity, + status: CourseProgressStatus, + completionPercent: number, + ): ICourseSnapshot { + return { + courseId: record.courseId, + title: course.title, + level: course.level, + learningPathId: course.learningPathId, + status, + completionPercent, + lessonsCompleted: record.completedLessonIds.size, + totalLessons: record.totalLessons, + tasksCompleted: record.completedTaskIds.size, + totalTasks: record.totalTasks, + xpEarned: record.xpEarned, + xpAvailable: course.xpReward, + startedAt: record.startedAt, + lastActivityAt: record.lastActivityAt, + completedAt: record.completedAt, + firstCompletedLessonId: this.findFirstCompletedLessonId(record), + }; + } + + /** + * Returns the first lesson the learner recorded as completed for the + * given course. JavaScript Set iteration order is insertion order, so + * when learners record completions sequentially the result matches the + * chronological first; this is NOT a lesson-index lookup. + */ + private findFirstCompletedLessonId(record: CourseProgressRecord): string | null { + if (record.completedLessonIds.size === 0) return null; + const [first] = record.completedLessonIds; + return first ?? null; + } +}