UNPKG

@memberjunction/actions-bizapps-lms

Version:

LMS system integration actions for MemberJunction

652 lines (571 loc) 22.9 kB
import { RegisterClass } from '@memberjunction/global'; import { BaseLMSAction } from '../../base/base-lms.action'; import { UserInfo } from '@memberjunction/core'; import { MJCompanyIntegrationEntity } from '@memberjunction/core-entities'; import { BaseAction } from '@memberjunction/actions'; import { ActionParam } from '@memberjunction/actions-base'; import { LWApiEnrollmentStatus, LWApiProgressData, LWApiUser, LearnWorldsUser, LearnWorldsPaginatedResponse } from './interfaces'; /** * Base class for all LearnWorlds LMS actions. * Handles LearnWorlds-specific authentication and API interaction patterns. */ @RegisterClass(BaseAction, 'LearnWorldsBaseAction') export abstract class LearnWorldsBaseAction extends BaseLMSAction { protected lmsProvider = 'LearnWorlds'; protected integrationName = 'LearnWorlds'; /** * LearnWorlds API version */ protected apiVersion = 'v2'; /** * Concurrency limit for parallel API calls to avoid overwhelming the API. */ protected static readonly CONCURRENCY_LIMIT = 5; /** * Allowed user roles in LearnWorlds. */ protected static readonly ALLOWED_ROLES: readonly string[] = ['student', 'observer', 'instructor'] as const; /** * Maximum number of items per page supported by the LearnWorlds API */ protected static readonly LW_MAX_PAGE_SIZE = 100; // ── Rate-limit / retry constants ────────────────────────────────── private static readonly MAX_RETRIES = 5; private static readonly BASE_DELAY_MS = 1000; private static readonly MAX_DELAY_MS = 30_000; private static readonly RATE_LIMIT_WINDOW_MS = 10_000; private static readonly RATE_LIMIT_MAX_REQUESTS = 25; private static readonly INTER_BATCH_DELAY_MS = 2000; /** * Safety limit for pagination to prevent infinite loops if the API misbehaves. */ protected static readonly MAX_PAGES = 100; /** * Sliding-window timestamps shared across all instances so concurrent * actions against the same LearnWorlds school stay within the limit. */ private static requestTimestamps: number[] = []; /** * Reset the global rate-limiter state. Intended for test teardown only. */ public static ResetRateLimiter(): void { LearnWorldsBaseAction.requestTimestamps = []; } /** * Current action parameters (set by the framework or by SetCompanyContext) */ protected params: ActionParam[] = []; /** * Tracks the number of actual HTTP requests made since last reset. * Useful for callers that need accurate API call counts (e.g., bulk data). */ protected apiCallCount = 0; /** * Public accessor for the running API call count. */ public get ApiCallCount(): number { return this.apiCallCount; } /** * Set the company context for direct (non-framework) calls. * This populates `this.params` so that `makeLearnWorldsRequest` can find the CompanyID. */ public SetCompanyContext(companyId: string): void { this.params = [{ Name: 'CompanyID', Type: 'Input', Value: companyId }]; } /** * Makes an authenticated request to LearnWorlds API. * The body parameter accepts any object that will be JSON-serialized. */ protected async makeLearnWorldsRequest<T = Record<string, unknown>>( endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', body?: object | null, contextUser?: UserInfo, ): Promise<T> { if (!contextUser) { throw new Error('Context user is required for LearnWorlds API calls'); } const { fullUrl, headers } = await this.buildRequestConfig(contextUser); const requestUrl = `${fullUrl}/${endpoint}`; try { this.apiCallCount++; const response = await this.sendRequestWithRetry(requestUrl, { method, headers, body: body ? JSON.stringify(body) : undefined, }); if (!response.ok) { throw new Error(await this.buildErrorMessage(response)); } return (await response.json()) as T; } catch (error) { if (error instanceof Error) { throw error; } throw new Error(`LearnWorlds API request failed: ${error}`); } } /** * Resolves integration credentials and builds the base URL and headers. */ private async buildRequestConfig(contextUser: UserInfo): Promise<{ fullUrl: string; nonVersionedUrl: string; headers: Record<string, string> }> { const companyId = this.getParamValue(this.params, 'CompanyID') as string | undefined; if (!companyId) { throw new Error('CompanyID parameter is required'); } const integration = await this.getCompanyIntegration(companyId, contextUser); const credentials = await this.getAPICredentials(integration); if (!credentials.apiKey) { throw new Error('API Key is required for LearnWorlds integration'); } const schoolDomain = integration.ExternalSystemID || this.getCredentialFromEnv(companyId, 'SCHOOL_DOMAIN'); if (!schoolDomain) { throw new Error('School domain not found. Set in CompanyIntegration.ExternalSystemID or environment variable'); } this.validateSchoolDomain(schoolDomain); const lwClient = this.getCredentialFromEnv(companyId, 'CLIENT_ID') || schoolDomain; const nonVersionedUrl = `https://${schoolDomain}/admin/api`; return { fullUrl: `${nonVersionedUrl}/${this.apiVersion}`, nonVersionedUrl, headers: { Authorization: `Bearer ${credentials.apiKey}`, Accept: 'application/json', 'Content-Type': 'application/json', 'Lw-Client': lwClient, }, }; } /** * Makes an authenticated request to a non-versioned LearnWorlds API endpoint (e.g. /admin/api/sso). */ protected async makeLearnWorldsNonVersionedRequest<T = Record<string, unknown>>( endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', body?: object | null, contextUser?: UserInfo, ): Promise<T> { if (!contextUser) { throw new Error('Context user is required for LearnWorlds API calls'); } const { nonVersionedUrl, headers } = await this.buildRequestConfig(contextUser); const requestUrl = `${nonVersionedUrl}/${endpoint}`; try { this.apiCallCount++; const response = await this.sendRequestWithRetry(requestUrl, { method, headers, body: body ? JSON.stringify(body) : undefined, }); if (!response.ok) { throw new Error(await this.buildErrorMessage(response)); } return (await response.json()) as T; } catch (error) { if (error instanceof Error) { throw error; } throw new Error(`LearnWorlds API request failed: ${error}`); } } /** * Parses error details from a non-OK API response. */ private async buildErrorMessage(response: Response): Promise<string> { const errorText = await response.text(); let errorMessage = `LearnWorlds API error: ${response.status} ${response.statusText}`; try { const errorJson = JSON.parse(errorText) as { error?: { message?: string }; message?: string }; if (errorJson.error) { errorMessage = `LearnWorlds API error: ${errorJson.error.message || String(errorJson.error)}`; } else if (errorJson.message) { errorMessage = `LearnWorlds API error: ${errorJson.message}`; } } catch { errorMessage += ` - ${errorText}`; } return errorMessage; } /** * Makes a paginated request to LearnWorlds API */ protected async makeLearnWorldsPaginatedRequest<T = Record<string, unknown>>( endpoint: string, queryParams: Record<string, string | number | boolean> = {}, contextUser?: UserInfo, maxResults?: number, ): Promise<T[]> { const results: T[] = []; let page = 1; let hasMore = true; const limit = (queryParams.limit as number) || LearnWorldsBaseAction.LW_MAX_PAGE_SIZE; const effectiveMax = maxResults ?? (this.getParamValue(this.params, 'MaxResults') as number | undefined); while (hasMore) { if (page > LearnWorldsBaseAction.MAX_PAGES) { console.warn(`Pagination safety limit reached (${LearnWorldsBaseAction.MAX_PAGES} pages). Returning partial results.`); break; } const paginatedParams: Record<string, string> = {}; for (const [key, val] of Object.entries(queryParams)) { paginatedParams[key] = String(val); } paginatedParams.page = page.toString(); paginatedParams.limit = limit.toString(); const qs = new URLSearchParams(paginatedParams); const response = await this.makeLearnWorldsRequest<LearnWorldsPaginatedResponse<T>>(`${endpoint}?${qs}`, 'GET', undefined, contextUser); if (response.data && Array.isArray(response.data)) { results.push(...response.data); } // Check if there are more pages if (response.meta && response.meta.page < response.meta.totalPages) { page++; } else { hasMore = false; } // Respect max results if (effectiveMax && results.length >= effectiveMax) { return results.slice(0, effectiveMax); } } return results; } /** * Convert LearnWorlds date format to Date object */ protected parseLearnWorldsDate(dateString: string | number): Date { // LearnWorlds sometimes returns timestamps as seconds since epoch if (typeof dateString === 'number') { return new Date(dateString * 1000); } return new Date(dateString); } /** * Format date for LearnWorlds API (ISO 8601) */ protected formatLearnWorldsDate(date: Date): string { return date.toISOString(); } /** * Map LearnWorlds user status to standard status */ protected mapUserStatus(status: string): 'active' | 'inactive' | 'suspended' { const statusMap: Record<string, 'active' | 'inactive' | 'suspended'> = { active: 'active', inactive: 'inactive', suspended: 'suspended', blocked: 'suspended', }; return statusMap[status.toLowerCase()] || 'inactive'; } /** * Map LearnWorlds enrollment status */ protected mapLearnWorldsEnrollmentStatus(enrollment: LWApiEnrollmentStatus): 'active' | 'completed' | 'expired' | 'suspended' { if (enrollment.completed) { return 'completed'; } if (enrollment.expired) { return 'expired'; } if (enrollment.suspended || !enrollment.active) { return 'suspended'; } return 'active'; } /** * Calculate progress from LearnWorlds data */ protected calculateProgress(progressData: LWApiProgressData): { percentage: number; completedUnits: number; totalUnits: number; timeSpent: number; } { return { percentage: progressData.percentage || 0, completedUnits: progressData.completed_units || 0, totalUnits: progressData.total_units || 0, timeSpent: progressData.time_spent || 0, }; } /** * Safely parses a date string to ISO format. * Returns undefined if the input is falsy or produces an invalid date. */ protected safeParseDateToISO(dateString: string | undefined): string | undefined { if (!dateString) return undefined; const parsed = new Date(dateString); if (isNaN(parsed.getTime())) { console.warn(`Invalid date string "${dateString}" — skipping date filter`); return undefined; } return parsed.toISOString(); } // ---------------------------------------------------------------- // Validation helpers // ---------------------------------------------------------------- /** * Validates that a value is safe to use as a URL path segment. * Rejects values containing path traversal or URL manipulation characters. */ protected validatePathSegment(value: string, paramName: string): string { if (value.length === 0) { throw new Error(`${paramName} must not be empty`); } if (/[\/\\?#@%]|\.\./.test(value)) { throw new Error(`Invalid ${paramName}: contains forbidden characters`); } return value; } /** * Validates that a school domain is safe to use in URL construction. * Must look like a hostname (alphanumeric + hyphens + dots). */ private validateSchoolDomain(domain: string): string { if (/[\/\\?#@\s]/.test(domain)) { throw new Error('Invalid school domain: contains URL-unsafe characters'); } if (!/^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(domain)) { throw new Error('Invalid school domain format'); } return domain; } /** * Validates that a role is in the allowed set. */ protected validateRole(role: string): string { const normalizedRole = role.toLowerCase(); if (!LearnWorldsBaseAction.ALLOWED_ROLES.includes(normalizedRole)) { throw new Error(`Invalid role '${role}'. Allowed roles: ${LearnWorldsBaseAction.ALLOWED_ROLES.join(', ')}`); } return normalizedRole; } /** * Validates that a string is a plausible email address. */ protected validateEmail(email: string, paramName: string = 'Email'): string { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { throw new Error(`Invalid ${paramName} format: '${email}'`); } return email; } /** * Validates that a redirect URL is either a relative path or an absolute URL * that belongs to the LearnWorlds school domain and uses http(s). */ protected validateRedirectTo(redirectTo: string, schoolDomain?: string): string { // Allow relative paths if (redirectTo.startsWith('/')) { return redirectTo; } // If absolute URL, parse and check against school domain let url: URL; try { url = new URL(redirectTo); } catch { throw new Error('RedirectTo is not a valid URL or relative path'); } // Block dangerous protocols if (!['http:', 'https:'].includes(url.protocol)) { throw new Error('RedirectTo must use http or https protocol'); } if (schoolDomain && !url.hostname.endsWith('.learnworlds.com') && url.hostname !== schoolDomain) { throw new Error('RedirectTo must be a relative path or a LearnWorlds domain URL'); } return redirectTo; } // ---------------------------------------------------------------- // Rate-limit helpers // ---------------------------------------------------------------- /** * Returns a promise that resolves after `ms` milliseconds. */ private async waitForRetryDelay(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Pure calculation — determines how long to wait before the next retry. * Prefers the Retry-After header (seconds → ms, capped); falls back to * exponential backoff with random jitter. */ private calculateRetryDelay(attempt: number, retryAfterHeader: string | null): number { if (retryAfterHeader != null) { const retryAfterSeconds = Number(retryAfterHeader); if (!isNaN(retryAfterSeconds) && retryAfterSeconds > 0) { return Math.min(retryAfterSeconds * 1000, LearnWorldsBaseAction.MAX_DELAY_MS); } } const exponential = LearnWorldsBaseAction.BASE_DELAY_MS * Math.pow(2, attempt); if (exponential >= LearnWorldsBaseAction.MAX_DELAY_MS) { return LearnWorldsBaseAction.MAX_DELAY_MS; } const jitter = Math.random() * LearnWorldsBaseAction.BASE_DELAY_MS; return Math.min(exponential + jitter, LearnWorldsBaseAction.MAX_DELAY_MS); } /** * Proactive throttle: if the sliding window is at capacity, sleep until * the oldest request falls outside the window. */ private async waitForRateLimitCapacity(): Promise<void> { const now = Date.now(); const windowStart = now - LearnWorldsBaseAction.RATE_LIMIT_WINDOW_MS; LearnWorldsBaseAction.requestTimestamps = LearnWorldsBaseAction.requestTimestamps.filter(t => t > windowStart); if (LearnWorldsBaseAction.requestTimestamps.length < LearnWorldsBaseAction.RATE_LIMIT_MAX_REQUESTS) { return; } const oldestInWindow = LearnWorldsBaseAction.requestTimestamps[0]; const waitMs = oldestInWindow + LearnWorldsBaseAction.RATE_LIMIT_WINDOW_MS - now + 50; if (waitMs > 0) { await this.waitForRetryDelay(waitMs); } } /** * Stamps the current time into the sliding window. */ private recordRequest(): void { LearnWorldsBaseAction.requestTimestamps.push(Date.now()); } /** * Wraps `fetch` with 429-aware retry + proactive rate-limit throttling. * Non-429 responses are returned immediately without retry. * If all retries are exhausted the final 429 response is returned (not thrown). */ private async sendRequestWithRetry(url: string, init: RequestInit): Promise<Response> { await this.waitForRateLimitCapacity(); this.recordRequest(); let response = await fetch(url, init); for (let attempt = 0; attempt < LearnWorldsBaseAction.MAX_RETRIES; attempt++) { if (response.status !== 429) { return response; } const retryAfter = response.headers.get('Retry-After'); const delay = this.calculateRetryDelay(attempt, retryAfter); console.warn( `429 rate limited on ${url} — retry ${attempt + 1}/${LearnWorldsBaseAction.MAX_RETRIES} after ${delay}ms`, ); await this.waitForRetryDelay(delay); await this.waitForRateLimitCapacity(); this.recordRequest(); response = await fetch(url, init); } return response; } // ---------------------------------------------------------------- // Batch concurrency helper // ---------------------------------------------------------------- /** * Processes items in batches with controlled concurrency. * Prevents unbounded parallel API calls from overwhelming the target API. */ protected async processInBatches<TItem, TResult>( items: TItem[], processFn: (item: TItem) => Promise<TResult>, batchSize: number = LearnWorldsBaseAction.CONCURRENCY_LIMIT, ): Promise<TResult[]> { const results: TResult[] = []; for (let i = 0; i < items.length; i += batchSize) { if (i > 0) { await this.waitForRetryDelay(LearnWorldsBaseAction.INTER_BATCH_DELAY_MS); } const batch = items.slice(i, i + batchSize); const batchResults = await Promise.all(batch.map(processFn)); results.push(...batchResults); } return results; } // ---------------------------------------------------------------- // Parameter extraction helpers // ---------------------------------------------------------------- /** * Gets a required string parameter, throwing if missing or empty. */ protected getRequiredStringParam(params: ActionParam[], name: string): string { const value = this.getParamValue(params, name); if (typeof value !== 'string' || !value) { throw new Error(`Required string parameter '${name}' is missing or invalid`); } return value; } /** * Gets an optional string parameter. */ protected getOptionalStringParam(params: ActionParam[], name: string): string | undefined { const value = this.getParamValue(params, name); if (value === undefined || value === null) return undefined; return typeof value === 'string' ? value : String(value); } /** * Gets an optional boolean parameter with a default value. * When defaultValue is undefined, returns undefined if the parameter is missing. */ protected getOptionalBooleanParam(params: ActionParam[], name: string, defaultValue: boolean): boolean; protected getOptionalBooleanParam(params: ActionParam[], name: string, defaultValue: boolean | undefined): boolean | undefined; protected getOptionalBooleanParam(params: ActionParam[], name: string, defaultValue: boolean | undefined): boolean | undefined { const value = this.getParamValue(params, name); if (value === undefined || value === null) return defaultValue; if (typeof value === 'boolean') return value; const strVal = String(value).toLowerCase(); if (strVal === 'true' || strVal === '1') return true; if (strVal === 'false' || strVal === '0') return false; return defaultValue; } /** * Gets an optional number parameter with a default value. * When defaultValue is undefined, returns undefined if the parameter is missing. */ protected getOptionalNumberParam(params: ActionParam[], name: string, defaultValue: number): number; protected getOptionalNumberParam(params: ActionParam[], name: string, defaultValue: number | undefined): number | undefined; protected getOptionalNumberParam(params: ActionParam[], name: string, defaultValue: number | undefined): number | undefined { const value = this.getParamValue(params, name); if (value === undefined || value === null) return defaultValue; const parsed = Number(value); return isNaN(parsed) ? defaultValue : parsed; } /** * Gets an optional string array parameter. */ protected getOptionalStringArrayParam(params: ActionParam[], name: string): string[] | undefined { const value = this.getParamValue(params, name); if (value === undefined || value === null) return undefined; if (Array.isArray(value)) return value.map(String); return undefined; } /** * Shared utility: find a LearnWorlds user by email. * Returns the user if found, null if not found (empty results). * Re-throws errors for network failures, auth errors, rate limiting, etc. */ public async FindUserByEmail(email: string, contextUser: UserInfo): Promise<LearnWorldsUser | null> { this.validateEmail(email, 'Email'); const qs = new URLSearchParams({ search: email, limit: '50' }); const response = await this.makeLearnWorldsRequest<LearnWorldsPaginatedResponse<LWApiUser>>( `users?${qs}`, 'GET', undefined, contextUser, ); const users = (response.data && Array.isArray(response.data)) ? response.data : []; const match = users.find((u) => u.email.toLowerCase() === email.toLowerCase()); if (!match) return null; return this.mapLWApiUserToLearnWorldsUser(match); } /** * Maps a raw LW API user to the normalized LearnWorldsUser shape. */ private mapLWApiUserToLearnWorldsUser(user: LWApiUser): LearnWorldsUser { return { id: user.id || user._id || '', email: user.email, username: user.username || user.email, firstName: user.first_name, lastName: user.last_name, fullName: user.full_name || `${user.first_name || ''} ${user.last_name || ''}`.trim(), status: this.mapUserStatus(user.status || 'active'), role: user.role || 'student', createdAt: this.parseLearnWorldsDate(user.created || user.created_at || ''), lastLoginAt: user.last_login ? this.parseLearnWorldsDate(user.last_login) : undefined, tags: user.tags || [], customFields: user.custom_fields || {}, }; } }