UNPKG

@memberjunction/actions-bizapps-lms

Version:

LMS system integration actions for MemberJunction

558 lines 22.6 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var LearnWorldsBaseAction_1; import { RegisterClass } from '@memberjunction/global'; import { BaseLMSAction } from '../../base/base-lms.action.js'; import { BaseAction } from '@memberjunction/actions'; /** * Base class for all LearnWorlds LMS actions. * Handles LearnWorlds-specific authentication and API interaction patterns. */ let LearnWorldsBaseAction = class LearnWorldsBaseAction extends BaseLMSAction { constructor() { super(...arguments); this.lmsProvider = 'LearnWorlds'; this.integrationName = 'LearnWorlds'; /** * LearnWorlds API version */ this.apiVersion = 'v2'; /** * Current action parameters (set by the framework or by SetCompanyContext) */ this.params = []; /** * Tracks the number of actual HTTP requests made since last reset. * Useful for callers that need accurate API call counts (e.g., bulk data). */ this.apiCallCount = 0; } static { LearnWorldsBaseAction_1 = this; } /** * Concurrency limit for parallel API calls to avoid overwhelming the API. */ static { this.CONCURRENCY_LIMIT = 5; } /** * Allowed user roles in LearnWorlds. */ static { this.ALLOWED_ROLES = ['student', 'observer', 'instructor']; } /** * Maximum number of items per page supported by the LearnWorlds API */ static { this.LW_MAX_PAGE_SIZE = 100; } // ── Rate-limit / retry constants ────────────────────────────────── static { this.MAX_RETRIES = 5; } static { this.BASE_DELAY_MS = 1000; } static { this.MAX_DELAY_MS = 30_000; } static { this.RATE_LIMIT_WINDOW_MS = 10_000; } static { this.RATE_LIMIT_MAX_REQUESTS = 25; } static { this.INTER_BATCH_DELAY_MS = 2000; } /** * Safety limit for pagination to prevent infinite loops if the API misbehaves. */ static { this.MAX_PAGES = 100; } /** * Sliding-window timestamps shared across all instances so concurrent * actions against the same LearnWorlds school stay within the limit. */ static { this.requestTimestamps = []; } /** * Reset the global rate-limiter state. Intended for test teardown only. */ static ResetRateLimiter() { LearnWorldsBaseAction_1.requestTimestamps = []; } /** * Public accessor for the running API call count. */ get ApiCallCount() { return this.apiCallCount; } /** * Set the company context for direct (non-framework) calls. * This populates `this.params` so that `makeLearnWorldsRequest` can find the CompanyID. */ SetCompanyContext(companyId) { 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. */ async makeLearnWorldsRequest(endpoint, method = 'GET', body, contextUser) { 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()); } 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. */ async buildRequestConfig(contextUser) { const companyId = this.getParamValue(this.params, 'CompanyID'); 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). */ async makeLearnWorldsNonVersionedRequest(endpoint, method = 'GET', body, contextUser) { 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()); } 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. */ async buildErrorMessage(response) { const errorText = await response.text(); let errorMessage = `LearnWorlds API error: ${response.status} ${response.statusText}`; try { const errorJson = JSON.parse(errorText); 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 */ async makeLearnWorldsPaginatedRequest(endpoint, queryParams = {}, contextUser, maxResults) { const results = []; let page = 1; let hasMore = true; const limit = queryParams.limit || LearnWorldsBaseAction_1.LW_MAX_PAGE_SIZE; const effectiveMax = maxResults ?? this.getParamValue(this.params, 'MaxResults'); while (hasMore) { if (page > LearnWorldsBaseAction_1.MAX_PAGES) { console.warn(`Pagination safety limit reached (${LearnWorldsBaseAction_1.MAX_PAGES} pages). Returning partial results.`); break; } const paginatedParams = {}; 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(`${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 */ parseLearnWorldsDate(dateString) { // 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) */ formatLearnWorldsDate(date) { return date.toISOString(); } /** * Map LearnWorlds user status to standard status */ mapUserStatus(status) { const statusMap = { active: 'active', inactive: 'inactive', suspended: 'suspended', blocked: 'suspended', }; return statusMap[status.toLowerCase()] || 'inactive'; } /** * Map LearnWorlds enrollment status */ mapLearnWorldsEnrollmentStatus(enrollment) { if (enrollment.completed) { return 'completed'; } if (enrollment.expired) { return 'expired'; } if (enrollment.suspended || !enrollment.active) { return 'suspended'; } return 'active'; } /** * Calculate progress from LearnWorlds data */ calculateProgress(progressData) { 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. */ safeParseDateToISO(dateString) { 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. */ validatePathSegment(value, paramName) { 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). */ validateSchoolDomain(domain) { 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. */ validateRole(role) { const normalizedRole = role.toLowerCase(); if (!LearnWorldsBaseAction_1.ALLOWED_ROLES.includes(normalizedRole)) { throw new Error(`Invalid role '${role}'. Allowed roles: ${LearnWorldsBaseAction_1.ALLOWED_ROLES.join(', ')}`); } return normalizedRole; } /** * Validates that a string is a plausible email address. */ validateEmail(email, paramName = 'Email') { 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). */ validateRedirectTo(redirectTo, schoolDomain) { // Allow relative paths if (redirectTo.startsWith('/')) { return redirectTo; } // If absolute URL, parse and check against school domain let 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. */ async waitForRetryDelay(ms) { 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. */ calculateRetryDelay(attempt, retryAfterHeader) { if (retryAfterHeader != null) { const retryAfterSeconds = Number(retryAfterHeader); if (!isNaN(retryAfterSeconds) && retryAfterSeconds > 0) { return Math.min(retryAfterSeconds * 1000, LearnWorldsBaseAction_1.MAX_DELAY_MS); } } const exponential = LearnWorldsBaseAction_1.BASE_DELAY_MS * Math.pow(2, attempt); if (exponential >= LearnWorldsBaseAction_1.MAX_DELAY_MS) { return LearnWorldsBaseAction_1.MAX_DELAY_MS; } const jitter = Math.random() * LearnWorldsBaseAction_1.BASE_DELAY_MS; return Math.min(exponential + jitter, LearnWorldsBaseAction_1.MAX_DELAY_MS); } /** * Proactive throttle: if the sliding window is at capacity, sleep until * the oldest request falls outside the window. */ async waitForRateLimitCapacity() { const now = Date.now(); const windowStart = now - LearnWorldsBaseAction_1.RATE_LIMIT_WINDOW_MS; LearnWorldsBaseAction_1.requestTimestamps = LearnWorldsBaseAction_1.requestTimestamps.filter(t => t > windowStart); if (LearnWorldsBaseAction_1.requestTimestamps.length < LearnWorldsBaseAction_1.RATE_LIMIT_MAX_REQUESTS) { return; } const oldestInWindow = LearnWorldsBaseAction_1.requestTimestamps[0]; const waitMs = oldestInWindow + LearnWorldsBaseAction_1.RATE_LIMIT_WINDOW_MS - now + 50; if (waitMs > 0) { await this.waitForRetryDelay(waitMs); } } /** * Stamps the current time into the sliding window. */ recordRequest() { LearnWorldsBaseAction_1.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). */ async sendRequestWithRetry(url, init) { await this.waitForRateLimitCapacity(); this.recordRequest(); let response = await fetch(url, init); for (let attempt = 0; attempt < LearnWorldsBaseAction_1.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_1.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. */ async processInBatches(items, processFn, batchSize = LearnWorldsBaseAction_1.CONCURRENCY_LIMIT) { const results = []; for (let i = 0; i < items.length; i += batchSize) { if (i > 0) { await this.waitForRetryDelay(LearnWorldsBaseAction_1.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. */ getRequiredStringParam(params, name) { 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. */ getOptionalStringParam(params, name) { const value = this.getParamValue(params, name); if (value === undefined || value === null) return undefined; return typeof value === 'string' ? value : String(value); } getOptionalBooleanParam(params, name, defaultValue) { 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; } getOptionalNumberParam(params, name, defaultValue) { 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. */ getOptionalStringArrayParam(params, name) { 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. */ async FindUserByEmail(email, contextUser) { this.validateEmail(email, 'Email'); const qs = new URLSearchParams({ search: email, limit: '50' }); const response = await this.makeLearnWorldsRequest(`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. */ mapLWApiUserToLearnWorldsUser(user) { 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 || {}, }; } }; LearnWorldsBaseAction = LearnWorldsBaseAction_1 = __decorate([ RegisterClass(BaseAction, 'LearnWorldsBaseAction') ], LearnWorldsBaseAction); export { LearnWorldsBaseAction }; //# sourceMappingURL=learnworlds-base.action.js.map