@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
626 lines (552 loc) • 22.7 kB
text/typescript
import { RegisterClass } from '@memberjunction/global';
import { LearnWorldsBaseAction } from '../learnworlds-base.action';
import { ActionParam, ActionResultSimple, RunActionParams } from '@memberjunction/actions-base';
import { BaseAction } from '@memberjunction/actions';
/**
* Interface for user progress in a course
*/
export interface CourseProgress {
courseId: string;
courseTitle: string;
enrollmentId: string;
enrolledAt: Date;
lastAccessedAt?: Date;
// Progress metrics
progressPercentage: number;
completedUnits: number;
totalUnits: number;
completedLessons: number;
totalLessons: number;
// Time metrics
totalTimeSpent: number;
averageSessionTime: number;
estimatedTimeToComplete?: number;
// Performance metrics
quizScoreAverage?: number;
assignmentsCompleted?: number;
assignmentsTotal?: number;
currentGrade?: number;
// Status
status: 'not_started' | 'in_progress' | 'completed' | 'expired';
completedAt?: Date;
expiresAt?: Date;
certificateEarned: boolean;
certificateUrl?: string;
// Unit-level progress
unitProgress?: UnitProgress[];
}
/**
* Interface for progress within a course unit/module
*/
export interface UnitProgress {
unitId: string;
unitTitle: string;
unitType: 'section' | 'chapter' | 'module';
order: number;
// Progress
progressPercentage: number;
completedLessons: number;
totalLessons: number;
timeSpent: number;
// Status
status: 'not_started' | 'in_progress' | 'completed';
startedAt?: Date;
completedAt?: Date;
// Lesson details
lessons?: LessonProgress[];
}
/**
* Interface for individual lesson progress
*/
export interface LessonProgress {
lessonId: string;
lessonTitle: string;
lessonType: 'video' | 'text' | 'quiz' | 'assignment' | 'scorm' | 'interactive';
order: number;
// Progress
completed: boolean;
progressPercentage: number;
timeSpent: number;
// Timestamps
startedAt?: Date;
completedAt?: Date;
lastAccessedAt?: Date;
// Additional data for specific types
videoWatchTime?: number;
videoTotalTime?: number;
quizScore?: number;
quizMaxScore?: number;
quizAttempts?: number;
assignmentSubmitted?: boolean;
assignmentGrade?: number;
}
/**
* Interface for overall user learning progress
*/
export interface UserLearningProgress {
userId: string;
userEmail: string;
totalCourses: number;
coursesCompleted: number;
coursesInProgress: number;
coursesNotStarted: number;
overallProgressPercentage: number;
totalTimeSpent: number;
totalCertificatesEarned: number;
averageQuizScore?: number;
courses: CourseProgress[];
// Analytics
learningStreak?: number;
lastLearningDate?: Date;
mostActiveDay?: string;
preferredLearningTime?: string;
}
/**
* Action to retrieve detailed progress information for a LearnWorlds user
*/
(BaseAction, 'GetLearnWorldsUserProgressAction')
export class GetLearnWorldsUserProgressAction extends LearnWorldsBaseAction {
/**
* Description of the action
*/
public get Description(): string {
return 'Retrieves comprehensive learning progress for a user in LearnWorlds, including course completion, time spent, and detailed unit/lesson progress';
}
/**
* Main execution method
*/
protected async InternalRunAction(params: RunActionParams): Promise<ActionResultSimple> {
try {
const contextUser = params.ContextUser;
if (!contextUser) {
return {
Success: false,
ResultCode: 'ERROR',
Message: 'Context user is required for LearnWorlds API calls'
};
}
// Store params for base class methods
this.params = params.Params;
// Get user ID
const userId = this.getParamValue(params.Params, 'UserID');
if (!userId) {
return {
Success: false,
ResultCode: 'ERROR',
Message: 'UserID parameter is required'
};
}
// Optional course ID for specific course progress
const courseId = this.getParamValue(params.Params, 'CourseID');
const includeUnitDetails = this.getParamValue(params.Params, 'IncludeUnitDetails') ?? false;
const includeLessonDetails = this.getParamValue(params.Params, 'IncludeLessonDetails') ?? false;
let userProgress: UserLearningProgress;
if (courseId) {
// Get progress for specific course
const courseProgress = await this.getCourseProgress(userId, courseId, includeUnitDetails, includeLessonDetails, contextUser);
// Create user progress object with single course
userProgress = {
userId,
userEmail: '', // Will be populated if we get user info
totalCourses: 1,
coursesCompleted: courseProgress.status === 'completed' ? 1 : 0,
coursesInProgress: courseProgress.status === 'in_progress' ? 1 : 0,
coursesNotStarted: courseProgress.status === 'not_started' ? 1 : 0,
overallProgressPercentage: courseProgress.progressPercentage,
totalTimeSpent: courseProgress.totalTimeSpent,
totalCertificatesEarned: courseProgress.certificateEarned ? 1 : 0,
courses: [courseProgress]
};
} else {
// Get progress for all courses
userProgress = await this.getAllCoursesProgress(userId, includeUnitDetails, includeLessonDetails, contextUser);
}
// Try to get user email
try {
const userInfo = await this.makeLearnWorldsRequest<any>(
`users/${userId}`,
'GET',
undefined,
contextUser
);
userProgress.userEmail = userInfo.email || '';
} catch {
// Ignore if we can't get user info
}
// Calculate analytics
this.calculateLearningAnalytics(userProgress);
// Create output parameters
if (!params.Params.find(p => p.Name === 'UserProgress')) {
params.Params.push({
Name: 'UserProgress',
Type: 'Output',
Value: userProgress
});
} else {
params.Params.find(p => p.Name === 'UserProgress')!.Value = userProgress;
}
if (!params.Params.find(p => p.Name === 'Summary')) {
params.Params.push({
Name: 'Summary',
Type: 'Output',
Value: this.createProgressSummary(userProgress)
});
} else {
params.Params.find(p => p.Name === 'Summary')!.Value = this.createProgressSummary(userProgress);
}
return {
Success: true,
ResultCode: 'SUCCESS',
Message: courseId ?
`Successfully retrieved progress for course ${courseId}` :
`Successfully retrieved progress for ${userProgress.totalCourses} courses`
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
return {
Success: false,
ResultCode: 'ERROR',
Message: errorMessage
};
}
}
/**
* Get progress for a specific course
*/
private async getCourseProgress(
userId: string,
courseId: string,
includeUnits: boolean,
includeLessons: boolean,
contextUser: any
): Promise<CourseProgress> {
// Get enrollment/progress data
const progressResponse = await this.makeLearnWorldsRequest<any>(
`users/${userId}/courses/${courseId}/progress`,
'GET',
undefined,
contextUser
);
const progress = this.mapCourseProgress(progressResponse);
// Get unit-level details if requested
if (includeUnits) {
try {
const unitsResponse = await this.makeLearnWorldsRequest<{
data: any[]
}>(
`users/${userId}/courses/${courseId}/units`,
'GET',
undefined,
contextUser
);
if (unitsResponse.data) {
progress.unitProgress = await Promise.all(
unitsResponse.data.map(unit =>
this.mapUnitProgress(unit, userId, courseId, includeLessons, contextUser)
)
);
}
} catch {
// Units endpoint might not be available
}
}
return progress;
}
/**
* Get progress for all user courses
*/
private async getAllCoursesProgress(
userId: string,
includeUnits: boolean,
includeLessons: boolean,
contextUser: any
): Promise<UserLearningProgress> {
// Get all enrollments
const enrollmentsResponse = await this.makeLearnWorldsRequest<{
data: any[]
}>(
`users/${userId}/enrollments`,
'GET',
undefined,
contextUser
);
const courseProgressPromises = enrollmentsResponse.data.map(enrollment =>
this.getCourseProgress(
userId,
enrollment.course_id || enrollment.course?.id,
includeUnits,
includeLessons,
contextUser
).catch(() => null) // Handle individual course failures gracefully
);
const courseProgressResults = await Promise.all(courseProgressPromises);
const validCourses = courseProgressResults.filter(p => p !== null) as CourseProgress[];
// Calculate aggregated metrics
const completed = validCourses.filter(c => c.status === 'completed').length;
const inProgress = validCourses.filter(c => c.status === 'in_progress').length;
const notStarted = validCourses.filter(c => c.status === 'not_started').length;
const totalTime = validCourses.reduce((sum, c) => sum + c.totalTimeSpent, 0);
const totalCerts = validCourses.filter(c => c.certificateEarned).length;
// Calculate overall progress
let overallProgress = 0;
if (validCourses.length > 0) {
const totalProgress = validCourses.reduce((sum, c) => sum + c.progressPercentage, 0);
overallProgress = Math.round(totalProgress / validCourses.length);
}
// Calculate average quiz score if available
const coursesWithQuizzes = validCourses.filter(c => c.quizScoreAverage !== undefined);
const avgQuizScore = coursesWithQuizzes.length > 0 ?
coursesWithQuizzes.reduce((sum, c) => sum + (c.quizScoreAverage || 0), 0) / coursesWithQuizzes.length :
undefined;
return {
userId,
userEmail: '',
totalCourses: validCourses.length,
coursesCompleted: completed,
coursesInProgress: inProgress,
coursesNotStarted: notStarted,
overallProgressPercentage: overallProgress,
totalTimeSpent: totalTime,
totalCertificatesEarned: totalCerts,
averageQuizScore: avgQuizScore,
courses: validCourses
};
}
/**
* Map course progress data
*/
private mapCourseProgress(data: any): CourseProgress {
const progress = this.calculateProgress(data.progress || data);
const status = this.determineCourseStatus(data);
return {
courseId: data.course_id || data.course?.id,
courseTitle: data.course_title || data.course?.title || 'Unknown Course',
enrollmentId: data.enrollment_id || data.id,
enrolledAt: this.parseLearnWorldsDate(data.enrolled_at || data.created),
lastAccessedAt: data.last_accessed_at ? this.parseLearnWorldsDate(data.last_accessed_at) : undefined,
progressPercentage: progress.percentage,
completedUnits: progress.completedUnits,
totalUnits: progress.totalUnits,
completedLessons: data.completed_lessons || 0,
totalLessons: data.total_lessons || 0,
totalTimeSpent: progress.timeSpent,
averageSessionTime: data.average_session_time || 0,
estimatedTimeToComplete: this.calculateEstimatedTimeToComplete(data),
quizScoreAverage: data.quiz_score_average,
assignmentsCompleted: data.assignments_completed,
assignmentsTotal: data.assignments_total,
currentGrade: data.current_grade || data.grade,
status,
completedAt: data.completed_at ? this.parseLearnWorldsDate(data.completed_at) : undefined,
expiresAt: data.expires_at ? this.parseLearnWorldsDate(data.expires_at) : undefined,
certificateEarned: data.certificate_earned || false,
certificateUrl: data.certificate_url
};
}
/**
* Map unit progress data
*/
private async mapUnitProgress(
unitData: any,
userId: string,
courseId: string,
includeLessons: boolean,
contextUser: any
): Promise<UnitProgress> {
const unitProgress: UnitProgress = {
unitId: unitData.id || unitData.unit_id,
unitTitle: unitData.title || unitData.name,
unitType: unitData.type || 'section',
order: unitData.order || unitData.position || 0,
progressPercentage: unitData.progress_percentage || 0,
completedLessons: unitData.completed_lessons || 0,
totalLessons: unitData.total_lessons || 0,
timeSpent: unitData.time_spent || 0,
status: this.determineUnitStatus(unitData),
startedAt: unitData.started_at ? this.parseLearnWorldsDate(unitData.started_at) : undefined,
completedAt: unitData.completed_at ? this.parseLearnWorldsDate(unitData.completed_at) : undefined
};
// Get lesson details if requested
if (includeLessons && unitData.lessons) {
unitProgress.lessons = unitData.lessons.map((lesson: any) => this.mapLessonProgress(lesson));
}
return unitProgress;
}
/**
* Map lesson progress data
*/
private mapLessonProgress(lessonData: any): LessonProgress {
return {
lessonId: lessonData.id || lessonData.lesson_id,
lessonTitle: lessonData.title || lessonData.name,
lessonType: this.mapLessonType(lessonData.type),
order: lessonData.order || lessonData.position || 0,
completed: lessonData.completed || false,
progressPercentage: lessonData.progress_percentage || (lessonData.completed ? 100 : 0),
timeSpent: lessonData.time_spent || 0,
startedAt: lessonData.started_at ? this.parseLearnWorldsDate(lessonData.started_at) : undefined,
completedAt: lessonData.completed_at ? this.parseLearnWorldsDate(lessonData.completed_at) : undefined,
lastAccessedAt: lessonData.last_accessed_at ? this.parseLearnWorldsDate(lessonData.last_accessed_at) : undefined,
videoWatchTime: lessonData.video_watch_time,
videoTotalTime: lessonData.video_total_time,
quizScore: lessonData.quiz_score,
quizMaxScore: lessonData.quiz_max_score,
quizAttempts: lessonData.quiz_attempts,
assignmentSubmitted: lessonData.assignment_submitted,
assignmentGrade: lessonData.assignment_grade
};
}
/**
* Determine course status from data
*/
private determineCourseStatus(data: any): 'not_started' | 'in_progress' | 'completed' | 'expired' {
if (data.expired || (data.expires_at && new Date(data.expires_at) < new Date())) {
return 'expired';
}
if (data.completed || data.progress_percentage === 100) {
return 'completed';
}
if (data.progress_percentage > 0 || data.started) {
return 'in_progress';
}
return 'not_started';
}
/**
* Determine unit status
*/
private determineUnitStatus(data: any): 'not_started' | 'in_progress' | 'completed' {
if (data.completed || data.progress_percentage === 100) {
return 'completed';
}
if (data.progress_percentage > 0 || data.started) {
return 'in_progress';
}
return 'not_started';
}
/**
* Map lesson type
*/
private mapLessonType(type: string): 'video' | 'text' | 'quiz' | 'assignment' | 'scorm' | 'interactive' {
const typeMap: Record<string, 'video' | 'text' | 'quiz' | 'assignment' | 'scorm' | 'interactive'> = {
'video': 'video',
'text': 'text',
'quiz': 'quiz',
'exam': 'quiz',
'assignment': 'assignment',
'scorm': 'scorm',
'interactive': 'interactive',
'multimedia': 'interactive'
};
return typeMap[type?.toLowerCase()] || 'text';
}
/**
* Calculate estimated time to complete
*/
private calculateEstimatedTimeToComplete(data: any): number | undefined {
if (data.estimated_time_to_complete) {
return data.estimated_time_to_complete;
}
// Estimate based on progress
if (data.total_time_spent && data.progress_percentage > 0 && data.progress_percentage < 100) {
const timePerPercent = data.total_time_spent / data.progress_percentage;
const remainingPercent = 100 - data.progress_percentage;
return Math.round(timePerPercent * remainingPercent);
}
return undefined;
}
/**
* Calculate learning analytics
*/
private calculateLearningAnalytics(progress: UserLearningProgress): void {
if (progress.courses.length === 0) return;
// Find last learning date
const lastDates = progress.courses
.map(c => c.lastAccessedAt)
.filter(d => d !== undefined) as Date[];
if (lastDates.length > 0) {
progress.lastLearningDate = new Date(Math.max(...lastDates.map(d => d.getTime())));
// Calculate learning streak (simplified - days since last activity)
const daysSinceLastActivity = Math.floor(
(new Date().getTime() - progress.lastLearningDate.getTime()) / (1000 * 60 * 60 * 24)
);
progress.learningStreak = daysSinceLastActivity <= 1 ? 1 : 0;
}
}
/**
* Create progress summary
*/
private createProgressSummary(progress: UserLearningProgress): any {
const activeCourses = progress.courses.filter(c => c.status === 'in_progress');
const recentActivity = progress.courses
.filter(c => c.lastAccessedAt)
.sort((a, b) => (b.lastAccessedAt?.getTime() || 0) - (a.lastAccessedAt?.getTime() || 0))
.slice(0, 5);
return {
overview: {
totalCourses: progress.totalCourses,
completedCourses: progress.coursesCompleted,
inProgressCourses: progress.coursesInProgress,
notStartedCourses: progress.coursesNotStarted,
overallProgress: `${progress.overallProgressPercentage}%`,
certificatesEarned: progress.totalCertificatesEarned,
totalLearningTime: this.formatDuration(progress.totalTimeSpent)
},
performance: {
averageQuizScore: progress.averageQuizScore ? `${Math.round(progress.averageQuizScore)}%` : 'N/A',
averageCourseProgress: `${progress.overallProgressPercentage}%`,
completionRate: progress.totalCourses > 0 ?
`${Math.round((progress.coursesCompleted / progress.totalCourses) * 100)}%` : '0%'
},
currentFocus: activeCourses.map(c => ({
courseTitle: c.courseTitle,
progress: `${c.progressPercentage}%`,
timeSpent: this.formatDuration(c.totalTimeSpent),
estimatedTimeToComplete: c.estimatedTimeToComplete ?
this.formatDuration(c.estimatedTimeToComplete) : 'N/A'
})),
recentActivity: recentActivity.map(c => ({
courseTitle: c.courseTitle,
lastAccessed: c.lastAccessedAt,
progress: `${c.progressPercentage}%`
})),
achievements: {
totalCertificates: progress.totalCertificatesEarned,
coursesWithCertificates: progress.courses
.filter(c => c.certificateEarned)
.map(c => ({
courseTitle: c.courseTitle,
completedAt: c.completedAt
}))
}
};
}
/**
* Define the parameters for this action
*/
public get Params(): ActionParam[] {
const baseParams = this.getCommonLMSParams();
const specificParams: ActionParam[] = [
{
Name: 'UserID',
Type: 'Input',
Value: null
},
{
Name: 'CourseID',
Type: 'Input',
Value: null
},
{
Name: 'IncludeUnitDetails',
Type: 'Input',
Value: false
},
{
Name: 'IncludeLessonDetails',
Type: 'Input',
Value: false
}
];
return [...baseParams, ...specificParams];
}
}