Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions BackendAcademy/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,6 +48,7 @@ import { SessionsModule } from './sessions/sessions.module';
OnboardingModule,
LessonModule,
TaskModule,
ProgressModule,
SearchModule,
PaymentsModule,
SessionsModule,
Expand Down
16 changes: 16 additions & 0 deletions BackendAcademy/src/courses/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
27 changes: 27 additions & 0 deletions BackendAcademy/src/courses/progress/dto/record-completion.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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',
}
108 changes: 108 additions & 0 deletions BackendAcademy/src/courses/progress/progress.controller.ts
Original file line number Diff line number Diff line change
@@ -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<IProgressSnapshot> {
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<ICourseSnapshot> {
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<void> {
await this.progressService.resetLearner(userId);
}
}
12 changes: 12 additions & 0 deletions BackendAcademy/src/courses/progress/progress.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading