UNPKG

@memberjunction/actions-bizapps-lms

Version:

LMS system integration actions for MemberJunction

494 lines (424 loc) 18.3 kB
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 */ @RegisterClass(BaseAction, 'GetCourseAnalyticsAction') 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'; } }