UNPKG

@memberjunction/actions-bizapps-lms

Version:

LMS system integration actions for MemberJunction

846 lines (694 loc) 37.7 kB
# LearnWorlds Sidecar Integration Plan ## Executive Summary This document details the plan for building a "sidecar" integration with LearnWorlds (LW) where MemberJunction (MJ) owns the checkout and authentication flow, and LearnWorlds serves as the course delivery platform. The two primary objectives are: 1. **Onboarding Flow**: Stripe checkout Auth0 account creation LW user provisioning + enrollment immediate redirect into LW (no password-reset email) 2. **Data Retrieval Actions**: Actions that pull learner progress, enrollment status, certificates, and other data from LW consumers handle their own DB storage, scheduling, and mapping --- ## Part 1: Onboarding Flow ### 1.1 End-to-End User Journey ``` ┌─────────────────────────────────────────────────────────────────────────┐ USER JOURNEY 1. User lands on signup/purchase page 2. Stripe Checkout form collects payment + email 3. Stripe payment succeeds 4. Same page transitions to Auth0 login widget (inline, not redirect) └─ If user already has Auth0 account they log in └─ If new user they create Auth0 account (email prefilled) 5. Auth0 callback returns to our app with auth token 6. Our app calls backend "Onboard Learner" action └─ Backend creates user in LW (if not exists) └─ Backend enrolls user in purchased course(s)/bundle(s) └─ Backend generates SSO login URL from LW 7. User is immediately redirected to LW school (auto-logged in) └─────────────────────────────────────────────────────────────────────────┘ ``` ### 1.2 Why This Flow (vs. Password Reset Email) The password-reset-email approach has several problems: - **Friction**: User has to check email, click link, set password, then log in multiple steps where they can drop off - **Deliverability**: Emails can land in spam, be delayed, or get blocked by corporate filters - **Timing**: User just paid and is motivated NOW making them wait kills momentum - **Clunkiness**: LW's password-reset flow is designed for forgotten-password scenarios, not onboarding The Auth0 widget approach solves all of these: - **Immediate access**: User creates account and is redirected to LW in one smooth flow - **Auth0 is already our IdP**: We use Auth0 for SSO with LW, so the infrastructure exists - **Binding is clean**: Auth0 account Stripe transaction LW user are all linked by email - **No LW auth dependency**: We never rely on LW's own authentication Auth0 is the single source of truth ### 1.3 Architecture Decision: Action with Strongly-Typed Class Composition The onboarding orchestration is built as an **Action** that directly instantiates other action classes and calls their **strongly-typed public methods** (not via the Action execution interface). This gives us: - **Agent/workflow discoverability**: It's a registered Action, so agents can find and invoke it, and it can be scheduled - **Type safety**: Direct class instantiation with typed method signatures, not serialized params - **Code reuse**: The individual action classes (CreateUser, EnrollUser, SSO) are both standalone Actions AND reusable building blocks - **No rule violation**: We're not calling actions through the Action interface we're using classes directly **The refactor pattern** applied to all existing and new action classes: ```typescript // Each action class exposes a strongly-typed public method // alongside the InternalRunAction entry point class CreateUserAction extends LearnWorldsBaseAction { /** Strongly-typed, directly callable by other code */ public async CreateUser(params: CreateUserParams, contextUser: UserInfo): Promise<CreateUserResult> { // Core logic with typed params and result } /** Action framework entry point maps untyped params to typed method */ protected async InternalRunAction(params: RunActionParams): Promise<ActionResultSimple> { const typed = this.extractParams(params.Params); const result = await this.CreateUser(typed, params.ContextUser); return this.mapToActionResult(result); } } // Onboarding action instantiates classes directly and calls typed methods class OnboardLearnerAction extends LearnWorldsBaseAction { public async OnboardLearner(params: OnboardLearnerParams, contextUser: UserInfo): Promise<OnboardResult> { const createAction = new CreateUserAction(); const lwUser = await createAction.CreateUser({ Email: params.Email, ... }, contextUser); const enrollAction = new EnrollUserAction(); await enrollAction.EnrollUser({ UserId: lwUser.Id, ... }, contextUser); const ssoAction = new SSOLoginAction(); const ssoResult = await ssoAction.GenerateSSOUrl({ Email: params.Email, ... }, contextUser); return { LoginURL: ssoResult.Url, ... }; } protected async InternalRunAction(params: RunActionParams): Promise<ActionResultSimple> { const typed = this.extractParams(params.Params); const result = await this.OnboardLearner(typed, params.ContextUser); return this.mapToActionResult(result); } } ``` ### 1.4 Detailed Onboarding Flow — Technical Sequence ``` Frontend (Angular Element Widget) Backend (MJAPI) External Services ───────────────────────────────── ─────────────── ───────────────── 1. Embedded Angular Element shows Stripe Checkout form ─── Stripe.js ───────────────────────────────────────────────────── Stripe Payment succeeds Stripe returns payment_intent.id and customer email 2. Widget seamlessly transitions to Auth0 login/signup (inline, same widget) (email prefilled from Stripe) ─── Auth0 SDK ──────────────────────────────────────────────────── Auth0 User logs in OR creates account Auth0 returns ID token + user info 3. Widget calls backend POST /api/onboard-learner { email, firstName, lastName, courseIds[], caller provides LW course/bundle IDs bundleIds[], optional LW bundle IDs redirectTo } 4. Create/find LW user ─── LW API ─────────────────── LearnWorlds POST /v2/users (if new) GET /v2/users?email= (if exists) 5. Enroll in course(s) and/or bundle(s) ─── LW API ─────────────────── LearnWorlds POST /v2/enrollments (product_type: course|bundle) 6. Generate SSO login URL ─── LW API ─────────────────── LearnWorlds POST /v2/sso { email, redirect_to: course_url } Returns: { url, user_id } 7. Return SSO URL to frontend 8. window.location.href = ssoUrl User lands in LW, auto-logged in, on the course they purchased ``` ### 1.5 Course/Bundle Mapping — Consumer Responsibility The onboarding action accepts LW course IDs and/or LW bundle IDs directly. **The mapping from a Stripe product/purchase to specific LW course/bundle IDs is the consumer's responsibility**, not this package's. - The action's `CourseIds` and `BundleIds` input parameters take LW-native identifiers - Each MJ instance consumer (e.g., BC CDP) maintains their own mapping logic - This keeps the LMS package generic and reusable across different deployments - LearnWorlds bundles ARE a real API concept the enrollment endpoint (`POST /v2/enrollments`) accepts `product_type: "course"` or `product_type: "bundle"` (bundles map to "Learning Programs" in LW's new platform version) ### 1.6 New Components to Build #### 1.6.1 SSO Login Action (`sso-login.action.ts`) **LW API Endpoint**: `POST /v2/sso` ```typescript // Strongly-typed public method public async GenerateSSOUrl(params: SSOLoginParams, contextUser: UserInfo): Promise<SSOLoginResult> { ... } // Types interface SSOLoginParams { CompanyID: string; Email?: string; // Use email OR UserID UserID?: string; RedirectTo?: string; // URL to land on after login } interface SSOLoginResult { LoginURL: string; LearnWorldsUserID: string; } ``` #### 1.6.2 Update User Action (`update-user.action.ts`) **LW API Endpoint**: `PUT /v2/users/{userId}` ```typescript public async UpdateUser(params: UpdateUserParams, contextUser: UserInfo): Promise<UpdateUserResult> { ... } interface UpdateUserParams { CompanyID: string; UserID: string; // LW user ID Email?: string; FirstName?: string; LastName?: string; Username?: string; Role?: string; IsActive?: boolean; Tags?: string[]; CustomFields?: Record<string, string>; } ``` #### 1.6.3 Tag Management Actions **LW API Endpoints**: `POST /v2/users/{userId}/tags`, `DELETE /v2/users/{userId}/tags` ```typescript public async AttachTags(params: TagParams, contextUser: UserInfo): Promise<TagResult> { ... } public async DetachTags(params: TagParams, contextUser: UserInfo): Promise<TagResult> { ... } interface TagParams { CompanyID: string; UserID: string; Tags: string[]; } ``` #### 1.6.4 Onboard Learner Action (`onboard-learner.action.ts`) The orchestration action callable by agents, schedulable, AND used directly by the REST endpoint: ```typescript @RegisterClass(BaseAction, 'OnboardLearnerAction') export class OnboardLearnerAction extends LearnWorldsBaseAction { /** * Strongly-typed onboarding method: * 1. Find or create LW user * 2. Enroll in purchased courses/bundles * 3. Generate SSO login URL */ public async OnboardLearner( params: OnboardLearnerParams, contextUser: UserInfo ): Promise<OnboardLearnerResult> { // Instantiate action classes directly const createUserAction = new CreateUserAction(); const enrollAction = new EnrollUserAction(); const ssoAction = new SSOLoginAction(); // Step 1: Find or create user let lwUser: CreateUserResult; const existingUser = await this.findUserByEmail(params.Email, contextUser); if (existingUser) { lwUser = existingUser; } else { lwUser = await createUserAction.CreateUser({ CompanyID: params.CompanyID, Email: params.Email, FirstName: params.FirstName, LastName: params.LastName, SendWelcomeEmail: false // We handle auth via Auth0 }, contextUser); } // Step 2: Enroll in courses const enrollments = []; for (const courseId of (params.CourseIds || [])) { const enrollment = await enrollAction.EnrollUser({ CompanyID: params.CompanyID, UserId: lwUser.UserId, CourseId: courseId, ProductType: 'course' }, contextUser); enrollments.push(enrollment); } // Step 2b: Enroll in bundles for (const bundleId of (params.BundleIds || [])) { const enrollment = await enrollAction.EnrollUser({ CompanyID: params.CompanyID, UserId: lwUser.UserId, CourseId: bundleId, ProductType: 'bundle' }, contextUser); enrollments.push(enrollment); } // Step 3: Generate SSO URL const ssoResult = await ssoAction.GenerateSSOUrl({ CompanyID: params.CompanyID, Email: params.Email, RedirectTo: params.RedirectTo }, contextUser); return { Success: true, LoginURL: ssoResult.LoginURL, LearnWorldsUserId: lwUser.UserId, Enrollments: enrollments }; } /** Action framework entry point */ protected async InternalRunAction(params: RunActionParams): Promise<ActionResultSimple> { const typed = this.extractOnboardParams(params.Params); const result = await this.OnboardLearner(typed, params.ContextUser); return this.mapToActionResult(result); } } ``` #### 1.6.5 Typed Interfaces (Exported for Consumers) ```typescript // Published in package exports for TypeScript consumers export interface OnboardLearnerParams { CompanyID: string; Email: string; FirstName?: string; LastName?: string; CourseIds?: string[]; // LW course IDs BundleIds?: string[]; // LW bundle IDs (Learning Programs) RedirectTo?: string; // Where to land in LW after login } export interface OnboardLearnerResult { Success: boolean; LoginURL: string; LearnWorldsUserId: string; Enrollments: EnrollmentResult[]; ErrorMessage?: string; } ``` #### 1.6.6 REST Endpoint for Frontend Widget ```typescript // Custom REST endpoint in MJAPI for the Angular Element widget to call // POST /api/onboard-learner app.post('/api/onboard-learner', authenticateJWT, async (req, res) => { const { courseIds, bundleIds, redirectTo } = req.body; const contextUser = req.user; // From Auth0 JWT const action = new OnboardLearnerAction(); const result = await action.OnboardLearner({ CompanyID: resolveCompanyId(contextUser), Email: contextUser.Email, FirstName: contextUser.FirstName, LastName: contextUser.LastName, CourseIds: courseIds, BundleIds: bundleIds, RedirectTo: redirectTo }, contextUser); res.json({ success: result.Success, loginUrl: result.LoginURL, enrollments: result.Enrollments }); }); ``` ### 1.7 Frontend: Angular Element Checkout Widget The checkout experience is built as a **custom Angular Element** (Web Component) that can be dropped into any website. It seamlessly transitions from Stripe checkout to Auth0 login within the same widget no page redirects, no jarring context switches. This is a separate deliverable built by consumers (e.g., BC CDP) but the LMS package provides the backend actions it calls. The widget would: 1. **Step 1 Stripe**: Embed Stripe Elements form for payment collection 2. **Step 2 Auth0**: On payment success, transition to inline Auth0 login/signup (email prefilled from Stripe) 3. **Step 3 Onboard**: After Auth0 login, call `POST /api/onboard-learner` with course/bundle IDs 4. **Step 4 Redirect**: On success, `window.location.href = loginUrl` to send user to LW ### 1.8 Edge Cases and Error Handling | Scenario | Handling | |----------|----------| | **User already exists in LW** | `findUserByEmail` finds them; skip creation, proceed to enrollment + SSO | | **User already enrolled** | LW API returns enrollment details; skip re-enrollment, proceed to SSO | | **LW user creation fails** | Return error with details; payment is still valid (can retry) | | **SSO URL generation fails** | Fall back to LW's `login_url` from user creation response | | **Auth0 account exists but LW doesn't** | Normal flow create LW user, enroll, generate SSO | | **User closes browser after Stripe, before Auth0** | Payment exists but no LW user; consumer handles reconciliation | | **LW API rate limiting** | Exponential backoff retry in `makeLearnWorldsRequest`; surface error if exhausted | --- ## Part 2: Data Retrieval Actions ### 2.1 Design Philosophy The LMS package provides **actions that retrieve data from LearnWorlds and return strongly-typed JSON payloads**. The package does NOT: - Define database entities for storing LW data - Handle scheduling of sync jobs - Map LW data to local database schemas These are all **consumer responsibilities**. Each MJ instance (e.g., BC CDP) decides: - Which data to pull and how often - What local tables/entities to store it in - How to schedule the sync (via MJ's ScheduledJob infrastructure) - How to map LW data structures to their own schemas ### 2.2 What the Package Provides #### Existing Retrieval Actions (Already Built) - `GetLearnWorldsUsersAction` list/search users with filters - `GetLearnWorldsUserDetailsAction` comprehensive user profile - `GetLearnWorldsUserProgressAction` learning progress across courses - `GetLearnWorldsCoursesAction` course catalog - `GetLearnWorldsCourseDetailsAction` full course info with curriculum - `GetLearnWorldsUserEnrollmentsAction` user's enrollments with progress - `GetCourseAnalyticsAction` course performance analytics - `GetQuizResultsAction` quiz/assessment results - `GetCertificatesAction` earned certificates #### New Retrieval Action - `GetLearnWorldsBundlesAction` list bundles/learning programs (`GET /v2/bundles`) #### Exported TypeScript Interfaces for Consumers The package exports strongly-typed interfaces that consumers can use when processing action results in TypeScript: ```typescript // Consumers import these for type-safe processing of action output export interface LearnWorldsUser { Id: string; Email: string; Username: string; FirstName: string; LastName: string; Status: 'active' | 'inactive' | 'suspended'; Role: 'student' | 'instructor' | 'admin'; Tags: string[]; CustomFields: Record<string, string>; CreatedAt: string; LastLoginAt: string; LastActivityAt: string; TotalCertificates: number; Points: number; } export interface LearnWorldsEnrollment { Id: string; CourseId: string; UserId: string; Status: 'active' | 'completed' | 'expired' | 'suspended'; EnrolledAt: string; CompletedAt: string | null; ProgressPercentage: number; CompletedLessons: number; TotalLessons: number; TotalTimeSpent: number; Grade: number | null; CertificateEligible: boolean; CertificateIssuedAt: string | null; } export interface LearnWorldsCourse { Id: string; Title: string; Description: string; Status: 'draft' | 'published' | 'archived'; Price: number; Currency: string; IsFree: boolean; Duration: number; TotalEnrollments: number; CertificateEnabled: boolean; } export interface LearnWorldsBundle { Id: string; Title: string; Description: string; Price: number; Currency: string; CourseIds: string[]; Status: string; } export interface LearnWorldsCourseProgress { UserId: string; CourseId: string; ProgressPercentage: number; CompletedLessons: number; TotalLessons: number; CompletedUnits: number; TotalUnits: number; TotalTimeSpent: number; QuizScoreAverage: number | null; LastAccessedAt: string; } export interface LearnWorldsCertificate { Id: string; UserId: string; CourseId: string; IssuedAt: string; ExpiresAt: string | null; CertificateURL: string; VerificationCode: string; VerificationURL: string; } // Sync result types for consumers building sync services export interface LearnWorldsSyncPayload { Users: LearnWorldsUser[]; Courses: LearnWorldsCourse[]; Bundles: LearnWorldsBundle[]; Enrollments: LearnWorldsEnrollment[]; Progress: LearnWorldsCourseProgress[]; Certificates: LearnWorldsCertificate[]; SyncTimestamp: string; TotalApiCalls: number; Errors: SyncError[]; } export interface SyncError { Entity: string; EntityId: string; ErrorMessage: string; Timestamp: string; } ``` ### 2.3 Consumer-Side Sync Pattern (Example for BC CDP) The consumer (e.g., BC CDP) would build their own sync infrastructure using the LMS package: ```typescript // Example consumer-side sync service (NOT in the LMS package) import { GetLearnWorldsUsersAction, GetLearnWorldsUserEnrollmentsAction, LearnWorldsUser, LearnWorldsEnrollment } from '@memberjunction/actions-bizapps-lms'; class BCLearnerSyncService { async syncFromLearnWorlds() { // Use the typed public methods directly const getUsersAction = new GetLearnWorldsUsersAction(); const users = await getUsersAction.GetUsers({ CompanyID: myCompanyId, MaxResults: 1000 }, contextUser); // Map to local DB entities (consumer's schema) for (const lwUser of users.Users) { await this.upsertLocalLearnerRecord(lwUser); } } } ``` The consumer would then schedule this via MJ's `ScheduledJob` infrastructure. ### 2.4 Bulk Data Retrieval Action (New) For consumers who want to pull everything in one call: ```typescript @RegisterClass(BaseAction, 'GetLearnWorldsBulkDataAction') export class GetLearnWorldsBulkDataAction extends LearnWorldsBaseAction { /** * Pulls all data types from LW in a single call. * Returns a LearnWorldsSyncPayload with all entities. * Consumer decides what to do with the data. */ public async GetBulkData( params: BulkDataParams, contextUser: UserInfo ): Promise<LearnWorldsSyncPayload> { // Orchestrate all retrieval actions const getUsersAction = new GetLearnWorldsUsersAction(); const getCoursesAction = new GetLearnWorldsCoursesAction(); // ... etc const users = await getUsersAction.GetUsers({ ... }, contextUser); const courses = await getCoursesAction.GetCourses({ ... }, contextUser); // ... return { Users: users, Courses: courses, // ... SyncTimestamp: new Date().toISOString(), TotalApiCalls: totalCalls, Errors: errors }; } } ``` --- ## Part 3: Stripe Integration in Core MJ (Separate Workstream) ### 3.1 Current State There is **no Stripe SDK or integration** anywhere in MJ today. The Accounting BizApps package covers Business Central and QuickBooks, but not Stripe. ### 3.2 Two-Part Stripe Integration Stripe is ubiquitous enough to warrant a core MJ integration with both server-side actions (non-visual) and a client-side Angular widget (visual). #### Server-Side: `@memberjunction/actions-bizapps-payments` (new BizApps package) Non-visual integration actions that agents, workflows, and scheduled jobs can call: ``` packages/Actions/BizApps/Payments/ ├── src/ ├── base/ └── base-payment.action.ts # Shared payment provider patterns ├── providers/ └── stripe/ ├── stripe-base.action.ts # Stripe API auth, common utilities └── actions/ ├── verify-payment.action.ts # Verify payment_intent status ├── create-checkout-session.action.ts # Create Stripe Checkout session ├── get-customer.action.ts # Get customer details ├── get-payment-intent.action.ts # Get payment intent details ├── list-subscriptions.action.ts # List customer subscriptions ├── create-payment-link.action.ts # Generate payment links └── list-invoices.action.ts # List customer invoices ├── interfaces/ └── stripe.types.ts # Exported typed interfaces └── index.ts ├── package.json # Depends on `stripe` npm package ``` Same pattern as LMS each action exposes a strongly-typed public method alongside `InternalRunAction`. Extensible for future payment providers (PayPal, Square, etc.) via the base class. #### Client-Side: `@memberjunction/ng-stripe` (new Angular/Generic package) Visual integration an Angular wrapper widget for Stripe Elements: ``` packages/Angular/Generic/stripe/ ├── src/lib/ ├── components/ ├── stripe-payment-form/ # Wraps Stripe Elements (card, payment) ├── stripe-checkout-button/ # One-click checkout button └── stripe-payment-status/ # Payment confirmation display ├── services/ ├── stripe-config.service.ts # Publishable key management └── stripe-elements.service.ts # Stripe.js SDK lifecycle ├── types/ └── stripe-widget.types.ts └── module.ts # StripeModule for NgModule consumers ├── package.json # Depends on `@stripe/stripe-js` ``` The widget provides: - Drop-in `<mj-stripe-payment-form>` component for embedding in any Angular app or Angular Element - Event emitters: `(paymentSuccess)`, `(paymentError)`, `(paymentProcessing)` - Configurable via `@Input()` properties: amount, currency, publishable key, payment method types - Handles Stripe.js SDK loading and lifecycle - Can be combined with auth widgets in a checkout flow ### 3.3 Scope for This Project For the LW sidecar integration, the onboarding action does NOT need Stripe payment verification. The flow is: 1. Frontend handles Stripe checkout (via `@memberjunction/ng-stripe` widget or consumer's own Stripe integration) 2. Frontend handles Auth0 login 3. Frontend calls onboard-learner with LW course/bundle IDs 4. Backend only talks to LW no Stripe server-side calls needed If Stripe server-side verification is desired as an extra safety layer (verifying the payment_intent is actually paid before provisioning), that would be part of the Payments BizApps package and called from the consumer's REST endpoint, not from the LMS package itself. --- ## Part 3b: Generic Auth Widget in Core MJ (Separate Workstream) ### Current State The existing auth integration lives at `packages/Angular/Explorer/auth-services/` and is **tightly coupled to the Explorer app**. It provides Auth0 authentication via `MJAuth0Provider` but is not reusable outside Explorer. ### Recommendation: Generic Auth Package Under `Angular/Generic` Build a provider-agnostic auth widget under `packages/Angular/Generic/` that: - Provides embeddable login/signup UI components - Supports multiple auth providers (Auth0 initially, extensible to others) - Can be used in Angular Elements (Web Components) for embedding in external sites - Can be consumed by Explorer (replacing or wrapping the current Explorer-specific auth) #### Proposed Structure ``` packages/Angular/Generic/auth/ ├── src/lib/ ├── components/ ├── auth-login-widget/ # Embeddable login/signup form ├── auth-status-indicator/ # Shows logged-in state └── auth-profile-menu/ # User profile dropdown ├── providers/ ├── auth-provider.base.ts # Abstract base for auth providers ├── auth0/ └── auth0-provider.service.ts # Auth0 implementation └── index.ts ├── services/ ├── auth-config.service.ts # Configuration management └── auth-state.service.ts # Observable auth state ├── types/ └── auth.types.ts └── module.ts # GenericAuthModule ├── package.json ``` #### Key Design Goals - **Provider-agnostic**: The widget components work with any auth provider that implements the base interface - **Embeddable**: Works in Angular Elements (Web Components) for embedding in marketing sites, checkout pages, etc. - **Email prefill**: The login widget accepts an `email` input to prefill from a prior step (e.g., Stripe checkout) - **Event-driven**: Emits `(loginSuccess)`, `(loginError)`, `(signupSuccess)` events - **Explorer-compatible**: Explorer's auth can migrate to use this generic package, reducing duplication #### Relationship to the LW Sidecar Checkout Widget The consumer's Angular Element checkout widget (e.g., BC CDP) would compose: 1. `<mj-stripe-payment-form>` from `@memberjunction/ng-stripe` 2. `<mj-auth-login-widget>` from `@memberjunction/ng-auth` (with email prefilled from Stripe) 3. Custom checkout logic that calls the onboarding REST endpoint 4. Redirect to LW on success This gives maximum reusability both widgets are useful independently and together. --- ## Part 4: Implementation Plan ### Phase 1: Refactor Existing Actions + Build New Actions Refactor existing action classes to expose strongly-typed public methods, and build the new actions: | # | Item | File | Details | |---|------|------|---------| | 1 | Refactor `CreateUserAction` | `create-user.action.ts` | Extract `CreateUser(params, contextUser)` public method | | 2 | Refactor `EnrollUserAction` | `enroll-user.action.ts` | Extract `EnrollUser(params, contextUser)` public method; add bundle support via `product_type` | | 3 | Refactor all other existing actions | Various | Same pattern: typed public method + thin InternalRunAction | | 4 | **NEW**: SSO Login action | `sso-login.action.ts` | `POST /v2/sso` `GenerateSSOUrl()` | | 5 | **NEW**: Update User action | `update-user.action.ts` | `PUT /v2/users/{id}` `UpdateUser()` | | 6 | **NEW**: Attach Tags action | `attach-tags.action.ts` | `POST /v2/users/{id}/tags` `AttachTags()` | | 7 | **NEW**: Detach Tags action | `detach-tags.action.ts` | `DELETE /v2/users/{id}/tags` `DetachTags()` | | 8 | **NEW**: Get Bundles action | `get-bundles.action.ts` | `GET /v2/bundles` `GetBundles()` | | 9 | Export typed interfaces | `interfaces/` | All strongly-typed param/result interfaces | | 10 | Action metadata | `.bizapps-actions.json` | Metadata entries for new actions | | 11 | Unit tests | `__tests__/` | Tests for all new and refactored actions | ### Phase 2: Onboarding Action | # | Item | Details | |---|------|---------| | 1 | `OnboardLearnerAction` | Orchestration action with typed `OnboardLearner()` method | | 2 | `findUserByEmail` utility | Shared method in base class for user lookup | | 3 | Action metadata | Entry in `.bizapps-actions.json` | | 4 | Unit tests | Mock LW API calls, test full flow + edge cases | ### Phase 3: Bulk Data Retrieval Action | # | Item | Details | |---|------|---------| | 1 | `GetLearnWorldsBulkDataAction` | Orchestrates all retrieval actions, returns `LearnWorldsSyncPayload` | | 2 | Concurrency control | Limit parallel API calls to avoid LW rate limiting | | 3 | Error collection | Partial failures logged in `Errors` array, doesn't abort entire sync | | 4 | Action metadata | Entry in `.bizapps-actions.json` | | 5 | Unit tests | Mock all sub-action calls, verify payload assembly | ### Phase 4: Frontend (Consumer-Built, Not in LMS Package) The Angular Element checkout widget is built by the consumer (BC CDP). The LMS package provides: - Backend actions the widget calls - TypeScript interfaces the widget can use - REST endpoint pattern (documented, consumer implements in their MJAPI instance) --- ## Part 5: Package Structure ``` packages/Actions/BizApps/LMS/ ├── src/ ├── base/ └── base-lms.action.ts (existing, unchanged) ├── providers/ └── learnworlds/ ├── learnworlds-base.action.ts (existing, unchanged) ├── actions/ ├── create-user.action.ts (REFACTOR: add typed public method) ├── enroll-user.action.ts (REFACTOR: add typed public method + bundle support) ├── get-users.action.ts (REFACTOR: add typed public method) ├── get-user-details.action.ts (REFACTOR: add typed public method) ├── get-user-progress.action.ts (REFACTOR: add typed public method) ├── get-user-enrollments.action.ts (REFACTOR: add typed public method) ├── get-courses.action.ts (REFACTOR: add typed public method) ├── get-course-details.action.ts (REFACTOR: add typed public method) ├── get-course-analytics.action.ts (REFACTOR: add typed public method) ├── get-quiz-results.action.ts (REFACTOR: add typed public method) ├── get-certificates.action.ts (REFACTOR: add typed public method) ├── update-user-progress.action.ts (REFACTOR: add typed public method) ├── sso-login.action.ts NEW ├── update-user.action.ts NEW ├── attach-tags.action.ts NEW ├── detach-tags.action.ts NEW ├── get-bundles.action.ts NEW ├── onboard-learner.action.ts NEW (orchestration) ├── get-bulk-data.action.ts NEW (bulk retrieval) └── index.ts (update exports) └── interfaces/ ├── user.types.ts NEW (or refactored from inline) ├── course.types.ts NEW ├── enrollment.types.ts NEW ├── onboarding.types.ts NEW ├── sync.types.ts NEW └── index.ts NEW └── index.ts (update exports) ├── src/__tests__/ ├── sso-login.action.test.ts NEW ├── update-user.action.test.ts NEW ├── onboard-learner.action.test.ts NEW ├── get-bulk-data.action.test.ts NEW └── ... └── package.json (no new deps needed Stripe is consumer-side) ``` ### No New Dependencies The LMS package does NOT need Stripe as a dependency. Stripe handling happens: - **Client-side**: In the Angular Element widget (consumer-built) via `@stripe/stripe-js` - **Server-side** (if needed): In a future `@memberjunction/actions-bizapps-payments` package --- ## Part 6: Resolved Questions | # | Question | Decision | |---|----------|----------| | 1 | **Course-to-product mapping** | Consumer's responsibility. The onboarding action accepts LW course IDs and bundle IDs directly. Each MJ instance maintains its own mapping (e.g., BC CDP has its own mapping table). | | 2 | **Auth0 widget placement** | Inline within a custom Angular Element widget. The widget transitions seamlessly from Stripe to Auth0 within the same embedded component no redirects. | | 3 | **Data sync approach** | Actions return JSON payloads with typed interfaces. Consumers handle their own DB storage, scheduling, and mapping. The package exports `LearnWorldsSyncPayload` and related interfaces for TypeScript consumers. | | 4 | **Stripe integration** | Not in the LMS package. A future `@memberjunction/actions-bizapps-payments` package is recommended for core MJ. For now, Stripe is handled client-side in the Angular Element widget. | | 5 | **SSO plan level** | Confirmed high-end LW plan with SSO API access. | | 6 | **Multi-course purchases** | Supported. `OnboardLearner` accepts arrays of `CourseIds` and `BundleIds`. Bundles are a real LW API concept (`product_type: "bundle"` in the enrollment endpoint). | ## Part 7: Open Items 1. **LW API rate limits** Need to verify LW's rate limits. The bulk data retrieval could generate many API calls. May need to add configurable concurrency limits. 2. **LW bundle enrollment API** Need to verify the exact endpoint and parameters for bundle enrollment. The enrollment endpoint may use `POST /v2/enrollments` with `product_type: "bundle"` or a separate path. 3. **Webhook support (future)** Not in initial scope. If consumers need real-time sync, LW webhooks can be added later as a separate enhancement.