@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
427 lines (388 loc) • 13.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';
import { UserInfo } from '@memberjunction/core';
import {
GetCourseDetailsParams,
GetCourseDetailsResult,
CourseDetailsData,
CourseModule,
CourseLesson,
CourseInstructor,
CourseStats,
CourseDetailsSummary,
} from '../interfaces';
/**
* Raw API shape returned by the LearnWorlds GET /v2/courses/{id} endpoint.
* The v2 API returns the course object directly (no wrapper).
*/
interface LWApiCourseData {
id: string;
title: string;
slug?: string;
description?: string | null;
short_description?: string;
access?: string;
original_price?: number;
discount_price?: number;
final_price?: number;
currency?: string;
level?: string;
language?: string;
duration?: number;
total_enrollments?: number;
average_rating?: number;
total_ratings?: number;
tags?: string[];
categories?: string[];
courseImage?: string | null;
image_url?: string;
video_url?: string;
certificate_enabled?: boolean;
created?: number;
modified?: number;
created_at?: string;
updated_at?: string;
published_at?: string;
label?: string | null;
expires?: number | null;
expiresType?: string;
author?: { name?: string; image?: string | null } | null;
}
/**
* Raw API shape for sections/modules response
*/
interface LWApiSectionsResponse {
data?: LWApiModuleData[] | { data: LWApiModuleData[] };
}
interface LWApiModuleData {
id: string;
title: string;
description?: string;
order?: number;
position?: number;
duration?: number;
total_lessons?: number;
lessons?: LWApiLessonData[];
}
interface LWApiLessonData {
id: string;
title: string;
type?: string;
duration?: number;
order?: number;
position?: number;
is_free?: boolean;
has_video?: boolean;
has_quiz?: boolean;
has_assignment?: boolean;
}
/**
* Raw API shape for instructors response
*/
interface LWApiInstructorsResponse {
data?: LWApiInstructorData[] | { data: LWApiInstructorData[] };
}
interface LWApiInstructorData {
id: string;
name?: string;
first_name?: string;
last_name?: string;
email?: string;
bio?: string;
title?: string;
image_url?: string;
avatar_url?: string;
total_courses?: number;
total_students?: number;
average_rating?: number;
}
/**
* Raw API shape for stats response
*/
interface LWApiStatsResponse {
success?: boolean;
data?: {
total_enrollments?: number;
active_students?: number;
completion_rate?: number;
average_progress?: number;
average_time_to_complete?: number;
total_revenue?: number;
};
message?: string;
}
/**
* Action to retrieve comprehensive course details including curriculum structure
*/
(BaseAction, 'GetLearnWorldsCourseDetailsAction')
export class GetLearnWorldsCourseDetailsAction extends LearnWorldsBaseAction {
/**
* Metadata about this action
*/
public get Description(): string {
return 'Retrieves comprehensive details about a specific LearnWorlds course including curriculum structure';
}
/**
* Typed public method for direct (non-framework) callers.
* Sets company context, fetches course details, and returns a strongly-typed result.
* Throws on error.
*/
public async GetCourseDetails(params: GetCourseDetailsParams, contextUser: UserInfo): Promise<GetCourseDetailsResult> {
this.SetCompanyContext(params.CompanyID);
const {
CourseID: courseId,
IncludeModules: includeModules = true,
IncludeInstructors: includeInstructors = true,
IncludeStats: includeStats = true,
} = params;
if (!courseId) {
throw new Error('CourseID is required');
}
this.validatePathSegment(courseId, 'CourseID');
// Get course details — LW v2 API returns the course object directly
const course = await this.makeLearnWorldsRequest<LWApiCourseData>(`courses/${courseId}`, 'GET', undefined, contextUser);
if (!course || !course.id) {
throw new Error('Failed to retrieve course details');
}
const courseDetails = this.buildCourseDetails(course);
if (includeModules) {
await this.attachModules(courseId, courseDetails, contextUser);
}
if (includeInstructors) {
await this.attachInstructors(courseId, courseDetails, contextUser);
}
if (includeStats) {
await this.attachStats(courseId, courseDetails, contextUser);
}
const summary = this.buildCourseSummary(courseDetails);
return {
CourseDetails: courseDetails,
Summary: summary,
};
}
/**
* Framework entry-point – thin wrapper around the typed public method.
*/
protected async InternalRunAction(params: RunActionParams): Promise<ActionResultSimple> {
const { Params, ContextUser } = params;
this.params = Params;
try {
const typedParams = this.extractGetCourseDetailsParams(Params);
const result = await this.GetCourseDetails(typedParams, ContextUser);
this.setOutputParam(Params, 'CourseDetails', result.CourseDetails);
this.setOutputParam(Params, 'Summary', result.Summary);
return this.buildSuccessResult('Course details retrieved successfully', Params);
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error occurred';
return this.buildErrorResult('ERROR', `Error retrieving course details: ${msg}`, Params);
}
}
/**
* Extract typed params from framework ActionParam[]
*/
private extractGetCourseDetailsParams(params: ActionParam[]): GetCourseDetailsParams {
return {
CompanyID: this.getRequiredStringParam(params, 'CompanyID'),
CourseID: this.getRequiredStringParam(params, 'CourseID'),
IncludeModules: this.getOptionalBooleanParam(params, 'IncludeModules', true),
IncludeInstructors: this.getOptionalBooleanParam(params, 'IncludeInstructors', true),
IncludeStats: this.getOptionalBooleanParam(params, 'IncludeStats', true),
};
}
/**
* Build the base course details from raw API data
*/
private buildCourseDetails(course: LWApiCourseData): CourseDetailsData {
return {
id: course.id,
title: course.title,
slug: course.slug,
description: course.description || undefined,
shortDescription: course.short_description,
status: course.access || 'published',
price: course.final_price ?? course.original_price ?? 0,
originalPrice: course.original_price,
currency: course.currency || 'USD',
level: course.level || 'all',
language: course.language || 'en',
duration: course.duration,
durationText: this.formatDuration(course.duration || 0),
totalEnrollments: course.total_enrollments || 0,
averageRating: course.average_rating,
totalRatings: course.total_ratings || 0,
tags: course.tags || [],
categories: course.categories || [],
imageUrl: course.courseImage || course.image_url || undefined,
videoUrl: course.video_url,
certificateEnabled: course.certificate_enabled || false,
createdAt: course.created ? String(course.created) : course.created_at,
updatedAt: course.modified ? String(course.modified) : course.updated_at,
publishedAt: course.published_at,
};
}
/**
* Fetch and attach modules/curriculum to course details
*/
private async attachModules(courseId: string, courseDetails: CourseDetailsData, contextUser: UserInfo): Promise<void> {
try {
const modulesResponse = await this.makeLearnWorldsRequest<LWApiSectionsResponse>(`courses/${courseId}/sections`, 'GET', undefined, contextUser);
const rawModules = modulesResponse.data
? (Array.isArray(modulesResponse.data) ? modulesResponse.data : (modulesResponse.data as { data: LWApiModuleData[] }).data || [])
: [];
if (rawModules.length > 0) {
courseDetails.modules = this.formatModules(rawModules);
courseDetails.totalModules = courseDetails.modules.length;
courseDetails.totalLessons = courseDetails.modules.reduce((sum, mod) => sum + (mod.lessons?.length || 0), 0);
}
} catch (error) {
console.warn(`Sections endpoint unavailable for course ${courseId}:`, error instanceof Error ? error.message : error);
}
}
/**
* Fetch and attach instructors to course details
*/
private async attachInstructors(courseId: string, courseDetails: CourseDetailsData, contextUser: UserInfo): Promise<void> {
try {
const instructorsResponse = await this.makeLearnWorldsRequest<LWApiInstructorsResponse>(`courses/${courseId}/instructors`, 'GET', undefined, contextUser);
const rawInstructors = instructorsResponse.data
? (Array.isArray(instructorsResponse.data) ? instructorsResponse.data : (instructorsResponse.data as { data: LWApiInstructorData[] }).data || [])
: [];
if (rawInstructors.length > 0) {
courseDetails.instructors = this.formatInstructors(rawInstructors);
}
} catch (error) {
console.warn(`Instructors endpoint unavailable for course ${courseId}:`, error instanceof Error ? error.message : error);
}
}
/**
* Fetch and attach stats to course details
*/
private async attachStats(courseId: string, courseDetails: CourseDetailsData, contextUser: UserInfo): Promise<void> {
try {
const statsResponse = await this.makeLearnWorldsRequest<LWApiStatsResponse>(`courses/${courseId}/stats`, 'GET', undefined, contextUser);
if (statsResponse.data) {
const statsData = statsResponse.data;
courseDetails.stats = {
totalEnrollments: statsData.total_enrollments || courseDetails.totalEnrollments,
activeStudents: statsData.active_students || 0,
completionRate: statsData.completion_rate || 0,
averageProgressPercentage: statsData.average_progress || 0,
averageTimeToComplete: statsData.average_time_to_complete,
totalRevenue: statsData.total_revenue || 0,
} satisfies CourseStats;
}
} catch (error) {
console.warn(`Stats endpoint unavailable for course ${courseId}:`, error instanceof Error ? error.message : error);
}
}
/**
* Format course modules/sections data
*/
private formatModules(modules: LWApiModuleData[]): CourseModule[] {
return modules
.map((module) => ({
id: module.id,
title: module.title,
description: module.description,
order: module.order || module.position || 0,
duration: module.duration,
durationText: this.formatDuration(module.duration || 0),
totalLessons: module.total_lessons || module.lessons?.length || 0,
lessons: this.formatLessons(module.lessons || []),
}))
.sort((a, b) => a.order - b.order);
}
/**
* Format lesson data within a module
*/
private formatLessons(lessons: LWApiLessonData[]): CourseLesson[] {
return lessons.map((lesson) => ({
id: lesson.id,
title: lesson.title,
type: lesson.type || 'video',
duration: lesson.duration,
durationText: this.formatDuration(lesson.duration || 0),
order: lesson.order || lesson.position || 0,
isFree: lesson.is_free || false,
hasVideo: lesson.has_video || false,
hasQuiz: lesson.has_quiz || false,
hasAssignment: lesson.has_assignment || false,
}));
}
/**
* Format instructor data
*/
private formatInstructors(instructors: LWApiInstructorData[]): CourseInstructor[] {
return instructors.map((instructor) => ({
id: instructor.id,
name: instructor.name || `${instructor.first_name || ''} ${instructor.last_name || ''}`.trim(),
email: instructor.email,
bio: instructor.bio,
title: instructor.title,
imageUrl: instructor.image_url || instructor.avatar_url,
totalCourses: instructor.total_courses || 0,
totalStudents: instructor.total_students || 0,
averageRating: instructor.average_rating || 0,
}));
}
/**
* Build a summary from the course details
*/
private buildCourseSummary(courseDetails: CourseDetailsData): CourseDetailsSummary {
return {
courseId: courseDetails.id,
title: courseDetails.title,
status: courseDetails.status,
level: courseDetails.level,
duration: courseDetails.durationText,
totalModules: courseDetails.totalModules || 0,
totalLessons: courseDetails.totalLessons || 0,
totalEnrollments: courseDetails.totalEnrollments,
averageRating: courseDetails.averageRating || 0,
certificateEnabled: courseDetails.certificateEnabled,
price: courseDetails.price,
currency: courseDetails.currency,
};
}
/**
* Define the parameters this action expects
*/
public get Params(): ActionParam[] {
const baseParams = this.getCommonLMSParams();
const specificParams: ActionParam[] = [
{
Name: 'CourseID',
Type: 'Input',
Value: null,
},
{
Name: 'IncludeModules',
Type: 'Input',
Value: true,
},
{
Name: 'IncludeInstructors',
Type: 'Input',
Value: true,
},
{
Name: 'IncludeStats',
Type: 'Input',
Value: true,
},
{
Name: 'CourseDetails',
Type: 'Output',
Value: null,
},
{
Name: 'Summary',
Type: 'Output',
Value: null,
},
];
return [...baseParams, ...specificParams];
}
}