@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
275 lines (240 loc) • 8.96 kB
text/typescript
import { ActionParam, ActionResultSimple } from '@memberjunction/actions-base';
import { BaseAction } from '@memberjunction/actions';
import { RegisterClass, UUIDsEqual } from '@memberjunction/global';
import { UserInfo } from '@memberjunction/core';
import { MJCompanyIntegrationEntity } from '@memberjunction/core-entities';
import { Metadata, RunView } from '@memberjunction/core';
/**
* Base class for all LMS-related actions.
* Provides common functionality and patterns for interacting with Learning Management Systems.
*/
(BaseAction, 'BaseLMSAction')
export abstract class BaseLMSAction extends BaseAction {
/**
* The LMS provider this action is designed for (e.g., 'LearnWorlds', 'Moodle', etc.)
*/
protected abstract lmsProvider: string;
/**
* The integration name to look up in the Integration entity
*/
protected abstract integrationName: string;
/**
* Cached company integration for the current execution
*/
private _companyIntegration: MJCompanyIntegrationEntity | null = null;
/**
* Common LMS parameters that many actions will need
*/
protected getCommonLMSParams(): ActionParam[] {
return [
{
Name: 'CompanyID',
Type: 'Input',
Value: null,
},
];
}
/**
* Validates that a string is a valid UUID format to prevent SQL injection.
*/
private static readonly uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Validates that a string is a valid UUID format to prevent SQL injection.
* Exposed as a reusable helper for subclasses to validate CompanyID, UserID, etc.
*/
protected validateUUID(value: string, paramName: string): string {
if (!BaseLMSAction.uuidRegex.test(value)) {
throw new Error(`${paramName} must be a valid UUID`);
}
return value;
}
/**
* Validates that an integration name contains only safe characters (alphanumeric, spaces, hyphens, underscores).
*/
private validateIntegrationName(name: string): string {
if (!/^[a-zA-Z0-9 _-]+$/.test(name)) {
throw new Error('Invalid integration name: contains forbidden characters');
}
return name;
}
/**
* Gets the company integration record for the specified company and LMS
*/
protected async getCompanyIntegration(companyId: string, contextUser: UserInfo): Promise<MJCompanyIntegrationEntity> {
this.validateUUID(companyId, 'CompanyID');
// Validate integration name before SQL interpolation
const safeIntegrationName = this.validateIntegrationName(this.integrationName);
// Check cache first
if (this._companyIntegration && UUIDsEqual(this._companyIntegration.CompanyID, companyId)) {
return this._companyIntegration;
}
const rv = new RunView();
const result = await rv.RunView<MJCompanyIntegrationEntity>(
{
EntityName: 'MJ: Company Integrations',
ExtraFilter: `CompanyID = '${companyId}' AND Integration = '${safeIntegrationName}'`,
ResultType: 'entity_object',
},
contextUser,
);
if (!result.Success) {
throw new Error(`Failed to retrieve company integration: ${result.ErrorMessage}`);
}
if (!result.Results || result.Results.length === 0) {
throw new Error(`No ${this.integrationName} integration found for company ${companyId}. Please configure the integration first.`);
}
this._companyIntegration = result.Results[0];
return this._companyIntegration;
}
/**
* Gets credentials from environment variables
* Format: BIZAPPS_{PROVIDER}_{COMPANY_ID}_{CREDENTIAL_TYPE}
* Example: BIZAPPS_LEARNWORLDS_12345_API_KEY
*/
protected getCredentialFromEnv(companyId: string, credentialType: string): string | undefined {
const provider = this.lmsProvider.toUpperCase().replace(/\s+/g, '_');
const credential = credentialType.toUpperCase();
const envKey = `BIZAPPS_${provider}_${companyId}_${credential}`;
const value = process.env[envKey];
if (value !== undefined) return value;
// Fallback: try lowercase companyId (SQL Server returns uppercase UUIDs)
const lowerKey = `BIZAPPS_${provider}_${companyId.toLowerCase()}_${credential}`;
return process.env[lowerKey];
}
/**
* Gets API credentials - first tries environment variables, then falls back to database
*/
protected async getAPICredentials(integration: MJCompanyIntegrationEntity): Promise<{ apiKey?: string; apiSecret?: string; accessToken?: string }> {
const companyId = integration.CompanyID;
// Try environment variables first
const envApiKey = this.getCredentialFromEnv(companyId, 'API_KEY');
const envApiSecret = this.getCredentialFromEnv(companyId, 'API_SECRET');
const envAccessToken = this.getCredentialFromEnv(companyId, 'ACCESS_TOKEN');
if (envApiKey || envAccessToken) {
return {
apiKey: envApiKey,
apiSecret: envApiSecret,
accessToken: envAccessToken,
};
}
// Fall back to database (for backwards compatibility)
if (!integration.APIKey && !integration.AccessToken) {
throw new Error(`No API credentials found for ${this.integrationName} integration. Please set environment variables or configure in database.`);
}
return {
apiKey: integration.APIKey || undefined,
accessToken: integration.AccessToken || undefined,
};
}
/**
* Gets the base URL for API calls
*/
protected async getAPIBaseURL(integration: MJCompanyIntegrationEntity): Promise<string> {
// Check if custom URL is stored in the integration
if (integration.CustomAttribute1) {
return integration.CustomAttribute1;
}
// Return empty string - derived classes should override this
return '';
}
/**
* Helper to get parameter value from an ActionParam array
*/
protected getParamValue(params: ActionParam[], paramName: string): unknown {
const param = params.find((p) => p.Name === paramName);
return param?.Value;
}
/**
* Standard date format for LMS systems (ISO 8601)
*/
protected formatLMSDate(date: Date): string {
return date.toISOString();
}
/**
* Parse date from LMS format
*/
protected parseLMSDate(dateString: string): Date {
return new Date(dateString);
}
/**
* Calculate progress percentage
*/
protected calculateProgressPercentage(completed: number, total: number): number {
if (total === 0) return 0;
return Math.round((completed / total) * 100);
}
/**
* Format duration in seconds to human readable format
*/
protected formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
} else if (minutes > 0) {
return `${minutes}m ${secs}s`;
} else {
return `${secs}s`;
}
}
/**
* Helper to build consistent error messages for LMS operations
*/
protected buildLMSErrorMessage(operation: string, details: string, systemError?: Error | string | { message?: string }): string {
let message = `LMS operation failed: ${operation}. ${details}`;
if (systemError) {
const errorMsg = typeof systemError === 'string' ? systemError : (systemError as { message?: string }).message || String(systemError);
message += ` System error: ${errorMsg}`;
}
return message;
}
/**
* Common enrollment status mapping
*/
protected mapEnrollmentStatus(status: string): 'active' | 'completed' | 'expired' | 'suspended' | 'unknown' {
const statusMap: Record<string, 'active' | 'completed' | 'expired' | 'suspended' | 'unknown'> = {
active: 'active',
completed: 'completed',
finished: 'completed',
expired: 'expired',
suspended: 'suspended',
paused: 'suspended',
inactive: 'suspended',
};
return statusMap[status.toLowerCase()] || 'unknown';
}
/**
* Helper to set an output parameter, creating it if it doesn't exist
*/
protected setOutputParam(params: ActionParam[], name: string, value: unknown): void {
const existing = params.find((p) => p.Name === name);
if (existing) {
existing.Value = value;
} else {
params.push({ Name: name, Type: 'Output', Value: value });
}
}
/**
* Build a standard success ActionResultSimple
*/
protected buildSuccessResult(message: string, params?: ActionParam[]): ActionResultSimple {
return {
Success: true,
ResultCode: 'SUCCESS',
Message: message,
...(params ? { Params: params } : {}),
};
}
/**
* Build a standard error ActionResultSimple
*/
protected buildErrorResult(code: string, message: string, params?: ActionParam[]): ActionResultSimple {
return {
Success: false,
ResultCode: code,
Message: message,
...(params ? { Params: params } : {}),
};
}
}