@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
626 lines (547 loc) • 21.2 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 {
GetQuizResultsParams,
GetQuizResultsResult,
FormattedQuizResult,
FormattedQuestion,
FormattedAnswer,
QuizAnswerValue,
QuizMetrics,
QuizResultsSummary,
} from '../interfaces';
// ----------------------------------------------------------------
// File-local interfaces for raw LearnWorlds API shapes
// ----------------------------------------------------------------
/** Query parameters sent to quiz results endpoints */
interface LWQuizResultQueryParams {
limit: number;
sort: string;
order: string;
passed?: boolean;
completed_after?: string;
completed_before?: string;
user_id?: string;
[key: string]: string | number | boolean | undefined;
}
/** Raw quiz result from the API */
interface LWRawQuizResult {
id?: string;
result_id?: string;
user_id?: string;
course_id?: string;
quiz_id?: string;
attempt_number?: number;
attempt?: number;
score?: number;
max_score?: number;
total_points?: number;
percentage?: number;
passed?: boolean;
is_passing?: boolean;
passing_score?: number;
passing_percentage?: number;
started_at?: string;
completed_at?: string;
submitted_at?: string;
duration?: number;
time_spent?: number;
user?: {
id?: string;
email?: string;
name?: string;
first_name?: string;
last_name?: string;
};
quiz?: {
id?: string;
title?: string;
name?: string;
type?: string;
question_count?: number;
total_questions?: number;
};
}
/** Raw quiz detail response */
interface LWQuizDetailResponse {
success?: boolean;
data?: {
questions?: LWRawQuestion[];
answers?: LWRawAnswer[];
};
}
/** Raw question from the API */
interface LWRawQuestion {
question_number?: number;
question_id?: string;
id?: string;
question_text?: string;
question?: string;
text?: string;
question_type?: string;
type?: string;
points?: number;
score?: number;
difficulty?: string;
}
/** Raw answer from the API */
interface LWRawAnswer {
question_number?: number;
question_id?: string;
user_answer?: unknown;
answer?: unknown;
selected_answer?: unknown;
correct_answer?: unknown;
is_correct?: boolean;
correct?: boolean;
points_earned?: number;
points_possible?: number;
points?: number;
feedback?: string;
time_spent?: number;
answered_at?: string;
}
/** Results API response wrapper */
interface LWQuizResultsApiResponse {
success?: boolean;
message?: string;
data?: LWRawQuizResult[] | { data?: LWRawQuizResult[] };
}
/** Quiz info lookup response */
interface LWQuizLookup {
success?: boolean;
data?: {
id?: string;
title?: string;
name?: string;
type?: string;
question_count?: number;
total_questions?: number;
};
}
/** Stats tracked per quiz when building the breakdown */
interface QuizBreakdownEntry {
results: FormattedQuizResult[];
stats: {
attempts: number;
averageScore: number;
passRate: number;
};
}
/**
* Action to retrieve quiz/assessment results from LearnWorlds
*/
export class GetQuizResultsAction extends LearnWorldsBaseAction {
// ----------------------------------------------------------------
// Typed public method – can be called directly from code
// ----------------------------------------------------------------
/**
* Get quiz results for a user, course, or specific quiz.
* Throws on any error.
*/
public async GetQuizResults(params: GetQuizResultsParams, contextUser: UserInfo): Promise<GetQuizResultsResult> {
this.SetCompanyContext(params.CompanyID);
const {
UserID: userId,
CourseID: courseId,
QuizID: quizId,
IncludeQuestions: includeQuestionsRaw,
IncludeAnswers: includeAnswersRaw,
PassingOnly: passingOnly = false,
DateFrom: dateFrom,
DateTo: dateTo,
SortBy: sortBy = 'completed_at',
SortOrder: sortOrder = 'desc',
MaxResults: maxResults = 100,
} = params;
const includeQuestions = includeQuestionsRaw !== false;
const includeAnswers = includeAnswersRaw !== false;
// Require at least one identifier
if (!userId && !courseId && !quizId) {
throw new Error('At least one of UserID, CourseID, or QuizID is required');
}
// Build query parameters
const queryParams = this.buildQuizQueryParams(userId, quizId, passingOnly, dateFrom, dateTo, sortBy, sortOrder, maxResults);
// Validate path segments before URL interpolation
if (userId) this.validatePathSegment(userId, 'UserID');
if (courseId) this.validatePathSegment(courseId, 'CourseID');
if (quizId) this.validatePathSegment(quizId, 'QuizID');
// Determine endpoint
const endpoint = this.buildQuizEndpoint(userId, courseId, quizId);
// Build query string
const queryString = this.buildQueryString(queryParams);
// Fetch quiz results
const resultsResponse = await this.makeLearnWorldsRequest<LWQuizResultsApiResponse>(endpoint + queryString, 'GET', null, contextUser);
if (resultsResponse.success === false) {
throw new Error(resultsResponse.message || 'Failed to retrieve quiz results');
}
// Normalize results
const rawResults = this.extractQuizResultsArray(resultsResponse);
// Process each quiz result
const formattedResults = await this.processQuizResults(rawResults, userId, courseId, quizId, includeQuestions, includeAnswers, contextUser);
// Calculate summary
const summary = this.buildQuizResultsSummary(formattedResults, userId, courseId, quizId, dateFrom, dateTo);
return {
QuizResults: formattedResults,
TotalCount: formattedResults.length,
Summary: summary,
};
}
// ----------------------------------------------------------------
// Framework wrapper – thin delegation to the public method
// ----------------------------------------------------------------
protected async InternalRunAction(params: RunActionParams): Promise<ActionResultSimple> {
const { Params, ContextUser } = params;
this.params = Params;
try {
const typedParams = this.extractQuizResultsParams(Params);
const result = await this.GetQuizResults(typedParams, ContextUser);
this.setOutputParam(Params, 'QuizResults', result.QuizResults);
this.setOutputParam(Params, 'TotalCount', result.TotalCount);
this.setOutputParam(Params, 'Summary', result.Summary);
return this.buildSuccessResult(`Retrieved ${result.TotalCount} quiz result(s)`, Params);
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
return this.buildErrorResult('ERROR', `Error retrieving quiz results: ${msg}`, Params);
}
}
// ----------------------------------------------------------------
// Private helpers – parameter extraction
// ----------------------------------------------------------------
private extractQuizResultsParams(params: ActionParam[]): GetQuizResultsParams {
return {
CompanyID: this.getRequiredStringParam(params, 'CompanyID'),
UserID: this.getOptionalStringParam(params, 'UserID'),
CourseID: this.getOptionalStringParam(params, 'CourseID'),
QuizID: this.getOptionalStringParam(params, 'QuizID'),
IncludeQuestions: this.getOptionalBooleanParam(params, 'IncludeQuestions', true),
IncludeAnswers: this.getOptionalBooleanParam(params, 'IncludeAnswers', true),
PassingOnly: this.getOptionalBooleanParam(params, 'PassingOnly', false),
DateFrom: this.getOptionalStringParam(params, 'DateFrom'),
DateTo: this.getOptionalStringParam(params, 'DateTo'),
SortBy: this.getOptionalStringParam(params, 'SortBy'),
SortOrder: (this.getOptionalStringParam(params, 'SortOrder') || 'desc') as 'asc' | 'desc',
MaxResults: this.getOptionalNumberParam(params, 'MaxResults', LearnWorldsBaseAction.LW_MAX_PAGE_SIZE),
};
}
// ----------------------------------------------------------------
// Private helpers – query building
// ----------------------------------------------------------------
private buildQuizQueryParams(
userId: string | undefined,
quizId: string | undefined,
passingOnly: boolean,
dateFrom: string | undefined,
dateTo: string | undefined,
sortBy: string,
sortOrder: string,
maxResults: number,
): LWQuizResultQueryParams {
const queryParams: LWQuizResultQueryParams = {
limit: Math.min(maxResults, LearnWorldsBaseAction.LW_MAX_PAGE_SIZE),
sort: sortBy,
order: sortOrder,
};
if (passingOnly) {
queryParams.passed = true;
}
const parsedFrom = this.safeParseDateToISO(dateFrom);
if (parsedFrom) {
queryParams.completed_after = parsedFrom;
}
const parsedTo = this.safeParseDateToISO(dateTo);
if (parsedTo) {
queryParams.completed_before = parsedTo;
}
if (quizId && userId) {
queryParams.user_id = userId;
}
return queryParams;
}
private buildQuizEndpoint(userId: string | undefined, courseId: string | undefined, quizId: string | undefined): string {
if (quizId) {
return `/quizzes/${quizId}/results`;
} else if (userId && courseId) {
return `/users/${userId}/courses/${courseId}/quiz-results`;
} else if (userId) {
return `/users/${userId}/quiz-results`;
} else if (courseId) {
return `/courses/${courseId}/quiz-results`;
}
return '/quiz-results';
}
private buildQueryString(queryParams: Record<string, string | number | boolean | undefined>): string {
const filtered: Record<string, string> = {};
for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined) {
filtered[key] = String(value);
}
}
const keys = Object.keys(filtered);
if (keys.length === 0) return '';
return '?' + new URLSearchParams(filtered).toString();
}
private extractQuizResultsArray(response: LWQuizResultsApiResponse): LWRawQuizResult[] {
const data = response.data;
if (Array.isArray(data)) {
return data;
}
if (data && typeof data === 'object' && 'data' in data && Array.isArray(data.data)) {
return data.data;
}
return [];
}
// ----------------------------------------------------------------
// Private helpers – processing
// ----------------------------------------------------------------
private async processQuizResults(
rawResults: LWRawQuizResult[],
userId: string | undefined,
courseId: string | undefined,
quizId: string | undefined,
includeQuestions: boolean,
includeAnswers: boolean,
contextUser: UserInfo,
): Promise<FormattedQuizResult[]> {
const formatted: FormattedQuizResult[] = [];
for (const result of rawResults) {
const formattedResult = this.buildBaseQuizResult(result, userId, courseId, quizId);
// Attach user info if available
if (result.user) {
formattedResult.user = {
id: result.user.id || '',
email: result.user.email || '',
name: result.user.name || `${result.user.first_name || ''} ${result.user.last_name || ''}`.trim(),
};
}
// Attach quiz info
formattedResult.quiz = await this.resolveQuizInfo(result, quizId, contextUser);
// Attach questions & answers if requested
if (includeQuestions || includeAnswers) {
await this.attachDetailedResults(formattedResult, includeQuestions, includeAnswers, contextUser);
}
formatted.push(formattedResult);
}
return formatted;
}
private buildBaseQuizResult(
result: LWRawQuizResult,
userId: string | undefined,
courseId: string | undefined,
quizId: string | undefined,
): FormattedQuizResult {
const score = result.score || 0;
const maxScore = result.max_score || result.total_points || 100;
return {
id: result.id || result.result_id || '',
userId: result.user_id || userId || '',
courseId: result.course_id || courseId || '',
quizId: result.quiz_id || quizId || '',
attemptNumber: result.attempt_number || result.attempt || 1,
score,
maxScore,
percentage: result.percentage || (score / (maxScore || 100)) * 100,
passed: result.passed || result.is_passing || false,
passingScore: result.passing_score || result.passing_percentage || 70,
startedAt: result.started_at,
completedAt: result.completed_at || result.submitted_at,
duration: result.duration || result.time_spent || 0,
durationText: this.formatDuration(result.duration || result.time_spent || 0),
};
}
private async resolveQuizInfo(
result: LWRawQuizResult,
filterQuizId: string | undefined,
contextUser: UserInfo,
): Promise<{ id: string; title: string; type: string; questionCount?: number } | undefined> {
if (result.quiz) {
return {
id: result.quiz.id || '',
title: result.quiz.title || result.quiz.name || '',
type: result.quiz.type || 'quiz',
questionCount: result.quiz.question_count || result.quiz.total_questions,
};
}
if (!filterQuizId && result.quiz_id) {
const quizResponse = await this.makeLearnWorldsRequest<LWQuizLookup>(`/quizzes/${result.quiz_id}`, 'GET', null, contextUser);
if (quizResponse.success !== false && quizResponse.data) {
const quiz = quizResponse.data;
return {
id: quiz.id || '',
title: quiz.title || quiz.name || '',
type: quiz.type || 'quiz',
questionCount: quiz.question_count || quiz.total_questions,
};
}
}
return undefined;
}
private async attachDetailedResults(
formattedResult: FormattedQuizResult,
includeQuestions: boolean,
includeAnswers: boolean,
contextUser: UserInfo,
): Promise<void> {
const detailsResponse = await this.makeLearnWorldsRequest<LWQuizDetailResponse>(`/quiz-results/${formattedResult.id}/details`, 'GET', null, contextUser);
if (detailsResponse.success === false || !detailsResponse.data) {
return;
}
const details = detailsResponse.data;
if (includeQuestions) {
formattedResult.questions = this.formatQuestions(details.questions || details.answers || []);
}
if (includeAnswers) {
formattedResult.answers = this.formatAnswers(details.answers || details.questions || []);
// Calculate additional metrics
const answers = formattedResult.answers;
formattedResult.metrics = this.calculateQuizMetrics(answers);
}
}
// ----------------------------------------------------------------
// Private helpers – formatting
// ----------------------------------------------------------------
/**
* Format quiz questions
*/
private formatQuestions(questions: LWRawQuestion[]): FormattedQuestion[] {
return questions.map((q, index) => ({
questionNumber: q.question_number || index + 1,
questionId: q.question_id || q.id || '',
questionText: q.question_text || q.question || q.text || '',
questionType: q.question_type || q.type || 'multiple-choice',
points: q.points || q.score || 1,
difficulty: q.difficulty || 'medium',
}));
}
/**
* Format user answers
*/
private formatAnswers(answers: LWRawAnswer[]): FormattedAnswer[] {
return answers.map((a, index) => ({
questionNumber: a.question_number || index + 1,
questionId: a.question_id,
userAnswer: this.coerceAnswerValue(a.user_answer || a.answer || a.selected_answer),
correctAnswer: this.coerceAnswerValue(a.correct_answer),
isCorrect: a.is_correct || a.correct || false,
pointsEarned: a.points_earned || (a.is_correct ? a.points || 1 : 0),
pointsPossible: a.points_possible || a.points || 1,
feedback: a.feedback,
timeSpent: a.time_spent,
answeredAt: a.answered_at,
}));
}
/**
* Coerce a raw answer value from the API to a typed QuizAnswerValue.
*/
private coerceAnswerValue(value: unknown): QuizAnswerValue {
if (value === undefined || value === null) return undefined;
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
if (Array.isArray(value)) return value.map(String);
return String(value);
}
private calculateQuizMetrics(answers: FormattedAnswer[]): QuizMetrics {
const correctCount = answers.filter((a) => a.isCorrect).length;
return {
correctAnswers: correctCount,
incorrectAnswers: answers.filter((a) => !a.isCorrect).length,
totalQuestions: answers.length,
accuracyRate: answers.length > 0 ? Number(((correctCount / answers.length) * 100).toFixed(1)) : 0,
};
}
// ----------------------------------------------------------------
// Private helpers – summary
// ----------------------------------------------------------------
private buildQuizResultsSummary(
formattedResults: FormattedQuizResult[],
userId: string | undefined,
courseId: string | undefined,
quizId: string | undefined,
dateFrom: string | undefined,
dateTo: string | undefined,
): QuizResultsSummary {
const totalResults = formattedResults.length;
const passedResults = formattedResults.filter((r) => r.passed).length;
const averageScore = totalResults > 0 ? formattedResults.reduce((sum, r) => sum + r.percentage, 0) / totalResults : 0;
const averageDuration = totalResults > 0 ? formattedResults.reduce((sum, r) => sum + (r.duration || 0), 0) / totalResults : 0;
// Group results by quiz if multiple quizzes
const quizBreakdown = !quizId ? this.buildQuizBreakdown(formattedResults) : null;
const filterType = userId ? (courseId ? 'user-course' : 'user') : courseId ? 'course' : 'all';
return {
totalResults,
passedResults,
failedResults: totalResults - passedResults,
passRate: totalResults > 0 ? Number(((passedResults / totalResults) * 100).toFixed(1)) : 0,
averageScore: Number(averageScore.toFixed(1)),
averageDuration,
averageDurationText: this.formatDuration(Math.round(averageDuration)),
dateRange: {
from: dateFrom || 'all-time',
to: dateTo || 'current',
},
filterType,
quizBreakdown,
};
}
private buildQuizBreakdown(formattedResults: FormattedQuizResult[]): Record<string, QuizBreakdownEntry> {
const resultsByQuiz: Record<string, QuizBreakdownEntry> = {};
for (const result of formattedResults) {
const quizTitle = result.quiz?.title || 'Unknown Quiz';
if (!resultsByQuiz[quizTitle]) {
resultsByQuiz[quizTitle] = {
results: [],
stats: { attempts: 0, averageScore: 0, passRate: 0 },
};
}
resultsByQuiz[quizTitle].results.push(result);
}
// Calculate stats for each quiz
for (const quizTitle of Object.keys(resultsByQuiz)) {
const quizResults = resultsByQuiz[quizTitle].results;
const qTotal = quizResults.length;
const qPassed = quizResults.filter((r) => r.passed).length;
const qAvgScore = quizResults.reduce((sum, r) => sum + r.percentage, 0) / qTotal;
resultsByQuiz[quizTitle].stats = {
attempts: qTotal,
averageScore: Number(qAvgScore.toFixed(1)),
passRate: Number(((qPassed / qTotal) * 100).toFixed(1)),
};
}
return resultsByQuiz;
}
// ----------------------------------------------------------------
// Params & Description metadata
// ----------------------------------------------------------------
/**
* Define the parameters this action expects
*/
public get Params(): ActionParam[] {
const baseParams = this.getCommonLMSParams();
const specificParams: ActionParam[] = [
{ Name: 'UserID', Type: 'Input', Value: null },
{ Name: 'CourseID', Type: 'Input', Value: null },
{ Name: 'QuizID', Type: 'Input', Value: null },
{ Name: 'IncludeQuestions', Type: 'Input', Value: true },
{ Name: 'IncludeAnswers', Type: 'Input', Value: true },
{ Name: 'PassingOnly', Type: 'Input', Value: false },
{ Name: 'DateFrom', Type: 'Input', Value: null },
{ Name: 'DateTo', Type: 'Input', Value: null },
{ Name: 'SortBy', Type: 'Input', Value: 'completed_at' },
{ Name: 'SortOrder', Type: 'Input', Value: 'desc' },
{ Name: 'MaxResults', Type: 'Input', Value: 100 },
{ Name: 'QuizResults', Type: 'Output', Value: null },
{ Name: 'TotalCount', Type: 'Output', Value: null },
{ Name: 'Summary', Type: 'Output', Value: null },
];
return [...baseParams, ...specificParams];
}
/**
* Metadata about this action
*/
public get Description(): string {
return 'Retrieves quiz and assessment results from LearnWorlds with detailed question/answer information';
}
}