@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
494 lines (424 loc) • 18.3 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 {
GetCourseAnalyticsParams,
GetCourseAnalyticsResult,
CourseAnalyticsData,
TrendDataPoint,
CourseAnalyticsSummary,
CourseAnalyticsUserBreakdown,
CourseAnalyticsModuleStat,
CourseAnalyticsLessonStat,
} from '../interfaces';
// ----------------------------------------------------------------
// File-local interfaces for raw LearnWorlds API shapes
// ----------------------------------------------------------------
/** Raw analytics payload from the LearnWorlds analytics endpoint */
interface LWRawAnalyticsData {
total_enrollments?: number;
new_enrollments?: number;
active_students?: number;
enrollment_trend?: LWRawTrendDataPoint[];
average_progress?: number;
completion_rate?: number;
total_completions?: number;
in_progress_count?: number;
not_started_count?: number;
dropout_rate?: number;
average_time_spent?: number;
total_time_spent?: number;
average_session_duration?: number;
last_activity_date?: string;
daily_active_users?: LWRawTrendDataPoint[];
average_quiz_score?: number;
pass_rate?: number;
certificates_issued?: number;
average_time_to_complete?: number;
}
/** Raw trend data point from LearnWorlds API */
interface LWRawTrendDataPoint {
date?: string;
value?: number;
label?: string;
}
/** Wrapper for the analytics API response */
interface LWAnalyticsApiResponse {
success?: boolean;
message?: string;
data?: LWRawAnalyticsData;
}
/** Raw revenue data from the LearnWorlds revenue endpoint */
interface LWRawRevenueData {
total_revenue?: number;
currency?: string;
average_order_value?: number;
total_orders?: number;
revenue_trend?: LWRawTrendDataPoint[];
top_markets?: LWRawTrendDataPoint[];
}
/** Wrapper for the revenue API response */
interface LWRevenueApiResponse {
success?: boolean;
data?: LWRawRevenueData;
}
/** Raw module stats from the LearnWorlds module analytics endpoint */
interface LWRawModuleStat {
id?: string;
title?: string;
completion_rate?: number;
average_progress?: number;
average_time_spent?: number;
students_started?: number;
students_completed?: number;
lessons?: LWRawLessonStat[];
}
/** Raw lesson stat nested inside a module */
interface LWRawLessonStat {
id?: string;
title?: string;
completion_rate?: number;
average_time_spent?: number;
view_count?: number;
}
/** Wrapper for the module analytics response */
interface LWModuleStatsApiResponse {
success?: boolean;
data?: LWRawModuleStat[] | { data?: LWRawModuleStat[] };
}
/** Raw enrollment from the enrollments endpoint (for user breakdown) */
interface LWRawEnrollmentForBreakdown {
progress_percentage?: number;
}
/** Wrapper for the enrollments response */
interface LWEnrollmentsApiResponse {
success?: boolean;
data?: LWRawEnrollmentForBreakdown[] | { data?: LWRawEnrollmentForBreakdown[] };
}
/** Progress distribution buckets for user breakdown */
interface ProgressBuckets {
notStarted: number;
under25: number;
between25And50: number;
between50And75: number;
between75And99: number;
completed: number;
}
/**
* Action to retrieve comprehensive analytics for a LearnWorlds course
*/
export class GetCourseAnalyticsAction extends LearnWorldsBaseAction {
// ----------------------------------------------------------------
// Typed public method – can be called directly from code
// ----------------------------------------------------------------
/**
* Get course performance analytics.
* Throws on any error.
*/
public async GetCourseAnalytics(params: GetCourseAnalyticsParams, contextUser: UserInfo): Promise<GetCourseAnalyticsResult> {
this.SetCompanyContext(params.CompanyID);
const {
CourseID: courseId,
DateFrom: dateFrom,
DateTo: dateTo,
IncludeUserBreakdown: includeUserBreakdown = false,
IncludeModuleStats: includeModuleStatsRaw,
IncludeRevenue: includeRevenueRaw,
} = params;
const includeModuleStats = includeModuleStatsRaw !== false;
const includeRevenue = includeRevenueRaw !== false;
if (!courseId) {
throw new Error('CourseID is required');
}
this.validatePathSegment(courseId, 'CourseID');
// Build date query string
const queryString = this.buildDateQueryString(dateFrom, dateTo);
// Fetch core analytics
const analyticsData = await this.fetchCoreAnalytics(courseId, queryString, contextUser);
// Build the typed analytics object
const analytics = this.buildBaseAnalytics(courseId, dateFrom, dateTo, analyticsData);
// Optionally fetch revenue data
if (includeRevenue) {
analytics.revenue = await this.fetchRevenueData(courseId, queryString, contextUser);
}
// Optionally fetch module stats
if (includeModuleStats) {
analytics.moduleStats = await this.fetchModuleStats(courseId, contextUser);
}
// Optionally fetch user breakdown
if (includeUserBreakdown) {
analytics.userBreakdown = await this.fetchUserBreakdown(courseId, contextUser);
}
// Build summary
const summary = this.buildAnalyticsSummary(courseId, analytics);
return {
CourseAnalytics: analytics,
Summary: summary,
};
}
// ----------------------------------------------------------------
// Framework wrapper – thin delegation to the public method
// ----------------------------------------------------------------
protected async InternalRunAction(params: RunActionParams): Promise<ActionResultSimple> {
const { Params, ContextUser } = params;
this.params = Params;
try {
const typedParams = this.extractCourseAnalyticsParams(Params);
const result = await this.GetCourseAnalytics(typedParams, ContextUser);
this.setOutputParam(Params, 'CourseAnalytics', result.CourseAnalytics);
this.setOutputParam(Params, 'Summary', result.Summary);
return this.buildSuccessResult('Course analytics retrieved successfully', Params);
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
return this.buildErrorResult('ERROR', `Error retrieving course analytics: ${msg}`, Params);
}
}
// ----------------------------------------------------------------
// Private helpers – parameter extraction
// ----------------------------------------------------------------
private extractCourseAnalyticsParams(params: ActionParam[]): GetCourseAnalyticsParams {
return {
CompanyID: this.getRequiredStringParam(params, 'CompanyID'),
CourseID: this.getRequiredStringParam(params, 'CourseID'),
DateFrom: this.getOptionalStringParam(params, 'DateFrom'),
DateTo: this.getOptionalStringParam(params, 'DateTo'),
IncludeUserBreakdown: this.getOptionalBooleanParam(params, 'IncludeUserBreakdown', undefined),
IncludeModuleStats: this.getOptionalBooleanParam(params, 'IncludeModuleStats', undefined),
IncludeRevenue: this.getOptionalBooleanParam(params, 'IncludeRevenue', undefined),
};
}
// ----------------------------------------------------------------
// Private helpers – API calls
// ----------------------------------------------------------------
private buildDateQueryString(dateFrom?: string, dateTo?: string): string {
const queryParams: Record<string, string> = {};
const parsedFrom = this.safeParseDateToISO(dateFrom);
if (parsedFrom) {
queryParams.date_from = parsedFrom.split('T')[0];
}
const parsedTo = this.safeParseDateToISO(dateTo);
if (parsedTo) {
queryParams.date_to = parsedTo.split('T')[0];
}
const keys = Object.keys(queryParams);
if (keys.length === 0) return '';
return '?' + new URLSearchParams(queryParams).toString();
}
private async fetchCoreAnalytics(courseId: string, queryString: string, contextUser: UserInfo): Promise<LWRawAnalyticsData> {
const response = await this.makeLearnWorldsRequest<LWAnalyticsApiResponse>(`/courses/${courseId}/analytics${queryString}`, 'GET', null, contextUser);
if (response.success === false) {
throw new Error(response.message || 'Failed to retrieve analytics');
}
return response.data || {};
}
private async fetchRevenueData(courseId: string, queryString: string, contextUser: UserInfo): Promise<CourseAnalyticsData['revenue']> {
const response = await this.makeLearnWorldsRequest<LWRevenueApiResponse>(`/courses/${courseId}/revenue${queryString}`, 'GET', null, contextUser);
if (response.success !== false && response.data) {
return {
totalRevenue: response.data.total_revenue || 0,
currency: response.data.currency || 'USD',
averageOrderValue: response.data.average_order_value || 0,
totalOrders: response.data.total_orders || 0,
revenueTrend: response.data.revenue_trend || [],
topMarkets: response.data.top_markets || [],
};
}
return undefined;
}
private async fetchModuleStats(courseId: string, contextUser: UserInfo): Promise<CourseAnalyticsModuleStat[] | undefined> {
const response = await this.makeLearnWorldsRequest<LWModuleStatsApiResponse>(`/courses/${courseId}/modules/analytics`, 'GET', null, contextUser);
if (response.success !== false && response.data) {
const rawModules = Array.isArray(response.data) ? response.data : response.data.data;
if (rawModules) {
return this.formatModuleStats(rawModules);
}
}
return undefined;
}
private async fetchUserBreakdown(courseId: string, contextUser: UserInfo): Promise<CourseAnalyticsUserBreakdown | undefined> {
const enrollmentQs = '?' + new URLSearchParams({ limit: '1000', include: 'progress' }).toString();
const response = await this.makeLearnWorldsRequest<LWEnrollmentsApiResponse>(`/courses/${courseId}/enrollments${enrollmentQs}`, 'GET', null, contextUser);
if (response.success !== false && response.data) {
const enrollments = Array.isArray(response.data) ? response.data : response.data.data;
if (enrollments) {
return this.calculateUserBreakdown(enrollments);
}
}
return undefined;
}
// ----------------------------------------------------------------
// Private helpers – data building
// ----------------------------------------------------------------
private buildBaseAnalytics(courseId: string, dateFrom: string | undefined, dateTo: string | undefined, data: LWRawAnalyticsData): CourseAnalyticsData {
return {
courseId,
period: {
from: dateFrom || 'all-time',
to: dateTo || 'current',
},
enrollment: {
totalEnrollments: data.total_enrollments || 0,
newEnrollments: data.new_enrollments || 0,
activeStudents: data.active_students || 0,
enrollmentTrend: data.enrollment_trend || [],
},
progress: {
averageProgressPercentage: data.average_progress || 0,
completionRate: data.completion_rate || 0,
totalCompletions: data.total_completions || 0,
inProgressCount: data.in_progress_count || 0,
notStartedCount: data.not_started_count || 0,
dropoutRate: data.dropout_rate || 0,
},
engagement: {
averageTimeSpent: data.average_time_spent || 0,
averageTimeSpentText: this.formatDuration(data.average_time_spent || 0),
totalTimeSpent: data.total_time_spent || 0,
totalTimeSpentText: this.formatDuration(data.total_time_spent || 0),
averageSessionDuration: data.average_session_duration || 0,
lastActivityDate: data.last_activity_date,
dailyActiveUsers: data.daily_active_users || [],
},
performance: {
averageQuizScore: data.average_quiz_score || 0,
passRate: data.pass_rate || 0,
certificatesIssued: data.certificates_issued || 0,
averageTimeToComplete: data.average_time_to_complete || 0,
averageTimeToCompleteText: this.formatDuration(data.average_time_to_complete || 0),
},
};
}
// ----------------------------------------------------------------
// Private helpers – module stats formatting
// ----------------------------------------------------------------
private formatModuleStats(modules: LWRawModuleStat[]): CourseAnalyticsModuleStat[] {
return modules.map((module) => ({
moduleId: module.id || '',
moduleTitle: module.title || '',
completionRate: module.completion_rate || 0,
averageProgress: module.average_progress || 0,
averageTimeSpent: module.average_time_spent || 0,
averageTimeSpentText: this.formatDuration(module.average_time_spent || 0),
studentsStarted: module.students_started || 0,
studentsCompleted: module.students_completed || 0,
lessons: (module.lessons || []).map((lesson) => ({
lessonId: lesson.id || '',
lessonTitle: lesson.title || '',
completionRate: lesson.completion_rate || 0,
averageTimeSpent: lesson.average_time_spent || 0,
viewCount: lesson.view_count || 0,
})),
}));
}
// ----------------------------------------------------------------
// Private helpers – user breakdown
// ----------------------------------------------------------------
private calculateUserBreakdown(enrollments: LWRawEnrollmentForBreakdown[]): CourseAnalyticsUserBreakdown {
const buckets: ProgressBuckets = {
notStarted: 0,
under25: 0,
between25And50: 0,
between50And75: 0,
between75And99: 0,
completed: 0,
};
for (const enrollment of enrollments) {
const progress = enrollment.progress_percentage || 0;
if (progress === 0) buckets.notStarted++;
else if (progress < 25) buckets.under25++;
else if (progress < 50) buckets.between25And50++;
else if (progress < 75) buckets.between50And75++;
else if (progress < 100) buckets.between75And99++;
else buckets.completed++;
}
const total = enrollments.length;
const pct = (count: number): string => (total > 0 ? ((count / total) * 100).toFixed(1) : '0.0');
return {
total,
progressDistribution: buckets,
percentageDistribution: {
notStarted: pct(buckets.notStarted),
under25: pct(buckets.under25),
between25And50: pct(buckets.between25And50),
between50And75: pct(buckets.between50And75),
between75And99: pct(buckets.between75And99),
completed: pct(buckets.completed),
},
};
}
// ----------------------------------------------------------------
// Private helpers – trend calculations
// ----------------------------------------------------------------
private calculateGrowthRate(trend: TrendDataPoint[]): string {
if (!trend || trend.length < 2) return 'insufficient-data';
const recent = trend[trend.length - 1]?.value || 0;
const previous = trend[trend.length - 2]?.value || 0;
if (previous === 0) return 'new';
const growthRate = ((recent - previous) / previous) * 100;
if (growthRate > 10) return 'high-growth';
if (growthRate > 0) return 'growing';
if (growthRate === 0) return 'stable';
return 'declining';
}
private calculateEngagementTrend(dailyActiveUsers: TrendDataPoint[]): string {
if (!dailyActiveUsers || dailyActiveUsers.length < 7) return 'insufficient-data';
const recentAvg = dailyActiveUsers.slice(-7).reduce((sum, day) => sum + (day.value || 0), 0) / 7;
const previousAvg = dailyActiveUsers.slice(-14, -7).reduce((sum, day) => sum + (day.value || 0), 0) / 7;
if (previousAvg === 0) return 'new';
const change = ((recentAvg - previousAvg) / previousAvg) * 100;
if (change > 5) return 'increasing';
if (change < -5) return 'decreasing';
return 'stable';
}
// ----------------------------------------------------------------
// Private helpers – summary
// ----------------------------------------------------------------
private buildAnalyticsSummary(courseId: string, analytics: CourseAnalyticsData): CourseAnalyticsSummary {
return {
courseId,
period: analytics.period,
keyMetrics: {
totalEnrollments: analytics.enrollment.totalEnrollments,
completionRate: analytics.progress.completionRate,
averageProgress: analytics.progress.averageProgressPercentage,
activeStudents: analytics.enrollment.activeStudents,
averageTimeSpent: analytics.engagement.averageTimeSpentText,
certificatesIssued: analytics.performance.certificatesIssued,
},
trends: {
enrollmentGrowth: this.calculateGrowthRate(analytics.enrollment.enrollmentTrend),
engagementTrend: this.calculateEngagementTrend(analytics.engagement.dailyActiveUsers),
completionTrend: 'stable', // Would calculate from historical data
},
};
}
// ----------------------------------------------------------------
// Params & Description metadata
// ----------------------------------------------------------------
/**
* Define the parameters this action expects
*/
public get Params(): ActionParam[] {
const baseParams = this.getCommonLMSParams();
const specificParams: ActionParam[] = [
{ Name: 'CourseID', Type: 'Input', Value: null },
{ Name: 'DateFrom', Type: 'Input', Value: null },
{ Name: 'DateTo', Type: 'Input', Value: null },
{ Name: 'IncludeUserBreakdown', Type: 'Input', Value: false },
{ Name: 'IncludeModuleStats', Type: 'Input', Value: true },
{ Name: 'IncludeRevenue', Type: 'Input', Value: true },
{ Name: 'CourseAnalytics', Type: 'Output', Value: null },
{ Name: 'Summary', Type: 'Output', Value: null },
];
return [...baseParams, ...specificParams];
}
/**
* Metadata about this action
*/
public get Description(): string {
return 'Retrieves comprehensive analytics and performance metrics for a LearnWorlds course';
}
}