---
title: "LMS Roster Sync"
url: https://docs.growamal.com/en/guides/lms-sync/
language: en
---

# 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.

> **Note:** 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:

```json
{
  "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.

| Permission | Gates |
|---|---|
| `MANAGE_LMS_CONNECTIONS` | List connections; OAuth start; OAuth callback; OneRoster upload; delete connection; list courses; save mappings; list sync runs |
| `MANAGE_ROSTER` | Build preview; patch preview rows; commit; trigger sync-now |

> **Warning:** 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`:

```json
{
  "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:

| Action | Meaning |
|---|---|
| `create` | No matching Amal user found; commit will create a new account |
| `update` | Matched an existing Amal user with no diverging fields; commit will update class membership |
| `conflict` | Matched an existing user but the LMS data diverges from Amal's (name or grade level); must be resolved before commit |
| `skip` | Row 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:

| Value | Effect at commit |
|---|---|
| `accept_lms` | Overwrite the Amal field with the LMS value |
| `keep_amal` | Keep 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.

| Method | Path | Permission | Demo | Returns |
|---|---|---|---|---|
| `GET` | `/api/lms/connections` | `MANAGE_LMS_CONNECTIONS` | allowed | `200 LmsConnectionListResponse` |
| `POST` | `/api/lms/connections/{provider}/oauth-start` | `MANAGE_LMS_CONNECTIONS` | blocked | `200 LmsOAuthStartResponse` |
| `GET` | `/api/lms/connections/{provider}/oauth-callback` | `MANAGE_LMS_CONNECTIONS` | blocked | `200 LmsConnection` |
| `POST` | `/api/lms/connections/oneroster/upload` | `MANAGE_LMS_CONNECTIONS` | blocked | `201 LmsConnection` |
| `DELETE` | `/api/lms/connections/{id}` | `MANAGE_LMS_CONNECTIONS` | blocked | `200 { ok: true }` |
| `GET` | `/api/lms/{provider}/courses` | `MANAGE_LMS_CONNECTIONS` | allowed | `200 LmsCoursesResponse` |
| `POST` | `/api/lms/{provider}/mappings` | `MANAGE_LMS_CONNECTIONS` | blocked | `200 LmsMappingResponse` |
| `POST` | `/api/lms/preview` | `MANAGE_ROSTER` | blocked | `200 LmsPreviewResponse` |
| `PATCH` | `/api/lms/preview/{previewId}/rows/{rowId}` | `MANAGE_ROSTER` | blocked | `200 LmsPreviewResponse` |
| `POST` | `/api/lms/commit/{previewId}` | `MANAGE_ROSTER` | blocked | `200 LmsCommitResponse` |
| `POST` | `/api/lms/connections/{id}/sync-now` | `MANAGE_ROSTER` | blocked | `202 LmsSyncNowResponse` |
| `GET` | `/api/lms/sync-runs` | `MANAGE_LMS_CONNECTIONS` | allowed | `200 LmsSyncRunsResponse` |

For full schema definitions see the [API Reference](https://docs.growamal.com/en/api-reference/).

---

## Related guides

- [Provision Users](https://docs.growamal.com/en/guides/provision-users/): manual user creation and class enrollment
- [Classes and Rosters](https://docs.growamal.com/en/guides/classes-and-rosters/): how class membership drives teacher authorization
- [Notification Center](https://docs.growamal.com/en/guides/notifications/): where `lms_connection_revoked` alerts appear
