Skip to content

feat: add external_id field#8382

Open
LWS49 wants to merge 1 commit into
masterfrom
lws49/feat-add-ext-id-in-export
Open

feat: add external_id field#8382
LWS49 wants to merge 1 commit into
masterfrom
lws49/feat-add-ext-id-in-export

Conversation

@LWS49
Copy link
Copy Markdown
Collaborator

@LWS49 LWS49 commented May 14, 2026

Summary

Adds external_id support across the platform, an optional field for associating course members with an external system (e.g., a university student ID). External IDs are unique per course and can be set via the manage users table, individual invitation form, CSV bulk invite, and are included in the assessment score summary CSV export.


Database

Two new nullable string columns:

  • external_id on course_users
  • external_id on course_user_invitations

Both are optional and default to NULL for backwards compatibility.


Uniqueness Validation - Course::UniqueExternalIdConcern

A new shared concern included in both CourseUser and Course::UserInvitation enforces that external_id is unique per course, checked across both tables simultaneously.

The rule is:

  • Blank/nil external IDs are always allowed (normalised to nil before validation).
  • A non-nil external ID is rejected if it already exists on any CourseUser or any unconfirmed Course::UserInvitation in the same course.
  • Confirmed invitations are excluded from the check. Once an invitation is confirmed, its external_id has been transferred to the resulting CourseUser, so it no longer needs to block new assignments. In specs, it is explicitly tested that if only a confirmed invitation in the same course has the same external_id, it is considered valid.

User::Email - Confirm Invitation Before Building CourseUser

Previously, when a user confirmed their email and had pending invitations, the code built the CourseUser first and then confirmed the invitation:

user.build_course_user_from_invitation(unconfirmed_invitation)
unconfirmed_invitation.confirm!(confirmer: user) if user.save && user.persisted?

With UniqueExternalIdConcern in place, this ordering causes a validation failure: the pending invitation and the new CourseUser temporarily share the same external_id, and the concern (correctly) rejects this.

The fix wraps both steps in a transaction and confirms the invitation first, so the invitation is already in the confirmed (excluded) state when the CourseUser is validated:

CourseUser.transaction do
  unconfirmed_invitation.confirm!(confirmer: user)
  user.build_course_user_from_invitation(unconfirmed_invitation)
  raise ActiveRecord::Rollback unless user.save && user.persisted?
end

Course::UserInvitationsController Changes

1. external_id added to permitted params

external_id is now permitted in course_user_invitation_params for both file uploads and individual invitations.

2. propagate_errors now handles both paths

Previously, propagate_errors only propagated errors to the file (invitations_file key) and did nothing for individual form submissions. This meant that errors from the form path (e.g. duplicate external IDs) were silently dropped.

Now it branches on invite_by_file? and calls propagate_errors_to_form for individual invitations, which adds errors to :base.

3. propagate_errors_to_file - errors added individually, not joined

Previously, all errors were aggregated into one sentence and added as a single error:

current_course.errors.add(:invitations_file, errors.to_sentence)

Now each error message is added as a separate entry:

aggregate_errors.each { |msg| current_course.errors.add(:invitations_file, msg) }

This allows the response to return an array of error strings instead of a single joined sentence, so the frontend can display them individually and with overflow counts (e.g. "... and 2 more").

4. invalid_course_user_errors and invalid_invitation_email_errors — split external_id errors

Previously these methods produced a single generic error per invalid record. Now they check whether the failure was an external_id uniqueness violation and emit a dedicated duplicate_external_id error message for it, separately from any other validation errors on the same record. This gives users actionable, specific feedback.

5. destroy_invitation_failure - returns errors as array

Changed errors.full_messages.to_sentence to errors.full_messages (an array) to be consistent with the new error format consumed by the frontend.


CSV Invitation Parsing

The CSV column layout now branches on whether personalized timelines are enabled for the course:

  • With timelines: Name, Email, Role, Phantom, PersonalTimeline, ExternalId
  • Without timelines: Name, Email, Role, Phantom, ExternalId

external_id is optional - rows without it parse as nil.


Assessment Score Summary CSV Export

The external_id column is included conditionally: it only appears if at least one student in the course has a non-nil external_id. This avoids adding an empty column to existing exports.


Frontend

Manage Users Table - ExternalIdField

New inline-editable field component for external_id on the manage users table. Uses InlineEditTextField with allowEmpty: true (existing name field uses allowEmpty: false). Toast messages distinguish between add, change, and delete operations.

Individual Invitation Form

Added an optional External ID field to the per-row invitation form, rendered via the existing IndividualInvitation component.

Sent Invitations & Invitation Results Tables

Both UserInvitationsTable and InvitationResultInvitationsTable show an External ID column only if at least one entry in the dataset has a non-nil external_id.

IndividualInviteForm - Error handler updated

Error handler was updated to consume the new array error format: it shows the first error with an "(and N more)" overflow indicator when multiple errors are present.

InviteUsersFileUpload - Improved error display and correct template download

Error handling updated to consume the array format from the backend. Shows the first error with an overflow count.

The download template link previously always served the timeline-enabled template regardless of whether the course had personal timelines enabled. getCourseUserInviteTemplatePath now takes a hasPersonalTimelines boolean and returns the appropriate file:

  • With personal timelines: Name, Email, Role, Phantom, Timeline, ExternalId
  • Without personal timelines: Name, Email, Role, Phantom, ExternalId (new template file)

This matches the column layout the CSV parser expects on the backend, so users who download and fill in the template will no longer upload files with the wrong number of columns.

InvitationActionButtons - Fixed error handling

  • Resend: The resend endpoint returns head :bad_request with no body. Removed the dead error.response?.data?.errors read; the toast now shows the failure message directly with no error detail appended.
  • Delete: Updated to handle the new array error format from destroy_invitation_failure.

InlineEditTextField - allowEmpty prop

Added an allowEmpty boolean prop. When true, submitting an empty value is allowed (used by ExternalIdField to support deleting an external ID). The existing UserNameField explicitly passes allowEmpty={false} to preserve its existing behaviour. This is allow the new ExternalIdField to reuse InlineEditTextField while allowing for null.


Translations

All new strings are translated across English, Korean, and Chinese:

  • external_id column header (table, CSV)
  • External ID info text on the file upload page
  • Add / change / delete success and failure toasts on the manage users table
  • duplicate_external_id error message in errors.yml and activerecord/errors.yml (for both CourseUser and Course::UserInvitation)

@LWS49 LWS49 force-pushed the lws49/feat-add-ext-id-in-export branch 5 times, most recently from 4664701 to 3fa4f54 Compare May 15, 2026 07:51
@LWS49 LWS49 marked this pull request as ready for review May 15, 2026 08:07
@LWS49 LWS49 closed this May 15, 2026
@LWS49 LWS49 reopened this May 15, 2026
@LWS49 LWS49 marked this pull request as draft May 15, 2026 08:28
@LWS49 LWS49 force-pushed the lws49/feat-add-ext-id-in-export branch from 3fa4f54 to 61462a6 Compare May 15, 2026 09:45
@LWS49 LWS49 marked this pull request as ready for review May 15, 2026 09:55
@LWS49 LWS49 changed the title feat: add external_id column to assessment score summary CSV export feat: add external_id field May 16, 2026
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