@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
557 lines (510 loc) • 18.8 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 { GetCoursesParams, GetCoursesResult, CourseCatalogSummary, LearnWorldsCourse } from '../interfaces';
/**
* Raw course shape returned by the LearnWorlds API (snake_case).
* Kept file-local because it is an API implementation detail.
*/
interface LWApiCourse {
id?: string;
_id?: string;
title: string;
subtitle?: string;
description?: string;
short_description?: string;
excerpt?: string;
status?: string;
visibility?: string;
access_type?: string;
is_active?: boolean;
is_free?: boolean;
price?: number;
currency?: string;
original_price?: number;
category_id?: string;
category?: { id?: string; name?: string };
category_name?: string;
tags?: string[];
level?: string;
difficulty?: string;
language?: string;
duration?: number;
estimated_duration?: number;
thumbnail_url?: string;
image?: string;
cover_image_url?: string;
cover_image?: string;
promo_video_url?: string;
video_url?: string;
instructor_id?: string;
instructor?: { id?: string; name?: string; bio?: string; avatar_url?: string };
instructor_name?: string;
instructor_bio?: string;
instructor_avatar?: string;
author_id?: string;
author_name?: string;
total_units?: number;
sections_count?: number;
total_lessons?: number;
lessons_count?: number;
total_quizzes?: number;
quizzes_count?: number;
total_assignments?: number;
assignments_count?: number;
total_duration?: number;
total_enrollments?: number;
students_count?: number;
active_enrollments?: number;
active_students?: number;
completion_rate?: number;
average_rating?: number;
rating?: number;
total_reviews?: number;
reviews_count?: number;
created?: string | number;
created_at?: string | number;
updated?: string | number;
updated_at?: string | number;
published_at?: string | number;
enrollment_start?: string | number;
enrollment_end?: string | number;
requires_approval?: boolean;
has_prerequisites?: boolean;
prerequisites?: string[];
certificate_available?: boolean;
has_certificate?: boolean;
objectives?: string[];
learning_objectives?: string[];
target_audience?: string[];
requirements?: string[];
prerequisites_text?: string[];
}
/**
* Action to retrieve courses from LearnWorlds LMS
*/
export class GetLearnWorldsCoursesAction extends LearnWorldsBaseAction {
/**
* Description of the action
*/
public get Description(): string {
return 'Retrieves the course catalog from LearnWorlds with filtering, search, and sorting options';
}
/**
* Typed public method for direct (non-framework) callers.
* Sets company context, fetches courses, and returns a strongly-typed result.
* Throws on error.
*/
public async GetCourses(params: GetCoursesParams, contextUser: UserInfo): Promise<GetCoursesResult> {
this.SetCompanyContext(params.CompanyID);
const queryParams = this.buildQueryParams(params);
const courses = await this.makeLearnWorldsPaginatedRequest<LWApiCourse>('courses', queryParams, contextUser);
const mappedCourses: LearnWorldsCourse[] = courses.map((course) => this.mapLearnWorldsCourse(course));
const summary = this.calculateCourseCatalogSummary(mappedCourses);
return {
Courses: mappedCourses,
TotalCount: mappedCourses.length,
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.extractGetCoursesParams(Params);
const result = await this.GetCourses(typedParams, ContextUser);
this.setOutputParam(Params, 'Courses', result.Courses);
this.setOutputParam(Params, 'TotalCount', result.TotalCount);
this.setOutputParam(Params, 'Summary', result.Summary);
return this.buildSuccessResult(`Successfully retrieved ${result.TotalCount} courses from LearnWorlds`, Params);
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error occurred';
return this.buildErrorResult('ERROR', msg, Params);
}
}
/**
* Extract typed params from framework ActionParam[]
*/
private extractGetCoursesParams(params: ActionParam[]): GetCoursesParams {
return {
CompanyID: this.getRequiredStringParam(params, 'CompanyID'),
SearchText: this.getOptionalStringParam(params, 'SearchText'),
Status: this.getOptionalStringParam(params, 'Status'),
CategoryID: this.getOptionalStringParam(params, 'CategoryID'),
Level: this.getOptionalStringParam(params, 'Level'),
Language: this.getOptionalStringParam(params, 'Language'),
OnlyFree: this.getOptionalBooleanParam(params, 'OnlyFree', false),
MinPrice: this.getOptionalNumberParam(params, 'MinPrice', undefined),
MaxPrice: this.getOptionalNumberParam(params, 'MaxPrice', undefined),
Tags: this.getOptionalStringParam(params, 'Tags'),
InstructorID: this.getOptionalStringParam(params, 'InstructorID'),
CreatedAfter: this.getOptionalStringParam(params, 'CreatedAfter'),
CreatedBefore: this.getOptionalStringParam(params, 'CreatedBefore'),
SortBy: this.getOptionalStringParam(params, 'SortBy'),
SortOrder: (this.getOptionalStringParam(params, 'SortOrder') || 'desc') as 'asc' | 'desc',
IncludeEnrollmentStats: this.getOptionalBooleanParam(params, 'IncludeEnrollmentStats', true),
MaxResults: this.getOptionalNumberParam(params, 'MaxResults', LearnWorldsBaseAction.LW_MAX_PAGE_SIZE),
};
}
/**
* Build query parameters for the LearnWorlds API request
*/
private buildQueryParams(params: GetCoursesParams): Record<string, string | number | boolean> {
const queryParams: Record<string, string | number | boolean> = {};
if (params.SearchText) {
queryParams.search = params.SearchText;
}
if (params.Status) {
queryParams.status = params.Status;
}
if (params.CategoryID) {
queryParams.category_id = params.CategoryID;
}
if (params.Level) {
queryParams.level = params.Level;
}
if (params.Language) {
queryParams.language = params.Language;
}
if (params.OnlyFree) {
queryParams.is_free = true;
}
if (params.MinPrice != null && params.MinPrice > 0) {
queryParams.min_price = params.MinPrice;
}
if (params.MaxPrice != null && params.MaxPrice > 0) {
queryParams.max_price = params.MaxPrice;
}
if (params.Tags) {
queryParams.tags = params.Tags;
}
if (params.InstructorID) {
queryParams.instructor_id = params.InstructorID;
}
if (params.CreatedAfter) {
queryParams.created_after = this.formatLearnWorldsDate(new Date(params.CreatedAfter));
}
if (params.CreatedBefore) {
queryParams.created_before = this.formatLearnWorldsDate(new Date(params.CreatedBefore));
}
const sortBy = params.SortBy || 'created';
const sortOrder = params.SortOrder || 'desc';
queryParams.sort = `${sortOrder === 'asc' ? '' : '-'}${sortBy}`;
const includeEnrollmentStats = params.IncludeEnrollmentStats ?? true;
if (includeEnrollmentStats) {
queryParams.include = 'enrollment_stats';
}
const maxResults = params.MaxResults || 100;
queryParams.limit = Math.min(maxResults, 100);
return queryParams;
}
/**
* Map LearnWorlds course data to our interface
*/
private mapLearnWorldsCourse(lwCourse: LWApiCourse): LearnWorldsCourse {
return {
...this.mapCourseIdentity(lwCourse),
...this.mapCoursePricing(lwCourse),
...this.mapCourseInstructor(lwCourse),
...this.mapCourseContentStats(lwCourse),
...this.mapCourseMeta(lwCourse),
};
}
private mapCourseIdentity(lw: LWApiCourse): Pick<LearnWorldsCourse, 'id' | 'title' | 'subtitle' | 'description' | 'shortDescription' | 'status' | 'visibility' | 'isActive' | 'isFree' | 'categoryId' | 'categoryName' | 'tags' | 'level' | 'language' | 'duration' | 'thumbnailUrl' | 'coverImageUrl' | 'promoVideoUrl'> {
return {
id: lw.id || lw._id || '',
title: lw.title,
subtitle: lw.subtitle,
description: lw.description,
shortDescription: lw.short_description || lw.excerpt,
status: this.mapCourseStatus(lw.status || ''),
visibility: this.mapCourseVisibility(lw.visibility || lw.access_type || ''),
isActive: lw.is_active !== false,
isFree: lw.is_free || lw.price === 0 || false,
categoryId: lw.category_id || lw.category?.id,
categoryName: lw.category_name || lw.category?.name,
tags: lw.tags || [],
level: this.mapCourseLevel(lw.level || lw.difficulty || ''),
language: lw.language || 'en',
duration: lw.duration || lw.estimated_duration,
thumbnailUrl: lw.thumbnail_url || lw.image,
coverImageUrl: lw.cover_image_url || lw.cover_image,
promoVideoUrl: lw.promo_video_url || lw.video_url,
};
}
private mapCoursePricing(lw: LWApiCourse): Pick<LearnWorldsCourse, 'price' | 'currency' | 'originalPrice' | 'discountPercentage'> {
return {
price: lw.price,
currency: lw.currency || 'USD',
originalPrice: lw.original_price,
discountPercentage: this.calculateDiscountPercentage(lw.original_price, lw.price),
};
}
private mapCourseInstructor(lw: LWApiCourse): Pick<LearnWorldsCourse, 'instructorId' | 'instructorName' | 'instructorBio' | 'instructorAvatarUrl'> {
return {
instructorId: lw.instructor_id || lw.instructor?.id || lw.author_id,
instructorName: lw.instructor_name || lw.instructor?.name || lw.author_name,
instructorBio: lw.instructor_bio || lw.instructor?.bio,
instructorAvatarUrl: lw.instructor_avatar || lw.instructor?.avatar_url,
};
}
private mapCourseContentStats(lw: LWApiCourse): Pick<LearnWorldsCourse, 'totalUnits' | 'totalLessons' | 'totalQuizzes' | 'totalAssignments' | 'estimatedDuration' | 'totalEnrollments' | 'activeEnrollments' | 'completionRate' | 'averageRating' | 'totalReviews'> {
return {
totalUnits: lw.total_units || lw.sections_count || 0,
totalLessons: lw.total_lessons || lw.lessons_count || 0,
totalQuizzes: lw.total_quizzes || lw.quizzes_count || 0,
totalAssignments: lw.total_assignments || lw.assignments_count || 0,
estimatedDuration: lw.estimated_duration || lw.total_duration,
totalEnrollments: lw.total_enrollments || lw.students_count || 0,
activeEnrollments: lw.active_enrollments || lw.active_students || 0,
completionRate: lw.completion_rate || 0,
averageRating: lw.average_rating || lw.rating,
totalReviews: lw.total_reviews || lw.reviews_count,
};
}
private mapCourseMeta(lw: LWApiCourse): Pick<LearnWorldsCourse, 'createdAt' | 'updatedAt' | 'publishedAt' | 'enrollmentStartDate' | 'enrollmentEndDate' | 'requiresApproval' | 'hasPrerequisites' | 'prerequisites' | 'certificateAvailable' | 'objectives' | 'targetAudience' | 'requirements'> {
return {
createdAt: this.parseLearnWorldsDate(lw.created || lw.created_at || ''),
updatedAt: this.parseLearnWorldsDate(lw.updated || lw.updated_at || ''),
publishedAt: lw.published_at ? this.parseLearnWorldsDate(lw.published_at) : undefined,
enrollmentStartDate: lw.enrollment_start ? this.parseLearnWorldsDate(lw.enrollment_start) : undefined,
enrollmentEndDate: lw.enrollment_end ? this.parseLearnWorldsDate(lw.enrollment_end) : undefined,
requiresApproval: lw.requires_approval || false,
hasPrerequisites: lw.has_prerequisites || false,
prerequisites: lw.prerequisites || [],
certificateAvailable: lw.certificate_available || lw.has_certificate || false,
objectives: lw.objectives || lw.learning_objectives || [],
targetAudience: lw.target_audience || [],
requirements: lw.requirements || lw.prerequisites_text || [],
};
}
/**
* Map course status
*/
private mapCourseStatus(status: string): 'published' | 'draft' | 'coming_soon' {
const statusMap: Record<string, 'published' | 'draft' | 'coming_soon'> = {
published: 'published',
active: 'published',
draft: 'draft',
unpublished: 'draft',
coming_soon: 'coming_soon',
upcoming: 'coming_soon',
};
return statusMap[status?.toLowerCase()] || 'draft';
}
/**
* Map course visibility
*/
private mapCourseVisibility(visibility: string): 'public' | 'private' | 'hidden' {
const visibilityMap: Record<string, 'public' | 'private' | 'hidden'> = {
public: 'public',
open: 'public',
private: 'private',
closed: 'private',
hidden: 'hidden',
unlisted: 'hidden',
};
return visibilityMap[visibility?.toLowerCase()] || 'public';
}
/**
* Map course level
*/
private mapCourseLevel(level: string): 'beginner' | 'intermediate' | 'advanced' | 'all' {
const levelMap: Record<string, 'beginner' | 'intermediate' | 'advanced' | 'all'> = {
beginner: 'beginner',
basic: 'beginner',
introductory: 'beginner',
intermediate: 'intermediate',
medium: 'intermediate',
advanced: 'advanced',
expert: 'advanced',
all: 'all',
any: 'all',
mixed: 'all',
};
return levelMap[level?.toLowerCase()] || 'all';
}
/**
* Calculate discount percentage
*/
private calculateDiscountPercentage(originalPrice?: number, currentPrice?: number): number | undefined {
if (!originalPrice || !currentPrice || originalPrice <= currentPrice) {
return undefined;
}
return Math.round(((originalPrice - currentPrice) / originalPrice) * 100);
}
/**
* Calculate course catalog summary
*/
private calculateCourseCatalogSummary(courses: LearnWorldsCourse[]): CourseCatalogSummary {
const summary: CourseCatalogSummary = {
totalCourses: courses.length,
publishedCourses: courses.filter((c) => c.status === 'published').length,
draftCourses: courses.filter((c) => c.status === 'draft').length,
freeCourses: courses.filter((c) => c.isFree).length,
paidCourses: courses.filter((c) => !c.isFree).length,
categoryCounts: {},
levelCounts: {},
languageCounts: {},
enrollmentStats: {
totalEnrollments: 0,
averageEnrollmentsPerCourse: 0,
mostPopularCourses: [],
},
priceStats: {
averagePrice: 0,
minPrice: 0,
maxPrice: 0,
currency: 'USD',
},
};
this.populateCategoryCounts(courses, summary);
this.populateEnrollmentStats(courses, summary);
this.populatePriceStats(courses, summary);
return summary;
}
/**
* Populate category, level, and language counts in the summary
*/
private populateCategoryCounts(courses: LearnWorldsCourse[], summary: CourseCatalogSummary): void {
courses.forEach((course) => {
if (course.categoryName) {
summary.categoryCounts[course.categoryName] = (summary.categoryCounts[course.categoryName] || 0) + 1;
}
if (course.level) {
summary.levelCounts[course.level] = (summary.levelCounts[course.level] || 0) + 1;
}
if (course.language) {
summary.languageCounts[course.language] = (summary.languageCounts[course.language] || 0) + 1;
}
summary.enrollmentStats.totalEnrollments += course.totalEnrollments;
});
}
/**
* Populate enrollment statistics in the summary
*/
private populateEnrollmentStats(courses: LearnWorldsCourse[], summary: CourseCatalogSummary): void {
if (courses.length > 0) {
summary.enrollmentStats.averageEnrollmentsPerCourse = Math.round(summary.enrollmentStats.totalEnrollments / courses.length);
}
summary.enrollmentStats.mostPopularCourses = courses
.filter((c) => c.totalEnrollments > 0)
.sort((a, b) => b.totalEnrollments - a.totalEnrollments)
.slice(0, 5)
.map((c) => ({
id: c.id,
title: c.title,
enrollments: c.totalEnrollments,
}));
}
/**
* Populate price statistics in the summary
*/
private populatePriceStats(courses: LearnWorldsCourse[], summary: CourseCatalogSummary): void {
const paidCourses = courses.filter((c) => !c.isFree && c.price !== undefined);
if (paidCourses.length > 0) {
const prices = paidCourses.map((c) => c.price!);
summary.priceStats = {
averagePrice: Math.round(prices.reduce((sum, p) => sum + p, 0) / prices.length),
minPrice: Math.min(...prices),
maxPrice: Math.max(...prices),
currency: paidCourses[0].currency || 'USD',
};
}
}
/**
* Define the parameters for this action
*/
public get Params(): ActionParam[] {
const baseParams = this.getCommonLMSParams();
const specificParams: ActionParam[] = [
{
Name: 'SearchText',
Type: 'Input',
Value: null,
},
{
Name: 'Status',
Type: 'Input',
Value: null,
},
{
Name: 'CategoryID',
Type: 'Input',
Value: null,
},
{
Name: 'Level',
Type: 'Input',
Value: null,
},
{
Name: 'Language',
Type: 'Input',
Value: null,
},
{
Name: 'OnlyFree',
Type: 'Input',
Value: false,
},
{
Name: 'MinPrice',
Type: 'Input',
Value: null,
},
{
Name: 'MaxPrice',
Type: 'Input',
Value: null,
},
{
Name: 'Tags',
Type: 'Input',
Value: null,
},
{
Name: 'InstructorID',
Type: 'Input',
Value: null,
},
{
Name: 'CreatedAfter',
Type: 'Input',
Value: null,
},
{
Name: 'CreatedBefore',
Type: 'Input',
Value: null,
},
{
Name: 'SortBy',
Type: 'Input',
Value: 'created',
},
{
Name: 'SortOrder',
Type: 'Input',
Value: 'desc',
},
{
Name: 'IncludeEnrollmentStats',
Type: 'Input',
Value: true,
},
{
Name: 'MaxResults',
Type: 'Input',
Value: 100,
},
];
return [...baseParams, ...specificParams];
}
}