UNPKG

@memberjunction/actions-bizapps-lms

Version:

LMS system integration actions for MemberJunction

344 lines (312 loc) 12 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 { GetUserEnrollmentsParams, GetUserEnrollmentsResult, FormattedEnrollment, EnrollmentsSummary } from '../interfaces'; /** * Raw API shape for a single enrollment from LearnWorlds */ interface LWApiEnrollmentData { id?: string; course_id?: string; enrolled_at?: string; created_at?: string; starts_at?: string; expires_at?: string; status?: string; progress_percentage?: number; completed_units?: number; total_units?: number; completed_lessons?: number; total_lessons?: number; last_accessed_at?: string; total_time_spent?: number; grade?: number; certificate_eligible?: boolean; certificate_issued_at?: string; completed_at?: string; } /** * Raw API shape for course details looked up per enrollment */ interface LWApiCourseBasic { id?: string; title?: string; short_description?: string; description?: string; image_url?: string; level?: string; duration?: number; instructor_name?: string; certificate_enabled?: boolean; } /** * Action to retrieve all course enrollments for a specific user */ @RegisterClass(BaseAction, 'GetUserEnrollmentsAction') export class GetUserEnrollmentsAction extends LearnWorldsBaseAction { /** * Metadata about this action */ public get Description(): string { return 'Retrieves all course enrollments for a specific LearnWorlds user with detailed progress information'; } /** * Typed public method for direct (non-framework) callers. * Sets company context, fetches enrollments, and returns a strongly-typed result. * Throws on error. */ public async GetUserEnrollments(params: GetUserEnrollmentsParams, contextUser: UserInfo): Promise<GetUserEnrollmentsResult> { this.SetCompanyContext(params.CompanyID); const { UserID: userId, Status: status, IncludeExpired: includeExpired = false, IncludeCourseDetails: includeCourseDetails = true, SortBy: sortBy = 'enrolled_at', SortOrder: sortOrder = 'desc', MaxResults: maxResults = 100, } = params; if (!userId) { throw new Error('UserID is required'); } this.validatePathSegment(userId, 'UserID'); const queryParams = this.buildEnrollmentQueryParams(status, includeExpired, sortBy, sortOrder, maxResults); const enrollmentsData = await this.makeLearnWorldsPaginatedRequest<LWApiEnrollmentData>(`users/${userId}/courses`, queryParams, contextUser, maxResults); const enrollments = enrollmentsData || []; const formattedEnrollments = await this.formatEnrollments(enrollments, includeCourseDetails, contextUser); const summary = this.calculateEnrollmentSummary(userId, formattedEnrollments); return { Enrollments: formattedEnrollments, TotalCount: formattedEnrollments.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.extractGetUserEnrollmentsParams(Params); const result = await this.GetUserEnrollments(typedParams, ContextUser); this.setOutputParam(Params, 'Enrollments', result.Enrollments); this.setOutputParam(Params, 'TotalCount', result.TotalCount); this.setOutputParam(Params, 'Summary', result.Summary); return this.buildSuccessResult(`Retrieved ${result.TotalCount} enrollments for user`, Params); } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error occurred'; return this.buildErrorResult('ERROR', `Error retrieving enrollments: ${msg}`, Params); } } /** * Extract typed params from framework ActionParam[] */ private extractGetUserEnrollmentsParams(params: ActionParam[]): GetUserEnrollmentsParams { return { CompanyID: this.getRequiredStringParam(params, 'CompanyID'), UserID: this.getRequiredStringParam(params, 'UserID'), Status: this.getOptionalStringParam(params, 'Status'), IncludeExpired: this.getOptionalBooleanParam(params, 'IncludeExpired', false), IncludeCourseDetails: this.getOptionalBooleanParam(params, 'IncludeCourseDetails', true), SortBy: this.getOptionalStringParam(params, 'SortBy') || 'enrolled_at', SortOrder: (this.getOptionalStringParam(params, 'SortOrder') || 'desc') as 'asc' | 'desc', MaxResults: this.getOptionalNumberParam(params, 'MaxResults', LearnWorldsBaseAction.LW_MAX_PAGE_SIZE), }; } /** * Build query parameters for the enrollments API request */ private buildEnrollmentQueryParams( status: string | undefined, includeExpired: boolean, sortBy: string, sortOrder: string, maxResults: number, ): Record<string, string | number | boolean> { const queryParams: Record<string, string | number | boolean> = { limit: Math.min(maxResults, LearnWorldsBaseAction.LW_MAX_PAGE_SIZE), sort: sortBy, order: sortOrder, }; if (status) { queryParams.status = status; } if (!includeExpired) { queryParams.hide_expired = true; } return queryParams; } /** * Format all raw enrollments into typed FormattedEnrollment[]. * When course details are requested, fetches all unique courses in parallel * and attaches results from a lookup map. */ private async formatEnrollments(enrollments: LWApiEnrollmentData[], includeCourseDetails: boolean, contextUser: UserInfo): Promise<FormattedEnrollment[]> { // Build a course details map in one batch to avoid per-item API calls const courseDetailsMap = includeCourseDetails ? await this.fetchCourseDetailsBatch(enrollments, contextUser) : new Map<string, FormattedEnrollment['course']>(); return enrollments.map((enrollment) => { const formatted = this.formatSingleEnrollment(enrollment); if (includeCourseDetails && enrollment.course_id) { formatted.course = courseDetailsMap.get(enrollment.course_id); } return formatted; }); } /** * Fetch course details for all unique course IDs with controlled concurrency and return a lookup map. */ private async fetchCourseDetailsBatch(enrollments: LWApiEnrollmentData[], contextUser: UserInfo): Promise<Map<string, FormattedEnrollment['course']>> { const uniqueCourseIds = [...new Set(enrollments.map((e) => e.course_id).filter((id): id is string => !!id))]; const results = await this.processInBatches(uniqueCourseIds, (courseId) => { this.validatePathSegment(courseId, 'CourseID'); return this.fetchCourseDetailsForEnrollment(courseId, contextUser); }); const map = new Map<string, FormattedEnrollment['course']>(); uniqueCourseIds.forEach((courseId, index) => { map.set(courseId, results[index]); }); return map; } /** * Format a single raw enrollment to a FormattedEnrollment */ private formatSingleEnrollment(enrollment: LWApiEnrollmentData): FormattedEnrollment { return { id: enrollment.id || '', courseId: enrollment.course_id || '', enrolledAt: enrollment.enrolled_at || enrollment.created_at, startsAt: enrollment.starts_at, expiresAt: enrollment.expires_at, status: enrollment.status || 'active', progress: { percentage: enrollment.progress_percentage || 0, completedUnits: enrollment.completed_units || 0, totalUnits: enrollment.total_units || 0, completedLessons: enrollment.completed_lessons || 0, totalLessons: enrollment.total_lessons || 0, lastAccessedAt: enrollment.last_accessed_at, totalTimeSpent: enrollment.total_time_spent || 0, totalTimeSpentText: this.formatDuration(enrollment.total_time_spent || 0), }, grade: enrollment.grade, certificateEligible: enrollment.certificate_eligible || false, certificateIssuedAt: enrollment.certificate_issued_at, completedAt: enrollment.completed_at, }; } /** * Fetch course details for a given enrollment's course ID */ private async fetchCourseDetailsForEnrollment(courseId: string, contextUser: UserInfo): Promise<FormattedEnrollment['course']> { try { const course = await this.makeLearnWorldsRequest<LWApiCourseBasic>(`courses/${courseId}`, 'GET', undefined, contextUser); return { id: course.id || courseId, title: course.title || '', description: course.short_description || course.description, imageUrl: course.image_url, level: course.level, duration: course.duration, durationText: this.formatDuration(course.duration || 0), instructorName: course.instructor_name, certificateEnabled: course.certificate_enabled || false, }; } catch (error) { console.warn(`Failed to get course details for ${courseId}:`, error); return undefined; } } /** * Calculate summary statistics from formatted enrollments */ private calculateEnrollmentSummary(userId: string, formattedEnrollments: FormattedEnrollment[]): EnrollmentsSummary { const totalEnrollments = formattedEnrollments.length; const activeEnrollments = formattedEnrollments.filter((e) => e.status === 'active').length; const completedEnrollments = formattedEnrollments.filter((e) => e.progress.percentage >= 100 || e.completedAt).length; const expiredEnrollments = formattedEnrollments.filter((e) => e.status === 'expired').length; const averageProgress = totalEnrollments > 0 ? formattedEnrollments.reduce((sum, e) => sum + e.progress.percentage, 0) / totalEnrollments : 0; const totalTimeSpent = formattedEnrollments.reduce((sum, e) => sum + (e.progress.totalTimeSpent || 0), 0); const certificatesEarned = formattedEnrollments.filter((e) => e.certificateIssuedAt).length; return { userId, totalEnrollments, activeEnrollments, completedEnrollments, expiredEnrollments, inProgressEnrollments: Math.max(0, totalEnrollments - completedEnrollments - expiredEnrollments), averageProgressPercentage: Math.round(averageProgress * 100) / 100, totalTimeSpent, totalTimeSpentText: this.formatDuration(totalTimeSpent), certificatesEarned, enrollmentsByStatus: { active: activeEnrollments, completed: completedEnrollments, expired: expiredEnrollments, }, }; } /** * Define the parameters this action expects */ public get Params(): ActionParam[] { const baseParams = this.getCommonLMSParams(); const specificParams: ActionParam[] = [ { Name: 'UserID', Type: 'Input', Value: null, }, { Name: 'Status', Type: 'Input', Value: null, }, { Name: 'IncludeExpired', Type: 'Input', Value: false, }, { Name: 'IncludeCourseDetails', Type: 'Input', Value: true, }, { Name: 'SortBy', Type: 'Input', Value: 'enrolled_at', }, { Name: 'SortOrder', Type: 'Input', Value: 'desc', }, { Name: 'MaxResults', Type: 'Input', Value: 100, }, { Name: 'Enrollments', Type: 'Output', Value: null, }, { Name: 'TotalCount', Type: 'Output', Value: null, }, { Name: 'Summary', Type: 'Output', Value: null, }, ]; return [...baseParams, ...specificParams]; } }