feat(invitations): complete external ID bulk invite flow and redesign result dialog#8406
Open
LWS49 wants to merge 4 commits into
Open
feat(invitations): complete external ID bulk invite flow and redesign result dialog#8406LWS49 wants to merge 4 commits into
LWS49 wants to merge 4 commits into
Conversation
c0dcc05 to
e892fd9
Compare
d6e1808 to
b75a804
Compare
a037a06 to
023720d
Compare
b75a804 to
4347219
Compare
…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.
9c8e986 to
007edda
Compare
0ef7442 to
62635b4
Compare
62635b4 to
ba302dd
Compare
cbe8d6f to
6226857
Compare
6226857 to
9c27231
Compare
ba302dd to
3442e03
Compare
…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.
54e9ffd to
29ab1cf
Compare
952cf4a to
06af4fa
Compare
… dialog - add ext_id to CourseUser and UserInvitation with unique-per-course index - accept ext_id in bulk CSV and individual invite form; upsert for existing records (enrolled users and pending invitations) - conflicts (duplicate email or ext_id) surface in Failed with reasons - redesign result dialog: replace updated chip with row tint and tooltip
06af4fa to
ae12c5e
Compare
3498902 to
9c390c4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part 1: External ID in the invite flow
Summary
Completes the external ID bulk invite flow started in the previous PR. The CSV upload now acts as a full upsert for external IDs on existing records: a nil ext_id is filled in, a non-nil ext_id that differs from the CSV value is overwritten (not rejected), and a blank CSV cell preserves the existing value. The
external_id_conflicterror condition is removed - the only remaining hard rejection for ext_id isexternal_id_taken(the CSV value is already held by a different course member). Updated rows fold into the Existing Invitations and Existing Course Users sections with a bold ext_id badge and old-value tooltip; the separate "External IDs Updated" section is removed. Error messages in the controller are now per-error-type rather than a single sentence, enabling the result dialog to attribute each failure reason precisely. Theexternal_idfield is carried through toCourseUseron invitation acceptance. The score summary CSV download gains an opt-in ext_id column, shown only when at least one student has a non-blank ext_id.Design decisions
external_id_taken(ID collision across different people), which is still a hard error. Flagging a staff member correcting a previous ext_id entry as a conflict imposes workflow cost with no safety gain. Nil-to-value and non-nil-to-different-value are now treated symmetrically.freed-idtonew-idand Bob claimsfreed-idin the same batch, Alice's update must persist first so Bob's insert doesn't hit the unique constraint.external_idcoerced via.presencewhen building CourseUser from invitation - the partial unique index oncourse_users(course_id, external_id)only covers non-NULL values (WHERE external_id IS NOT NULL). Storing an empty string""would subject it to the uniqueness constraint, causing duplicate-key errors when multiple invitees have no external ID set..presenceconverts""→nil, keeping those rows outside the partial index scope.show_personalized_timeline_features?is false, the parser readsrow[4]asexternal_id. A 6-column file (the timeline template) would silently store the timeline string (e.g."otot") as the external ID. Column count detection (row.length > 5) is used as the signal: value-matching against known algorithm names was rejected because those strings are valid external IDs. The upload is aborted before any row is processed and a descriptive toast tells staff to use the 5-column template.Service behavior: master vs this PR
Ext_id is checked at four points in the service pipeline.
@taken_external_idsis pre-loaded from bothCourseUserand unconfirmedCourse::UserInvitationext_ids, so a conflict with either type anchors the same rejection.@taken_external_ids; conflict -external_id_takenfailureenroll_new_userexternal_id_takenfailureexternal_id_takenfailureCross-type freed-ID ordering limitation
When an existing course user frees an ext_id and a brand-new user (no Coursemology account) claims it in the same batch, the new user is still rejected with
external_id_taken.invite_new_usersruns beforeadd_existing_usersin the pipeline, so the new invitation is evaluated before the existing course user releases the ID. The same-type freed-ID case (both are existing invitations, or both are course users) works correctly. The spec documents this behavior explicitly rather than asserting it should work - a two-pass fix would add complexity for a narrow edge case.Regression prevention
Service specs cover: nil-to-value upsert for invitations and course users; non-nil-to-different-free-value upsert with
previous_external_idcapture;external_id_takenon new invitations, new enrollments (existing user not yet in course), existing invitation updates, and existing course user updates; conflict anchored by course user ext_id vs invitation ext_id; freed-ID batch claim within the same record type; cross-type freed-ID ordering limitation; unconfirmed-email path sends invitation not direct enrollment;isRetryable: falsepropagated through jbuilder; 6-column CSV uploaded to a non-timeline course raisesCSV::MalformedCSVError(guards the silent-corruption path where timeline strings become external IDs).Model specs cover: blank
external_idon invitation ("") producesnilon the builtCourseUser— guards the.presencecall inUser#build_course_user_from_invitationagainst silent removal (empty string would otherwise pass the partial unique index'sIS NOT NULLguard and cause duplicate-key errors).Frontend specs cover:
InvitationResultExistingTableupdated-row rendering (bold + badge + tooltip);InvitationResultDialog- failed invitations in Failed section not Existing Invitations,failedRowsSubtitleshown only forfailed_to_sendrows with correct count, subtitle absent when only CSV-issue duplicates present, mixed-failure count correctly split between header and caption;InvitationResultFailedTablereason text for all four failure reasons.Manual testing: happy path new batch; nil-to-value upsert with badge; non-nil-to-different upsert with tooltip;
external_id_takenin Failed section; freed-ID same-type batch; existing user no-change; failed-to-send rows in red with caption; CSV-issue rows without caption; mixed failure section showing correct split counts.Backward compatibility: existing invite flows unaffected. The only behavioral change for existing data is that re-inviting a user whose ext_id differs from the CSV value now updates the record instead of rejecting it.
Part 2: Independent improvements
Summary
Five fixes made to the invite flow independent of the external ID feature.
First, invitations with
is_retryable: false(permanent email delivery failures) were previously indistinguishable from normal pending invitations in the Existing Invitations section; they are now routed to a dedicated Failed section, highlighted in red, with a caption identifying exactly which rows could not be sent.Second,
UserInvitationsTablehad two related fixes: it was importinggetManageCourseUserPermissionsfromcourse/users/selectors(readsstate.users) instead of the invitations store (state.invitations), causing the Personalized Timeline column to flicker on SPA navigation; and the column gate was changed fromcanManagePersonalTimestoshowPersonalizedTimelineFeaturesfor correctness (see design decisions below).Third, a conditional "Personalized Timeline" column is added to all three invitation result tables (new invitations/enrollments, existing, and failures). The column appears only when
showPersonalizedTimelineFeaturesis enabled.timeline_algorithmis now serialized on all seven record sections in the result data; the flag is added to the invitation index response, stored in Redux alongsidedefaultTimelineAlgorithm(and added to the initial state of all three bundles that shareManageCourseUsersSharedData: invitations, users, and enrol-requests), read byInvitationResultDialog, and threaded to each table component.Fourth,
build_course_userin the invitation service was ignoring the CSVtimeline_algorithmcolumn for directly-enrolled existing users and always assigning the course default instead. The fix passesuser[:timeline_algorithm]through, consistent withbuild_invitationandbuild_course_user_from_invitation.build_course_user_from_invitationinUseralso had.presenceadded toexternal_idfor consistency withuser_registration_service.Fifth, stale naming is cleaned up throughout the course bundle (service, controller, jbuilder, types, components, specs; instance bundle unchanged):
InvitationResultUsersTable→InvitationResultFailedTable,InvitationResultSkippedTable→InvitationResultExistingTable(with its exported row typeSkippedRow→ExistingRow),DuplicateUserData→FailedInvitationRowData, andduplicate_users/duplicateUsers→failed_users/failedUsers.Design decisions
is_retryable: falseinvitations excluded from the "already invited" summary count - permanently failed invitations are not in the course and will never arrive; counting them as "already invited" misleads the admin into thinking those users are handled.canManagePersonalTimespreserved for editable inputs,showPersonalizedTimelineFeaturesused for read-only display - these two flags are related but distinct.canManagePersonalTimesis the compoundshow_personalized_timeline_features? && can?(:manage_personal_times)— it requires both the course feature to be on and the user to hold the manage permission. The invite forms and CSV upload (unchanged from master) correctly gate oncanManagePersonalTimes: showing an editable timeline field only makes sense when the user can actually set it. The live invitations table (UserInvitationsTable) and the result tables are read-only display — the column should be visible to anyone who can see the page whenever the course feature is on, regardless of whether they hold the manage permission.UserInvitationsTablepreviously usedcanManagePersonalTimes(same as the forms); this PR corrects it toshowPersonalizedTimelineFeaturesto match the result tables. The result tables are new and useshowPersonalizedTimelineFeaturesfrom the start.Regression prevention
Service specs cover:
build_course_userassigns the CSVtimeline_algorithmto the enrolledCourseUser, not the course default (regression guard for the ignored-column bug).Frontend specs cover:
InvitationsIndex- Personalized Timeline column appears whenshowPersonalizedTimelineFeaturesis true in the API response (seeded intostate.invitations) and is absent when false, withstate.usersunpopulated in both cases (regression guard for the wrong-store import and the gate change fromcanManagePersonalTimestoshowPersonalizedTimelineFeatures).showPersonalizedTimelineFeaturesis true, column absent when false, dash rendered whentimelineAlgorithmis undefined - for each ofInvitationResultPrimaryTable,InvitationResultExistingTable, andInvitationResultFailedTable.InvitationResultDialogintegration tests verify the flag is read from the Redux store and threads to tables (column visible withshowPersonalizedTimelineFeatures: truein store, hidden with default store).Manual testing:
is_retryable: falseinvitations appear in Failed section highlighted in red; Personalized Timeline column visible on both SPA navigation and hard refresh when personalized timelines enabled, absent in both when disabled.Backward compatibility: invite forms and CSV upload gate unchanged (
canManagePersonalTimes). Result dialog unchanged for courses with Personalized Timelines disabled.Current Result Display
Happy Path
All Failure Reasons Shown
Timelines Shown In Table If Enabled
Error Flagged if CSV with >5 Columns Used with Timelines Off