UNPKG

@memberjunction/actions-bizapps-lms

Version:

LMS system integration actions for MemberJunction

524 lines (458 loc) 18.2 kB
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 { GetCertificatesParams, GetCertificatesResult, FormattedCertificate, CertificatesSummary } from '../interfaces'; // ---------------------------------------------------------------- // File-local interfaces for raw LearnWorlds API shapes // ---------------------------------------------------------------- /** Query parameters sent to the certificates endpoint */ interface LWCertificateQueryParams { limit: number; sort: string; order: string; issued_after?: string; issued_before?: string; [key: string]: string | number | boolean | undefined; } /** Raw certificate data from the LearnWorlds API */ interface LWRawCertificate { id?: string; certificate_id?: string; user_id?: string; course_id?: string; certificate_number?: string; number?: string; issued_at?: string; created_at?: string; expires_at?: string; status?: string; grade?: number; score?: number; completion_percentage?: number; user?: { id?: string; email?: string; name?: string; first_name?: string; last_name?: string; username?: string; }; course?: { id?: string; title?: string; duration?: number; }; pdf_url?: string; download_url?: string; image_url?: string; public_url?: string; certificate_url?: string; verification_url?: string; verification_code?: string; qr_code_url?: string; } /** Wrapper response that may contain a data array */ interface LWCertificatesApiResponse { success?: boolean; message?: string; data?: LWRawCertificate[] | LWCertificatesNestedData | LWRawCertificate; } /** Nested data shape when the API wraps certificates inside .data.data */ interface LWCertificatesNestedData { data?: LWRawCertificate[]; id?: string; [key: string]: unknown; } /** User lookup response */ interface LWUserLookup { success?: boolean; data?: { id?: string; email?: string; first_name?: string; last_name?: string; username?: string; }; } /** Course lookup response */ interface LWCourseLookup { success?: boolean; data?: { id?: string; title?: string; duration?: number; }; } /** * Action to retrieve certificates earned by users in LearnWorlds */ @RegisterClass(BaseAction, 'GetCertificatesAction') export class GetCertificatesAction extends LearnWorldsBaseAction { // ---------------------------------------------------------------- // Typed public method – can be called directly from code // ---------------------------------------------------------------- /** * Get certificates for a user or course. * Throws on any error. */ public async GetCertificates(params: GetCertificatesParams, contextUser: UserInfo): Promise<GetCertificatesResult> { this.SetCompanyContext(params.CompanyID); const { UserID: userId, CourseID: courseId, DateFrom: dateFrom, DateTo: dateTo, IncludeDownloadLinks: includeDownloadLinksRaw, SortBy: sortBy = 'issued_at', SortOrder: sortOrder = 'desc', MaxResults: maxResults = 100, } = params; const includeDownloadLinks = includeDownloadLinksRaw !== false; // Require either userId or courseId if (!userId && !courseId) { throw new Error('Either UserID or CourseID is required'); } // Build query parameters const queryParams: LWCertificateQueryParams = { limit: Math.min(maxResults, LearnWorldsBaseAction.LW_MAX_PAGE_SIZE), sort: sortBy, order: sortOrder, }; const parsedFrom = this.safeParseDateToISO(dateFrom); if (parsedFrom) { queryParams.issued_after = parsedFrom; } const parsedTo = this.safeParseDateToISO(dateTo); if (parsedTo) { queryParams.issued_before = parsedTo; } // Validate path segments before URL interpolation if (userId) this.validatePathSegment(userId, 'UserID'); if (courseId) this.validatePathSegment(courseId, 'CourseID'); // Determine endpoint based on parameters const endpoint = this.buildCertificatesEndpoint(userId, courseId); // Build query string const queryString = this.buildQueryString(queryParams); // Get certificates const certificatesResponse = await this.makeLearnWorldsRequest<LWCertificatesApiResponse>(endpoint + queryString, 'GET', null, contextUser); if (certificatesResponse.success === false) { throw new Error(certificatesResponse.message || 'Failed to retrieve certificates'); } // Handle single certificate vs array const certificatesArray = this.extractCertificatesArray(certificatesResponse); // Process each certificate const formattedCertificates = await this.processCertificates(certificatesArray, userId, courseId, includeDownloadLinks, contextUser); // Calculate summary const summary = this.buildCertificatesSummary(formattedCertificates, userId, courseId, dateFrom, dateTo); return { Certificates: formattedCertificates, TotalCount: formattedCertificates.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.extractCertificatesParams(Params); const result = await this.GetCertificates(typedParams, ContextUser); this.setOutputParam(Params, 'Certificates', result.Certificates); this.setOutputParam(Params, 'TotalCount', result.TotalCount); this.setOutputParam(Params, 'Summary', result.Summary); return this.buildSuccessResult(`Retrieved ${result.TotalCount} certificate(s)`, Params); } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; return this.buildErrorResult('ERROR', `Error retrieving certificates: ${msg}`, Params); } } // ---------------------------------------------------------------- // Private helpers // ---------------------------------------------------------------- private extractCertificatesParams(params: ActionParam[]): GetCertificatesParams { return { CompanyID: this.getRequiredStringParam(params, 'CompanyID'), UserID: this.getOptionalStringParam(params, 'UserID'), CourseID: this.getOptionalStringParam(params, 'CourseID'), DateFrom: this.getOptionalStringParam(params, 'DateFrom'), DateTo: this.getOptionalStringParam(params, 'DateTo'), IncludeDownloadLinks: this.getOptionalBooleanParam(params, 'IncludeDownloadLinks', true), 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 buildCertificatesEndpoint(userId?: string, courseId?: string): string { if (userId && courseId) { return `/users/${userId}/courses/${courseId}/certificate`; } else if (userId) { return `/users/${userId}/certificates`; } else if (courseId) { return `/courses/${courseId}/certificates`; } return '/certificates'; } 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 extractCertificatesArray(response: LWCertificatesApiResponse): LWRawCertificate[] { const rawData = response.data; if (Array.isArray(rawData)) { return rawData; } if (rawData && typeof rawData === 'object') { const nested = rawData as LWCertificatesNestedData; if (Array.isArray(nested.data)) { return nested.data; } if (nested.id) { return [rawData as LWRawCertificate]; } } return []; } private async processCertificates( certificatesArray: LWRawCertificate[], userId: string | undefined, courseId: string | undefined, includeDownloadLinks: boolean, contextUser: UserInfo, ): Promise<FormattedCertificate[]> { // Pre-fetch all user and course info in batch to avoid per-item API calls const userInfoMap = await this.batchResolveUserInfo(certificatesArray, userId, contextUser); const courseInfoMap = await this.batchResolveCourseInfo(certificatesArray, courseId, contextUser); return certificatesArray.map((cert) => { const formattedCert = this.buildBaseCertificate(cert, userId, courseId); // Attach user info from pre-fetched map const certUserId = cert.user_id || ''; formattedCert.user = cert.user ? this.extractInlineUserInfo(cert) : userInfoMap.get(certUserId); // Attach course info from pre-fetched map const certCourseId = cert.course_id || ''; formattedCert.course = cert.course ? this.extractInlineCourseInfo(cert) : courseInfoMap.get(certCourseId); // Attach download links if requested if (includeDownloadLinks) { formattedCert.downloadLinks = { pdf: cert.pdf_url || cert.download_url, image: cert.image_url, publicUrl: cert.public_url || cert.certificate_url, }; } // Attach verification info formattedCert.verification = { url: cert.verification_url, code: cert.verification_code, qrCode: cert.qr_code_url, }; return formattedCert; }); } /** * Extracts inline user info when the cert already includes user data. */ private extractInlineUserInfo(cert: LWRawCertificate): { id: string; email: string; name: string } | undefined { if (!cert.user) return undefined; return { id: cert.user.id || cert.user_id || '', email: cert.user.email || '', name: cert.user.name || `${cert.user.first_name || ''} ${cert.user.last_name || ''}`.trim(), }; } /** * Extracts inline course info when the cert already includes course data. */ private extractInlineCourseInfo(cert: LWRawCertificate): { id: string; title: string; duration?: number } | undefined { if (!cert.course) return undefined; return { id: cert.course.id || cert.course_id || '', title: cert.course.title || '', duration: cert.course.duration, }; } /** * Batch-fetches user info for all certificates that need external lookup. * Returns a map from userId to user info. */ private async batchResolveUserInfo( certs: LWRawCertificate[], filterUserId: string | undefined, contextUser: UserInfo, ): Promise<Map<string, { id: string; email: string; name: string }>> { const map = new Map<string, { id: string; email: string; name: string }>(); // Only need to look up users when cert doesn't include inline user data // and we're not filtering by a single user if (filterUserId) return map; const idsToFetch = [...new Set(certs.filter((c) => !c.user && c.user_id).map((c) => c.user_id!))]; const results = await Promise.all( idsToFetch.map(async (uid) => { try { const resp = await this.makeLearnWorldsRequest<LWUserLookup>(`/users/${uid}`, 'GET', null, contextUser); if (resp.success !== false && resp.data) { return { id: uid, info: { id: resp.data.id || '', email: resp.data.email || '', name: `${resp.data.first_name || ''} ${resp.data.last_name || ''}`.trim() || resp.data.username || '', }, }; } } catch (error) { console.warn(`Failed to fetch user info for ${uid}:`, error instanceof Error ? error.message : error); } return null; }), ); for (const result of results) { if (result) map.set(result.id, result.info); } return map; } /** * Batch-fetches course info for all certificates that need external lookup. * Returns a map from courseId to course info. */ private async batchResolveCourseInfo( certs: LWRawCertificate[], filterCourseId: string | undefined, contextUser: UserInfo, ): Promise<Map<string, { id: string; title: string; duration?: number }>> { const map = new Map<string, { id: string; title: string; duration?: number }>(); // Only need to look up courses when cert doesn't include inline course data // and we're not filtering by a single course if (filterCourseId) return map; const idsToFetch = [...new Set(certs.filter((c) => !c.course && c.course_id).map((c) => c.course_id!))]; const results = await Promise.all( idsToFetch.map(async (cid) => { try { const resp = await this.makeLearnWorldsRequest<LWCourseLookup>(`/courses/${cid}`, 'GET', null, contextUser); if (resp.success !== false && resp.data) { return { id: cid, info: { id: resp.data.id || '', title: resp.data.title || '', duration: resp.data.duration, }, }; } } catch (error) { console.warn(`Failed to fetch course info for ${cid}:`, error instanceof Error ? error.message : error); } return null; }), ); for (const result of results) { if (result) map.set(result.id, result.info); } return map; } private buildBaseCertificate(cert: LWRawCertificate, userId: string | undefined, courseId: string | undefined): FormattedCertificate { return { id: cert.id || cert.certificate_id || '', userId: cert.user_id || userId || '', courseId: cert.course_id || courseId || '', certificateNumber: cert.certificate_number || cert.number, issuedAt: cert.issued_at || cert.created_at, expiresAt: cert.expires_at, status: cert.status || 'active', grade: cert.grade, score: cert.score, completionPercentage: cert.completion_percentage || 100, verification: { url: undefined, code: undefined, qrCode: undefined }, }; } private buildCertificatesSummary( certificates: FormattedCertificate[], userId: string | undefined, courseId: string | undefined, dateFrom: string | undefined, dateTo: string | undefined, ): CertificatesSummary { const totalCertificates = certificates.length; const now = new Date(); const activeCertificates = certificates.filter((c) => c.status === 'active' && (!c.expiresAt || new Date(c.expiresAt) > now)).length; const expiredCertificates = certificates.filter((c) => c.expiresAt && new Date(c.expiresAt) <= now).length; // Group by course or user depending on the filter let groupedData: Record<string, FormattedCertificate[]> | null = null; if (userId && !courseId) { groupedData = this.groupCertificatesByCourse(certificates); } else if (courseId && !userId) { groupedData = this.groupCertificatesByUser(certificates); } const filterType = userId && courseId ? 'user-course' : userId ? 'user' : 'course'; return { totalCertificates, activeCertificates, expiredCertificates, dateRange: { from: dateFrom || 'all-time', to: dateTo || 'current', }, filterType, groupedData, }; } private groupCertificatesByCourse(certificates: FormattedCertificate[]): Record<string, FormattedCertificate[]> { const grouped: Record<string, FormattedCertificate[]> = {}; for (const cert of certificates) { const courseTitle = cert.course?.title || 'Unknown Course'; if (!grouped[courseTitle]) { grouped[courseTitle] = []; } grouped[courseTitle].push(cert); } return grouped; } private groupCertificatesByUser(certificates: FormattedCertificate[]): Record<string, FormattedCertificate[]> { const grouped: Record<string, FormattedCertificate[]> = {}; for (const cert of certificates) { const userName = cert.user?.name || cert.user?.email || 'Unknown User'; if (!grouped[userName]) { grouped[userName] = []; } grouped[userName].push(cert); } return grouped; } // ---------------------------------------------------------------- // 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: 'DateFrom', Type: 'Input', Value: null }, { Name: 'DateTo', Type: 'Input', Value: null }, { Name: 'IncludeDownloadLinks', Type: 'Input', Value: true }, { Name: 'SortBy', Type: 'Input', Value: 'issued_at' }, { Name: 'SortOrder', Type: 'Input', Value: 'desc' }, { Name: 'MaxResults', Type: 'Input', Value: 100 }, { Name: 'Certificates', 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 certificates earned by users in LearnWorlds courses with download links and verification info'; } }