Skip to content

feat: add optional external_id field#8390

Merged
adi-herwana-nus merged 1 commit into
masterfrom
lws49/feat-add-ext-id
Jun 2, 2026
Merged

feat: add optional external_id field#8390
adi-herwana-nus merged 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.

@LWS49 LWS49 force-pushed the lws49/feat-add-ext-id branch 9 times, most recently from c646bec to d6e1808 Compare May 25, 2026 09:23
@LWS49 LWS49 marked this pull request as ready for review May 28, 2026 00:47
@LWS49 LWS49 force-pushed the lws49/feat-add-ext-id branch 6 times, most recently from 6226857 to 9c27231 Compare June 2, 2026 00:59
@adi-herwana-nus adi-herwana-nus requested a review from Copilot June 2, 2026 09:20
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an optional external_id to course members and pending invitations to support linking Coursemology enrolments to external institutional identifiers, and wires it through invitations, manage-users inline editing, exports, and statistics views.

Changes:

  • Added external_id columns + uniqueness validation logic (cross-table at model/service level) and updated registration/invitation flows to carry it through.
  • Updated UI/TypeScript types and tables/forms (invite flows + manage users) to display/edit external_id, plus CSV template handling.
  • Updated score summary export + student statistics table to conditionally include an external_id column when data exists, and adjusted invitation error handling to return arrays of messages.

Reviewed changes

Copilot reviewed 68 out of 68 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
spec/services/course/user_registration_service_spec.rb Adds regression test for not updating ext id on already-enrolled registration flow.
spec/services/course/user_invitation_service_spec.rb Expands coverage for ext id parsing, deduping, and conflict handling across invite modes.
spec/services/course/statistics/assessment_score_summary_download_service_spec.rb Tests conditional inclusion of external_id column in score summary CSV.
spec/models/user/email_spec.rb Tests invitation ext id transfer on sign-up + rollback behavior on conflict.
spec/models/course/user_invitation_spec.rb Tests invitation-level ext id uniqueness behavior.
spec/models/course_user_spec.rb Tests course_user-level ext id normalization + uniqueness behavior.
spec/fixtures/course/invitation_with_external_id.csv Fixture for CSV parsing with ext id (timeline enabled).
spec/fixtures/course/invitation_with_external_id_no_timeline.csv Fixture for CSV parsing with ext id (timeline disabled).
spec/fixtures/course/invitation_no_external_id_no_timeline.csv Fixture for CSV parsing without ext id (timeline disabled).
spec/fixtures/course/invitation_external_id_wrong_header.csv Fixture to ensure header detection works with nonstandard ext id header.
spec/fixtures/course/invitation_duplicate_external_id.csv Fixture for ext id conflict scenario.
spec/features/course/student_management_spec.rb Adjusts selectors for inline-edit name changes.
spec/features/course/staff_management_spec.rb Adjusts selectors for inline-edit name changes.
spec/controllers/course/user_invitations_controller_spec.rb Updates controller specs for new error propagation format + ext id scenarios.
db/schema.rb Reflects new columns/indexes and (also) includes a new unrelated table.
db/migrate/20260514052933_add_external_id_to_course_users_and_invitations.rb Adds external_id columns + per-table uniqueness indexes.
db/migrate/20260423065615_add_test_spreadsheets_to_text_response_solutions.rb Adds a new spreadsheets table (appears unrelated to ext id feature).
config/locales/zh/errors.yml Updates invitation error messages for new per-record error surfaces.
config/locales/zh/csv.yml Adds CSV header translation for external_id.
config/locales/zh/activerecord/errors.yml Adds AR validation messages for external_id taken.
config/locales/ko/errors.yml Updates invitation error messages for new per-record error surfaces.
config/locales/ko/csv.yml Adds CSV header translation for external_id.
config/locales/ko/activerecord/errors.yml Adds AR validation messages for external_id taken.
config/locales/en/errors.yml Updates invitation error messages for new per-record error surfaces.
config/locales/en/csv.yml Adds CSV header translation for external_id.
config/locales/en/activerecord/errors.yml Adds AR validation messages for external_id taken.
client/locales/zh.json Updates UI strings for ext id + duplicate reasons (some still English).
client/locales/ko.json Updates UI strings for ext id + duplicate reasons (some still English).
client/locales/en.json Updates UI strings for ext id + error handling.
client/app/types/course/userInvitations.ts Extends invitation result typing with externalId + duplicate reason.
client/app/types/course/courseUsers.ts Adds externalId + patch support for course user updates.
client/app/lib/translations/table.ts Adds table column translations for externalId + optional.
client/app/lib/components/form/fields/DataTableInlineEditable/TextField.tsx Adds allowEmpty support for inline editable text field.
client/app/bundles/course/users/translations.ts Adds toast messages for ext id add/change/delete operations.
client/app/bundles/course/users/operations.ts Sends external_id in update payload.
client/app/bundles/course/users/components/tables/ManageUsersTable/UserNameField.tsx Passes allowEmpty explicitly for name field.
client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx Adds External ID column to manage users table.
client/app/bundles/course/users/components/tables/ManageUsersTable/ExternalIdField.tsx New inline editable External ID field with toasts + dispatch update.
client/app/bundles/course/user-invitations/pages/InviteUsersFileUpload/index.tsx Adds ext id CSV guidance, template selection, and improved error display.
client/app/bundles/course/user-invitations/operations.ts Adds externalId field mapping into invitation FormData.
client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx Conditionally displays externalId column in invitations table.
client/app/bundles/course/user-invitations/components/tables/InvitationResultUsersTable.tsx Shows externalId + duplicate reasons for duplicate users result table.
client/app/bundles/course/user-invitations/components/tables/InvitationResultInvitationsTable.tsx Shows externalId column in invitation results tables when present.
client/app/bundles/course/user-invitations/components/misc/InvitationResultDialog.tsx Updates duplicate-users wording to be email/externalId-agnostic.
client/app/bundles/course/user-invitations/components/forms/IndividualInviteForm.tsx Adds per-error toast output for form invites and removes injectIntl.
client/app/bundles/course/user-invitations/components/forms/IndividualInvitation.tsx Adds externalId input field to individual invite rows.
client/app/bundles/course/user-invitations/components/buttons/InvitationActionButtons.tsx Improves error handling + adds generic failure messages.
client/app/bundles/course/statistics/types.ts Adds externalId + hasExternalIds metadata.
client/app/bundles/course/statistics/pages/StatisticsIndex/students/StudentStatisticsTable.tsx Conditionally adds External ID column to statistics students table.
client/app/bundles/course/helper/index.ts Adds timeline/no-timeline CSV template switching.
client/app/assets/templates/course-user-invitation-template.csv Updates timeline template to include ExternalId column.
client/app/assets/templates/course-user-invitation-template-no-timeline.csv Adds no-timeline template including ExternalId column.
app/views/course/users/_user_list_data.json.jbuilder Exposes externalId in course user list JSON.
app/views/course/user_invitations/_invitation_result_data.json.jbuilder Exposes externalId + duplicate reason in invitation result JSON.
app/views/course/user_invitations/_course_user_invitation_list_data.json.jbuilder Exposes externalId on invitation list rows.
app/views/course/statistics/aggregate/all_students.json.jbuilder Exposes externalId for students and emits hasExternalIds.
app/services/course/user_registration_service.rb Transfers invitation external_id only when creating new course_user.
app/services/course/user_invitation_service.rb Plumbs duplicate user accumulation into invite_users.
app/services/course/statistics/assessments_score_summary_download_service.rb Conditionally includes external_id column in score summary export.
app/services/concerns/course/user_invitation_service/process_invitation_concern.rb Implements ext id “taken” detection and duplicate reason tagging.
app/services/concerns/course/user_invitation_service/parse_invitation_concern.rb Adds ext id CSV parsing + batch-level ext id deduping.
app/models/user/email.rb Confirms invitation before building course_user to avoid ext id uniqueness clash.
app/models/user.rb Copies external_id from invitation to course_user creation.
app/models/course/user_invitation.rb Includes UniqueExternalIdConcern for invitations.
app/models/course_user.rb Includes UniqueExternalIdConcern for course users.
app/models/concerns/course/unique_external_id_concern.rb New shared validation for cross-table per-course ext id uniqueness.
app/controllers/course/user_invitations_controller.rb Returns errors as array and improves per-record error aggregation.
app/controllers/concerns/course/users_controller_management_concern.rb Permits external_id in course user update params.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/models/concerns/course/unique_external_id_concern.rb Outdated
Comment on lines 73 to 79
fileUploadExample: {
id: 'course.userInvitations.InviteUsersFileUpload.fileUploadExample',
defaultMessage:
'Name,Email[,Role,Phantom]' +
'{br}John,test1@example.org[,student,y]' +
'{br}Mary,test2@example.org[,teaching_assistant,n]',
'Name,Email,Role,Phantom,ExternalId' +
'{br}John,test1@example.org[,student,y,A0123456' +
'{br}Mary,test2@example.org[,teaching_assistant,n,A0123457',
},
Copy link
Copy Markdown
Collaborator Author

@LWS49 LWS49 Jun 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated.

Comment thread client/locales/zh.json
Comment thread client/locales/ko.json
@LWS49 LWS49 force-pushed the lws49/feat-add-ext-id branch 4 times, most recently from c9ff06f to 3498902 Compare June 2, 2026 10:36
…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 3498902 to 9c390c4 Compare June 2, 2026 16:48
@adi-herwana-nus
Copy link
Copy Markdown
Contributor

Failing tests are due to previous merge to master, and will be addressed in the next PR.

@adi-herwana-nus adi-herwana-nus merged commit 4b46478 into master Jun 2, 2026
9 of 10 checks passed
@adi-herwana-nus adi-herwana-nus deleted the lws49/feat-add-ext-id branch June 2, 2026 17:28
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.

3 participants