@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
558 lines • 22.6 kB
JavaScript
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