# LMS Integration This document describes the pluggable LMS (Learning Management System) integration in Notebooks Hub: how the architecture fits together, how configuration and routing work, and how to add a new LMS provider. ## Overview The LMS stack is a **gateway** over vendor REST APIs. Runtime wiring is driven by MongoDB config. Application code never calls Docebo (or a future vendor) directly—it uses a normalized `LmsGateway` contract. Main consumers: - **Admin APIs / Admin Console** — CRUD for LMS implementations, active route, and live course lookup - `LmsSyncService` — maps LMS course enrollments to OpenFGA group membership - **JupyterHub user init** — looks up courses for the signed-in user via the router Only one provider type is shipped today (`docebo`). Additional vendors are added by implementing a datasource + adapter and registering them in the factory and registry (see [Adding a new LMS datasource](#adding-a-new-lms-datasource)). ## Architecture ```{mermaid} flowchart TB subgraph consumers [Consumers] Admin["LmsAdminController"] SyncCtrl["LmsSyncController"] JH["JupyterHubController"] end subgraph routing [Routing and registry] Router["LmsRouterService"] RouteRepo["LmsRouteConfigRepository"] Registry["DynamicLmsRegistry"] ImplRepo["LmsImplementationConfigRepository"] Tokens["LmsTokenManager"] end subgraph provider [Per-implementation stack] Factory["createLmsGateway"] DS["REST DataSource"] Adapter["Vendor adapter"] Vendor["Vendor LMS API"] end subgraph sideeffects [Side effects] Sync["LmsSyncService"] FGA["OpenFGA tuples"] end Admin --> ImplRepo Admin --> RouteRepo Admin --> Registry Admin --> Router SyncCtrl --> Sync JH --> Router Sync --> Router Sync --> ImplRepo Sync --> FGA Router --> RouteRepo Router --> Registry Registry --> ImplRepo Registry --> Tokens Registry --> DS Registry --> Factory Factory --> Adapter Adapter --> DS DS --> Vendor Tokens -->|OAuth token POST| Vendor ``` ### Request path 1. Caller uses `LmsRouterService` with an optional `implementationKey`, or the active route key. 2. Router asks `DynamicLmsRegistry.getGateway(key)`. 3. Registry loads `LmsImplementationConfig`, rejects disabled implementations (`503`), and obtains an `Authorization` header from `LmsTokenManager`. 4. On cache miss (or config/auth signature change), the registry builds a LoopBack REST datasource, wraps it with `createLmsGateway`, and caches the result. 5. Caller invokes normalized `LmsGateway` methods. ### Gateway contract Defined in `[packages/API/src/services/lms-gateway.types.ts](../../../packages/API/src/services/lms-gateway.types.ts)`: ```typescript export interface LmsGateway { getUsers(username?: string): Promise; getCourses(name?: string): Promise; getUsersByCourse(courseName: string): Promise; getCoursesByUser(username: string): Promise; } ``` Semantics implementers must match: - `getUsers` / `getCourses` return normalized `LmsUser` / `LmsCourse` models. - `getUsersByCourse` returns **deduped lowercase emails/usernames** (`string[]`), not `LmsUser[]`. The parameter is the **provider’s course identifier** (sync passes course **ids** for Docebo despite the `courseName` name). - `getCoursesByUser` takes a username/email, resolves any vendor user id if needed, and returns courses. Vendor JSON stays behind the adapter. REST datasources expose raw clients (`Promise` or vendor-specific types); only adapters are application-facing. ### Key components | Layer | Location | Role | | -------------- | ----------------------------------------------------- | --------------------------------------------------------------- | | Contract | `services/lms-gateway.types.ts` | `LmsGateway` interface | | Factory | `services/lms/lms-gateway.factory.ts` | `implementationType` → adapter | | Registry | `services/dynamic-lms-registry.service.ts` | Build/cache datasources + gateways; invalidate on config change | | Router | `services/lms-router.service.ts` | Resolve active (or override) key; delegate gateway methods | | Token manager | `services/lms-token-manager.service.ts` | OAuth-style token fetch/cache; bearer header | | Sync | `services/lms-sync.service.ts` | Course enrollments → OpenFGA group members | | Docebo DS | `datasources/lms/docebo-lms.datasource.ts` | REST operations + `buildDoceboLmsDataSource` | | Docebo adapter | `services/lms/adapters/docebo-lms-gateway.adapter.ts` | Normalize Docebo payloads | | Admin API | `controllers/lms-admin.controller.ts` | JWT-protected config CRUD and route | | Sync API | `controllers/lms-sync.controller.ts` | `POST /lms/update/courses` | Services are registered as singletons in `application.ts` (`LmsTokenManager`, `DynamicLmsRegistry`, `LmsRouterService`, `LmsSyncService`). ## Configuration and routing ### Implementation config (`lms_implementation_config`) | Field | Meaning | | -------------------- | ----------------------------------------------------------- | | `name` | Stable key (path param `{key}`); unique | | `implementationType` | e.g. `docebo` (`LmsImplementation` enum) | | `baseUrl` | Vendor API base URL | | `auth` | Nested `LmsAuthConfig` (see below) | | `enabled` | `false` → gateway calls return `503` | | `courses` | `LmsCourseGroupMapping[]` used by sync (`course` ↔ `group`) | ### Route config (`lms_route_config`) Singleton document with `activeImplementationKey`. If missing, the repository auto-creates one (default key `default`). - `PUT /lms/route` requires the target implementation to exist. - Deleting an implementation that is currently active is blocked; switch the route first. - Changing the active route does **not** invalidate gateway caches (caches are keyed by implementation, not by “active”). ### Auth and tokens `LmsTokenManager` obtains a bearer token for each implementation: 1. If a cached token for the auth signature is still valid, reuse it. 2. Otherwise `POST` a JSON grant payload to `auth.authUrl` (`grant_type`, optional `client_id` / `client_secret` / `username` / `password` / `scope`). 3. Cache `access_token` using `expires_in` (default TTL 3600s). Concurrent refreshes per key are deduped. The authorization header is baked into the REST datasource at build time. When the token or auth config changes, the registry signature changes and the datasource is rebuilt. New implementations require `auth.authUrl`. On update, empty/omitted secret fields (`token`, `password`, `clientSecret`) are **preserved** from the previous document so the admin UI can omit them on edit. Today only simple credential-based grants are fully implemented. Roadmap includes implementation of static API tokens and complex multi-step authentication flows (i.e., OAuth2.0). ### Webhook Sync `POST /lms/update/courses` → `LmsSyncService.updateCourses()`: 1. Resolve the active implementation key and load its `courses` mappings. 2. For each mapping, call `getUsersByCourse(course.id)`. 3. For each returned email, create an OpenFGA member tuple for the mapped `group.id` (`409` treated as already-member success). 4. Courses are processed with `Promise.allSettled` so one mapping failure does not abort the others at the outer level. Course→group mappings live on the implementation config; they are not part of the gateway interface itself. ### Admin REST surface All admin routes use `@authenticate('oauth-jwt')`: | Method | Path | Behavior | | -------- | ------------------------------------ | ------------------------------------------- | | `GET` | `/lms/implementations` | List configs | | `GET` | `/lms/implementations/{key}` | One config | | `GET` | `/lms/implementations/{key}/courses` | Live courses via router (`?name=` optional) | | `PUT` | `/lms/implementations/{key}` | Upsert; invalidates registry for that key | | `DELETE` | `/lms/implementations/{key}` | Delete if not active route; invalidate | | `GET` | `/lms/route` | Active route | | `PUT` | `/lms/route` | Set `{ activeImplementationKey }` | Sync: | Method | Path | Behavior | | ------ | --------------------- | --------------------- | | `POST` | `/lms/update/courses` | Run course→group sync | The admin Angular client lives in `[packages/UI/src/app/services/lms/lms-admin.service.ts](../../../packages/UI/src/app/services/lms/lms-admin.service.ts)`. ## Adding a new LMS datasource Use the Docebo stack as the template. You do **not** need a new LoopBack injectable service per vendor if you follow the factory + adapter pattern; registration is the switch cases plus Mongo config. ### 1. Extend the implementation type enum Add a value to `LmsImplementation` in `[packages/API/src/models/lms/lms-implementation-config.model.ts](../../../packages/API/src/models/lms/lms-implementation-config.model.ts)`: ```typescript export const LmsImplementation = { Docebo: 'docebo', Acme: 'acme', // example } as const; ``` Mirror the same constant and `LMS_IMPLEMENTATION_TYPES` in the UI (`[lms-admin.service.ts](../../../packages/UI/src/app/services/lms/lms-admin.service.ts)`). Keep backend and UI enums aligned. ### 2. Vendor client types Add `packages/API/src/datasources/lms/-lms.client.types.ts` with the raw REST client shape (four methods matching your operation templates, typically returning `Promise`). ### 3. DataSource builder Add `packages/API/src/datasources/lms/-lms.datasource.ts` following `buildDoceboLmsDataSource`: - LoopBack `connector: 'rest'` - Operation templates (`RestOperationDefinition`) for the four gateway operations - Pass `authorizationHeader` into default headers (same snapshot pattern as Docebo) - Export a `build…` helper and optional `fromLmsImplementationConfig` mapper Export the new modules from `[packages/API/src/datasources/lms/index.ts](../../../packages/API/src/datasources/lms/index.ts)`. ### 4. Gateway adapter Add `packages/API/src/services/lms/adapters/-lms-gateway.adapter.ts` implementing `LmsGateway`: - Map vendor payloads → `LmsUser` (`userId`, `username`, …) and `LmsCourse` (`courseId`, `name`, …) - `getUsersByCourse` → deduped lowercase emails/usernames - Resolve username → vendor user id when the upstream API requires ids Export from `[packages/API/src/services/lms/adapters/index.ts](../../../packages/API/src/services/lms/adapters/index.ts)`. ### 5. Register in factory and registry In `[lms-gateway.factory.ts](../../../packages/API/src/services/lms/lms-gateway.factory.ts)`: ```typescript case LmsImplementation.Acme: return new AcmeLmsGatewayAdapter(rawClient as AcmeLmsClient); ``` In `DynamicLmsRegistry.buildDataSource`: ```typescript case LmsImplementation.Acme: return buildAcmeLmsDataSource({ ...fromLmsImplementationConfig(config, `lms-${implementationKey}`), authorizationHeader, }); ``` Unsupported types should continue to throw `HttpErrors.NotImplemented`. ### 6. Configure and activate Via admin API (or Admin Console): 1. `PUT /lms/implementations/{key}` with `implementationType`, `baseUrl`, `auth` (include `authUrl`), optional `courses` mappings, `enabled: true`. 2. `PUT /lms/route` with `{ "activeImplementationKey": "{key}" }`. 3. Optionally browse live courses: `GET /lms/implementations/{key}/courses`. 4. Run sync when mappings are ready: `POST /lms/update/courses`. ### 7. Tests Mirror existing unit tests under `packages/API/src/__tests__/unit/` for: - datasource builder / client types - adapter normalization - registry `buildDataSource` switch / cache invalidation ## Gotchas - **Two-layer client:** Keep vendor JSON parsing in adapters, not controllers or sync. - **Docebo domain model:** Docebo “courses” map to orgchart branches (`/manage/v1/orgchart…`), not a separate learning-course catalog. - **Datasource auth is snapshot-based:** Token is fixed in DS headers until the registry signature changes and rebuilds. - **Secret-preserving upsert:** Empty secret fields on update do not wipe stored values. - **Cannot delete the active implementation:** Switch `PUT /lms/route` first. - **Enum duplication:** Backend and Angular both define `LmsImplementation`; update both when adding a provider.