Skip to Content

LMS Roster Sync

Amal can import and keep in sync the student and teacher roster from your school’s learning management system. Two providers are supported in Wave 1: Google Classroom (OAuth-based, live token) and OneRoster (CSV file set upload, no live credential). Both providers share the same preview-then-commit review workflow.

All write operations on this surface (creating a connection, uploading a file set, saving mappings, running a preview, committing, or triggering a sync) are blocked on demo tenants. Read-only operations (listing connections, browsing courses, viewing sync history) work normally on demo tenants.


Two connection types

Google Classroom (OAuth)

A Google Classroom connection is established through a standard OAuth 2.0 consent flow:

  1. Call POST /api/lms/connections/google_classroom/oauth-start. The response carries an authUrl field pointing to Google’s consent screen.
  2. Redirect the admin user to that URL. After they authorise, Google redirects back to GET /api/lms/connections/google_classroom/oauth-callback?code=...&state=....
  3. The callback route exchanges the authorisation code, stores the access and refresh tokens, and returns a 200 LmsConnection JSON body. There is no HTTP redirect from the callback; the client handles navigation after reading the response body.

The connection is upserted on the natural key (organizationId, provider, scope, createdByUserId), so re-authorising an expired credential replaces tokens in place without creating a duplicate connection.

OneRoster (CSV file set)

A OneRoster connection requires no OAuth. Upload your OneRoster 1.1 CSV file set to POST /api/lms/connections/oneroster/upload as a JSON body with a files map:

{ "files": { "classes.csv": "<csv text>", "users.csv": "<csv text>", "enrollments.csv": "<csv text>" } }

The server stores the CSV content on the connection record. Preview and commit re-parse it locally without requiring re-upload. A successful upload returns 201 LmsConnection. Re-uploading a new file set replaces the stored content and clears the course cache for that connection.


Permission split

Two permissions gate the LMS surface. They have different levels of trust because connection management and course configuration do not write any People rows, while preview and commit do.

PermissionGates
MANAGE_LMS_CONNECTIONSList connections; OAuth start; OAuth callback; OneRoster upload; delete connection; list courses; save mappings; list sync runs
MANAGE_ROSTERBuild preview; patch preview rows; commit; trigger sync-now

An admin who holds MANAGE_LMS_CONNECTIONS but not MANAGE_ROSTER can configure and map a connection but cannot initiate any operation that creates or updates user accounts. Grant MANAGE_ROSTER only to admins who are authorised to provision accounts.


Course discovery and mapping

Once a connection is active, retrieve its available courses with GET /api/lms/{provider}/courses. Results are served from a 5-minute in-process cache per connection. Pass ?cache=bypass to force a fresh fetch from the provider (useful immediately after a roster change). The response includes a cached: boolean field so callers can tell which path was taken.

Map one or more provider courses to Amal classes with POST /api/lms/{provider}/mappings. Provide a connectionId and an array of { externalCourseId, amalClassId } pairs. Every amalClassId must belong to the caller’s organisation; a cross-org class ID produces a 422. Mappings upsert on the (organizationId, providerCourseId) unique key, so re-mapping a course to a different class replaces the prior mapping.


Preview and commit

Provisioning is a two-step process. Nothing is written to user accounts until you explicitly commit.

Step 1: Build a preview

POST /api/lms/preview reads the external roster for the connection’s mapped courses and produces a read-only diff. It writes nothing to User, StudentProfile, TeacherProfile, ClassStudent, ClassTeacher, or ParentStudent. The response is 200 LmsPreviewResponse:

{ "previewId": "<cuid>", "expiresAt": "<ISO-8601>", "summary": { "create": 4, "update": 12, "conflict": 2, "skip": 0 }, "rows": [ ... ] }

The preview has a 24-hour TTL. After expiry, commit and patch-row calls return 410 Preview expired. Build a new preview to start again.

Each row carries an action field with one of four values:

ActionMeaning
createNo matching Amal user found; commit will create a new account
updateMatched an existing Amal user with no diverging fields; commit will update class membership
conflictMatched an existing user but the LMS data diverges from Amal’s (name or grade level); must be resolved before commit
skipRow is intentionally excluded from the diff (e.g. a duplicate sub-identity)

Identity matching follows a priority chain: first by provider external-identity link (provider, externalSub), then by exact email match within the same organisation. There is no fuzzy auto-merge.

Step 2: Resolve conflicts

Rows with action = conflict must have a resolution set before you can commit. Use PATCH /api/lms/preview/{previewId}/rows/{rowId} with a body of { "resolution": "<value>" }.

Two resolution values are accepted:

ValueEffect at commit
accept_lmsOverwrite the Amal field with the LMS value
keep_amalKeep the Amal field; update only the class membership

Any other value produces a Zod validation error. Rows you do not want to import at all should be left unresolved; commit rejects with 422 Unresolved conflict rows until every conflict row has a resolution.

Step 3: Commit

POST /api/lms/commit/{previewId} provisions the entire reviewed roster in a single Postgres transaction: all creates, updates, and resolved-conflict rows apply atomically or none do. A successful commit returns 200 LmsCommitResponse with counts of rows created, updated, and skipped.

Commit is idempotent: if the same preview has already been committed, the response returns { alreadyCommitted: true } with no further writes.


Automatic sync (autoApplySimpleChanges)

Each connection has an autoApplySimpleChanges flag. When the cron or a manual sync-now runs, the flag controls what happens after the diff is built:

  • Flag OFF (default): the preview is always queued for admin review, regardless of what the diff contains. LmsSyncRun.status is set to pending_admin_review. No People rows are written automatically.
  • Flag ON, no conflict rows: the diff is committed automatically in one transaction. LmsSyncRun.status is set to success.
  • Flag ON, conflict rows present: the system cannot auto-resolve a conflict, so the preview is queued for admin review exactly as if the flag were OFF. LmsSyncRun.status is set to pending_admin_review.

In all cases lastSyncAt is updated on the connection after the sync run. lastSuccessfulSyncAt is updated only when an auto-commit succeeds.


Manual sync-now

To trigger an immediate sync outside the cron schedule, call POST /api/lms/connections/{id}/sync-now. The route returns 202 with a body of { syncRunId, status } where status is one of completed, pending_admin_review, or error. The connection must be active; a connection in any other status returns 404.


Token revocation

When the provider returns an HTTP 401 or 403 during any adapter call (course fetch or roster fetch), the sync service treats it as a token revocation. The following happens atomically:

  1. LmsConnection.connectionStatus is set to revoked.
  2. LmsConnection.syncCronEnabled is set to false (cron is disabled).
  3. An lms_connection_revoked notification is dispatched to every active admin in the organisation via the notification system.
  4. A LmsSyncRun row is appended with status = error and errorMessage = token_revoked.

Re-connecting requires the admin to run the OAuth flow (or re-upload the CSV file set for OneRoster) to restore the connection to active status and re-enable the cron.


Sync history

GET /api/lms/sync-runs returns paginated LmsSyncRun records for the caller’s organisation, newest first. Use ?page= and ?limit= (max 100) to page. Each run records whether it was triggered by the cron or manually, whether it was auto-applied, start/finish times, row counts, and an error message when applicable.


API reference

All routes require an authenticated session. organizationId is always derived from the session context and never accepted in request bodies or path parameters.

MethodPathPermissionDemoReturns
GET/api/lms/connectionsMANAGE_LMS_CONNECTIONSallowed200 LmsConnectionListResponse
POST/api/lms/connections/{provider}/oauth-startMANAGE_LMS_CONNECTIONSblocked200 LmsOAuthStartResponse
GET/api/lms/connections/{provider}/oauth-callbackMANAGE_LMS_CONNECTIONSblocked200 LmsConnection
POST/api/lms/connections/oneroster/uploadMANAGE_LMS_CONNECTIONSblocked201 LmsConnection
DELETE/api/lms/connections/{id}MANAGE_LMS_CONNECTIONSblocked200 { ok: true }
GET/api/lms/{provider}/coursesMANAGE_LMS_CONNECTIONSallowed200 LmsCoursesResponse
POST/api/lms/{provider}/mappingsMANAGE_LMS_CONNECTIONSblocked200 LmsMappingResponse
POST/api/lms/previewMANAGE_ROSTERblocked200 LmsPreviewResponse
PATCH/api/lms/preview/{previewId}/rows/{rowId}MANAGE_ROSTERblocked200 LmsPreviewResponse
POST/api/lms/commit/{previewId}MANAGE_ROSTERblocked200 LmsCommitResponse
POST/api/lms/connections/{id}/sync-nowMANAGE_ROSTERblocked202 LmsSyncNowResponse
GET/api/lms/sync-runsMANAGE_LMS_CONNECTIONSallowed200 LmsSyncRunsResponse

For full schema definitions see the API Reference.