Skip to content

feat: add optional external_id field#8390

Draft
LWS49 wants to merge 1 commit into
masterfrom
lws49/feat-add-ext-id
Draft

feat: add optional external_id field#8390
LWS49 wants to merge 1 commit into
masterfrom
lws49/feat-add-ext-id

Conversation

@LWS49
Copy link
Copy Markdown
Collaborator

@LWS49 LWS49 commented May 19, 2026

Summary

Adds an optional external_id field to course members and pending invitations, allowing institutions to link Coursemology enrolments to their own student records or LMS identifiers. The field is stored on both CourseUser and Course::UserInvitation and validated unique per course across both tables (a pending invitation "holds" an ext_id until confirmed).

The field is exposed in five places: the individual invite form, the bulk CSV invite (two templates - with and without Timeline column, depending on whether personal timelines are enabled), the manage users table (inline editable), the score summary export, and the Statistics > Students table. View-only tables (score summary export, statistics table) show the ext_id column only when at least one enrolled student has a non-null ext_id; the manage users edit table always shows it. Error responses from the invitations controller are changed from a single concatenated sentence to an array of per-record strings; the individual invite form shows the first error with a count of remaining errors (e.g. "Email taken (and 2 more)") rather than a generic failure message.

Scope note: two things are intentionally deferred to follow-up PRs: (1) updating ext_ids on existing pending invitations or enrolled users via bulk invite - a follow-up PR will expose those updates in a dedicated result-dialog section with proper UX; (2) additional display surfaces (gradebook table, leaderboard, submission listings) - these are separate in scope.

Design decisions

  • Existing records are read-only in bulk invite and registration - silent ext_id updates during bulk invite are invisible in the result dialog, and surfacing conflicts as "duplicate users" falsely implies the enrolment itself was rejected. Updates to existing records are deferred to a follow-up PR that will introduce a dedicated "Updated" section in the result dialog, if this behaviour is preferred - that itself warrants further discussion. In the registration flow (user clicks invitation link), ext_id is transferred only to newly created CourseUsers; existing CourseUsers are not updated.
  • New users with a taken ext_id land in Duplicate Users, not a hard failure, and does not land in Existing Course Users - the upload as a whole succeeds; only the conflicting row is deferred for the instructor to resolve. This is intentional: placing them in Duplicate Users (rather than returning 400) avoids blocking the rest of the batch. Note thata new user whose external ID is already taken cannot be enrolled — neither with the conflicting ID nor by silently dropping it. The ext_id was explicitly provided by the instructor and dropping it would lose data. The instructor must resolve the conflict before re-uploading that row.
    It does not land in Existing Course USers because with current implementation, the unique identifier is strictly email. as External ID is nullable, while we maintain its uniqueness within the course, for now we want to keep things simple and keep email as the only unique identifier. Thus, we do not intrepret duplicate ext_id without duplicate email as the course user with said ext_id attempting to be re-enrolled.
  • Uniqueness is cross-table within a course - a pending invitation that has not yet been confirmed still "occupies" its ext_id. This prevents a new invite from claiming an ext_id that is already reserved, and prevents a direct enrolment from colliding with an in-flight invitation.
  • Confirm-before-save ordering in User::Email - when a user adds an email that matches an existing invitation, the invitation is confirmed before the CourseUser is built. Without this, UniqueExternalIdConcern would reject the new CourseUser for sharing an ext_id with what is still a live invitation in the DB.
  • View-only tables suppress the ext_id column when no data exists - consistent with how optional columns like group managers, level, and video stats are gated in the statistics table. An empty ext_id column adds noise for courses that have never set external IDs. Edit tables (manage users) always show the column so instructors can add the first value.

Regression prevention

  • Covers: CSV parsing with and without ext_id column (both timeline and no-timeline templates), stage-1 deduplication of duplicate emails and duplicate ext_ids within a single upload, new-user ext_id taken vs free (returns success with conflicting row in Duplicate Users), existing-record read-only behaviour in both bulk invite and registration flow, inline edit uniqueness validation.
  • Manual testing: individual invite with ext_id; bulk CSV new users with ext_id; bulk CSV re-upload of existing enrolled user and pending invitation (confirmed ext_id not changed); duplicate ext_id within same CSV; ext_id already taken by another member (confirmed batch succeeds and row appears in Duplicate Users); manage users inline edit; score summary export with ext_id column; statistics students table with and without ext_ids present.
  • Backward compatible: external_id is optional and blank-normalised to nil. All existing flows behave identically when no ext_id is provided.

Complete feature intended behaviour

The following is what the bulk invite flow should do once the follow-up PR is implemented.

Intended decision tree (complete feature)

CSV row
├── [Stage 1] Email duplicate in upload ────► duplicate_email_in_file
├── [Stage 1] ext_id duplicate in upload ───► duplicate_external_id_in_file
└── passes Stage 1
    ├── email matches PENDING INVITATION
    │   ├── upload ext_id blank or same as current ──► existing_invitations (unchanged)
    │   └── upload ext_id different
    │       ├── free in DB ─────► updated_invitations (ext_id updated, surfaced)
    │       └── taken in DB ────► existing_invitations (unchanged, with conflict note)
    │
    ├── email matches ENROLLED USER
    │   ├── upload ext_id blank or same as current ──► existing_course_users (unchanged)
    │   └── upload ext_id different
    │       ├── free in DB ────► updated_course_users (ext_id updated, surfaced)
    │       └── taken in DB ───► existing_course_users (unchanged, with conflict note)
    │
    └── email is NEW (same as current PR)
        ├── ext_id blank ────► new_invitations / new_course_users
        ├── ext_id free ─────► new_invitations / new_course_users (ext_id stored)
        └── ext_id taken ────► duplicate_users[:external_id_taken]

Key differences from the current PR

Case Current PR Complete feature
Existing invitation, ext_id differs, free Unchanged (ext_id ignored) Ext_id updated, shown in "Updated invitations"
Existing invitation, ext_id differs, taken Unchanged (ext_id ignored) Unchanged, shown in existing invitations with conflict note
Existing enrolled user, ext_id differs, free Unchanged (ext_id ignored) Ext_id updated, shown in "Updated users"
Existing enrolled user, ext_id differs, taken Unchanged (ext_id ignored) Unchanged, shown in existing users with conflict note

@LWS49 LWS49 force-pushed the lws49/feat-add-ext-id branch 8 times, most recently from 0417ba7 to c646bec Compare May 25, 2026 09:10
…itations

  Allows institutions to link Coursemology enrolments to their own student
  records or LMS identifiers. The field is stored on CourseUser and
  Course::UserInvitation and validated unique per course across both tables
  via a cross-table concern and partial DB index - a pending invitation holds
  its ext_id until confirmed, preventing collisions with direct enrolments.

  Surfaces:
  - Individual invite form: ext_id input field
  - Bulk CSV invite: ext_id column in both template variants (with and without
    Timeline column); set on new records only - existing pending invitations
    and enrolled users are read-only in this flow
  - Manage users table: inline editable ext_id column (always visible)
  - Score summary export: ext_id column included when any student has one
  - Statistics > Students table: ext_id column, sortable and searchable,
    shown only when at least one student has a non-null ext_id

  View-only tables suppress the ext_id column when no course members have
  one set, consistent with how group manager, gamification, and video columns
  are conditionally shown. Edit tables always show it.

  Also changes error responses from the invitations controller from a single
  concatenated string to an array of per-record strings, enabling the frontend
  to render overflow counts without truncating meaningful error detail.
@LWS49 LWS49 force-pushed the lws49/feat-add-ext-id branch from c646bec to d6e1808 Compare May 25, 2026 09:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant