@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
287 lines (251 loc) • 10.7 kB
text/typescript
import { RegisterClass } from '@memberjunction/global';
import { LearnWorldsBaseAction } from '../learnworlds-base.action';
import { ActionParam, ActionResultSimple, RunActionParams } from '@memberjunction/actions-base';
import { BaseAction } from '@memberjunction/actions';
import { UserInfo } from '@memberjunction/core';
import { OnboardLearnerParams, OnboardLearnerResult, OnboardLearnerEnrollmentResult } from '../interfaces';
import { CreateUserAction } from './create-user.action';
import { EnrollUserAction } from './enroll-user.action';
import { SSOLoginAction } from './sso-login.action';
/**
* Action that orchestrates full learner onboarding:
* create user, enroll in courses/bundles, and generate SSO URL.
*/
(BaseAction, 'OnboardLearnerAction')
export class OnboardLearnerAction extends LearnWorldsBaseAction {
/**
* Orchestrates the full onboarding flow for a new or existing learner:
* 1. Find or create user
* 2. Enroll in courses and bundles
* 3. Generate SSO login URL
*/
public async OnboardLearner(params: OnboardLearnerParams, contextUser: UserInfo): Promise<OnboardLearnerResult> {
this.SetCompanyContext(params.CompanyID);
this.validateEmail(params.Email);
if (params.Role) {
this.validateRole(params.Role);
}
if (params.CourseIDs) {
params.CourseIDs.forEach((id) => this.validatePathSegment(id, 'CourseID'));
}
if (params.BundleIDs) {
params.BundleIDs.forEach((id) => this.validatePathSegment(id, 'BundleID'));
}
if (params.RedirectTo) {
this.validateRedirectTo(params.RedirectTo);
}
// Step 1: Find or create the user
const { userId, isNewUser, loginUrlFallback } = await this.resolveUser(params, contextUser);
// Step 2: Enroll in courses and bundles
const { enrollments, errors } = await this.enrollInProducts(params, userId, contextUser);
// Step 3: Generate SSO login URL
const loginURL = await this.generateLoginUrl(params, userId, loginUrlFallback, contextUser);
const hasEnrollmentErrors = errors.length > 0;
const allEnrollmentsFailed = enrollments.length > 0 && enrollments.every((e) => !e.success);
return {
Success: !allEnrollmentsFailed,
LoginURL: loginURL,
LearnWorldsUserId: userId,
IsNewUser: isNewUser,
Enrollments: enrollments,
Errors: hasEnrollmentErrors ? errors : [],
};
}
/**
* Finds an existing user by email or creates a new one.
* Returns the user ID, whether they are new, and a fallback login URL from creation.
*/
private async resolveUser(params: OnboardLearnerParams, contextUser: UserInfo): Promise<{ userId: string; isNewUser: boolean; loginUrlFallback?: string }> {
const existingUser = await this.FindUserByEmail(params.Email, contextUser);
if (existingUser) {
return { userId: existingUser.id, isNewUser: false };
}
return this.createNewUser(params, contextUser);
}
/**
* Creates a new user via the CreateUserAction typed public method.
* Returns the user's ID and login URL.
*/
private async createNewUser(params: OnboardLearnerParams, contextUser: UserInfo): Promise<{ userId: string; isNewUser: boolean; loginUrlFallback?: string }> {
const createAction = new CreateUserAction();
const createResult = await createAction.CreateUser(
{
CompanyID: params.CompanyID,
Email: params.Email,
FirstName: params.FirstName,
LastName: params.LastName,
Role: params.Role || 'student',
Tags: params.Tags,
CustomFields: params.CustomFields,
SendWelcomeEmail: params.SendWelcomeEmail ?? false,
},
contextUser,
);
if (!createResult.UserDetails.id) {
throw new Error('User creation succeeded but no user ID was returned');
}
return {
userId: createResult.UserDetails.id,
isNewUser: true,
loginUrlFallback: createResult.UserDetails.loginUrl,
};
}
/**
* Enrolls the user in all specified courses and bundles in parallel,
* collecting results and errors.
*/
private async enrollInProducts(
params: OnboardLearnerParams,
userId: string,
contextUser: UserInfo,
): Promise<{ enrollments: OnboardLearnerEnrollmentResult[]; errors: string[] }> {
const courseIDs = params.CourseIDs || [];
const bundleIDs = params.BundleIDs || [];
// Fire enrollment requests with controlled concurrency
const allProducts = [
...courseIDs.map((id) => ({ id, type: 'course' as const })),
...bundleIDs.map((id) => ({ id, type: 'bundle' as const })),
];
const enrollments = await this.processInBatches(allProducts, (product) =>
this.enrollInSingleProduct(params.CompanyID, userId, product.id, product.type, contextUser),
);
const errors = enrollments.filter((e) => e.error).map((e) => e.error!);
return { enrollments, errors };
}
/**
* Enrolls a user in a single course or bundle, catching errors for partial success.
*/
private async enrollInSingleProduct(
companyId: string,
userId: string,
productId: string,
productType: 'course' | 'bundle',
contextUser: UserInfo,
): Promise<OnboardLearnerEnrollmentResult> {
try {
const enrollAction = new EnrollUserAction();
const enrollResult = await enrollAction.EnrollUser(
{
CompanyID: companyId,
UserID: userId,
CourseID: productId,
ProductType: productType,
Price: 0,
Justification: 'Onboarding enrollment',
NotifyUser: false,
},
contextUser,
);
return {
productId,
productType,
success: true,
enrollmentId: enrollResult.EnrollmentDetails.id,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
productId,
productType,
success: false,
error: `Enrollment failed for ${productType} ${productId}: ${errorMessage}`,
};
}
}
/**
* Generates an SSO login URL, falling back to the user creation login URL on failure.
*/
private async generateLoginUrl(params: OnboardLearnerParams, userId: string, loginUrlFallback: string | undefined, contextUser: UserInfo): Promise<string | undefined> {
try {
const ssoAction = new SSOLoginAction();
ssoAction.SetCompanyContext(params.CompanyID);
const ssoResult = await ssoAction.GenerateSSOUrl(
{
CompanyID: params.CompanyID,
Email: params.Email,
RedirectTo: params.RedirectTo,
},
contextUser,
);
return ssoResult.LoginURL;
} catch (error) {
console.warn(`SSO URL generation failed for userId ${userId}, using fallback:`, error instanceof Error ? error.message : error);
return loginUrlFallback;
}
}
/**
* Framework entry point that delegates to the typed public method
*/
protected async InternalRunAction(params: RunActionParams): Promise<ActionResultSimple> {
const { Params, ContextUser } = params;
this.params = Params;
try {
const onboardParams: OnboardLearnerParams = {
CompanyID: this.getRequiredStringParam(Params, 'CompanyID'),
Email: this.getRequiredStringParam(Params, 'Email'),
FirstName: this.getOptionalStringParam(Params, 'FirstName'),
LastName: this.getOptionalStringParam(Params, 'LastName'),
Role: this.getOptionalStringParam(Params, 'Role'),
Tags: this.getOptionalStringArrayParam(Params, 'Tags'),
CustomFields: this.getParamValue(Params, 'CustomFields') as Record<string, unknown> | undefined,
CourseIDs: this.getOptionalStringArrayParam(Params, 'CourseIDs'),
BundleIDs: this.getOptionalStringArrayParam(Params, 'BundleIDs'),
RedirectTo: this.getOptionalStringParam(Params, 'RedirectTo'),
SendWelcomeEmail: this.getOptionalBooleanParam(Params, 'SendWelcomeEmail', false),
};
if (!onboardParams.Email) {
return this.buildErrorResult('VALIDATION_ERROR', 'Email is required', Params);
}
const result = await this.OnboardLearner(onboardParams, ContextUser);
this.setOutputParam(Params, 'LoginURL', result.LoginURL);
this.setOutputParam(Params, 'LearnWorldsUserId', result.LearnWorldsUserId);
this.setOutputParam(Params, 'IsNewUser', result.IsNewUser);
this.setOutputParam(Params, 'Enrollments', result.Enrollments);
this.setOutputParam(Params, 'Errors', result.Errors);
const successCount = result.Enrollments.filter((e) => e.success).length;
const totalCount = result.Enrollments.length;
const userStatus = result.IsNewUser ? 'new user created' : 'existing user found';
if (result.Success) {
return this.buildSuccessResult(`Onboarding complete (${userStatus}): ${successCount}/${totalCount} enrollment(s) succeeded`, Params);
}
return this.buildErrorResult(
'PARTIAL_FAILURE',
`Onboarding partially failed (${userStatus}): ${successCount}/${totalCount} enrollment(s) succeeded. Errors: ${result.Errors.join('; ')}`,
Params,
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
return this.buildErrorResult('ERROR', `Error during onboarding: ${errorMessage}`, Params);
}
}
/**
* Define the parameters this action expects
*/
public get Params(): ActionParam[] {
const baseParams = this.getCommonLMSParams();
const specificParams: ActionParam[] = [
{ Name: 'Email', Type: 'Input', Value: null },
{ Name: 'FirstName', Type: 'Input', Value: null },
{ Name: 'LastName', Type: 'Input', Value: null },
{ Name: 'Role', Type: 'Input', Value: 'student' },
{ Name: 'Tags', Type: 'Input', Value: null },
{ Name: 'CustomFields', Type: 'Input', Value: null },
{ Name: 'CourseIDs', Type: 'Input', Value: null },
{ Name: 'BundleIDs', Type: 'Input', Value: null },
{ Name: 'RedirectTo', Type: 'Input', Value: null },
{ Name: 'SendWelcomeEmail', Type: 'Input', Value: false },
{ Name: 'LoginURL', Type: 'Output', Value: null },
{ Name: 'LearnWorldsUserId', Type: 'Output', Value: null },
{ Name: 'IsNewUser', Type: 'Output', Value: null },
{ Name: 'Enrollments', Type: 'Output', Value: null },
{ Name: 'Errors', Type: 'Output', Value: null },
];
return [...baseParams, ...specificParams];
}
/**
* Metadata about this action
*/
public get Description(): string {
return 'Orchestrates full learner onboarding: create user, enroll in courses/bundles, and generate SSO URL';
}
}