@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
652 lines (571 loc) • 22.9 kB
text/typescript
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.
*/
(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 || {},
};
}
}