Skip to content

feat(invitations): complete external ID bulk invite flow and redesign result dialog#8406

Open
LWS49 wants to merge 2 commits into
lws49/feat-add-ext-idfrom
lws49/feat-ext-id-invite-flow
Open

feat(invitations): complete external ID bulk invite flow and redesign result dialog#8406
LWS49 wants to merge 2 commits into
lws49/feat-add-ext-idfrom
lws49/feat-ext-id-invite-flow

Conversation

@LWS49
Copy link
Copy Markdown
Collaborator

@LWS49 LWS49 commented May 26, 2026

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_conflict error condition is removed - the only remaining hard rejection for ext_id is external_id_taken (the CSV value is already held by a different course member). The invite result dialog is redesigned: six conditional flat sections replace the previous accordion layout, updated rows fold into the Existing Invitations and Existing Course Users sections with a bold ext_id and old-value tooltip, and the separate "External IDs Updated" section is removed. Invitations with is_retryable: false (permanent email delivery failures) are split out of Existing Invitations into a dedicated Failed section, highlighted in red, with a caption identifying exactly which rows could not be sent.

Design decisions

  • Incoming available non-null external ID differing from user's current non-null ID treated as upsert, not error - CSV uploads are submitted exclusively by course staff (trusted actors). The real safety guard is 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 beyond what external_id_taken already provides. Nil-to-value and non-nil-to-different-value are now treated symmetrically.
  • Updated rows merged into existing sections - folding updated rows into Existing Invitations/Course Users with a bold ext_id badge and "Previously: X" tooltip is sufficient signal. A dedicated "External IDs Updated" section created a confusing fourth category; updated users are conceptually still existing users.
  • Failed section at top - rows in the Failed section require the admin to take action before re-uploading. Placing them below success sections risks the admin closing the dialog without noticing. This matches master's original ordering intent.
  • Save order: updated records before new records - within the transaction, updated invitations and course users are saved before new ones. This is required for freed-ID batch claims: if Alice changes from freed-id to new-id and Bob claims freed-id in the same batch, Alice's update must persist first so Bob's insert doesn't hit the unique constraint.
  • is_retryable: false invitations routed to Failed - Invitations that permanently failed to send appeared under Existing Invitations on master, indistinguishable from normal pending invitations. Since those emails will never arrive, treating them as "already invited" misleads the admin. They are now highlighted in red in the Failed section and excluded from the "already in course" summary count. A red caption subtitle - "N rows highlighted in red could not be sent" - explicitly names the highlight so admins know which rows need action without hover interaction. The HelpIcon tooltip was replaced with the subtitle because the header count includes both failure types; a tooltip on that header would mislead staff into thinking it applied to all rows, not just the red subset.

Design Considerations

Failed section: subtitle vs HelpIcon for red-highlighted rows

Decision: Add a Typography variant="caption" color="error" subtitle below the "Failed (N)" header, shown only when isRetryable === false rows exist. Remove the HelpIcon tooltip from the section header.
Why considered: After consolidating all failure types into one "Failed" section, the HelpIcon tooltip sat next to a count that included CSV-issue rows too - implying it applied to all of them. Non-technical staff would read "Some invitation emails failed to send - please try again" and not know whether to retry all rows or just the red ones.
For:

  • Subtitle explicitly names the red highlight, connecting the visual to the meaning without requiring hover interaction
  • Count in the subtitle is scoped to the failed_to_send subset only, not the total needsAttentionCount
  • Reuses the updatedSubtitle caption pattern already established in the same component
    Against:
  • The copy ("could not be sent") gives no actionable retry guidance - the invitations page has no current retry path for permanently failed rows
    Alternatives:
  • Keep HelpIcon with updated tooltip text covering both row types conditionally - rejected because conditional tooltip text is harder to maintain and staff may not hover the icon
  • Per-row tooltip on each red Reason cell - rejected as heavy for information already visible in the Reason column
    Rationale: I chose the subtitle over the HelpIcon because the HelpIcon was anchored to a count that included both failure types, making the tooltip misleading for CSV-issue rows. The subtitle count is scoped to the failed_to_send subset only, making the connection between the red highlight and the meaning explicit without hover interaction. The action guidance ("could not be sent") is intentionally neutral - the invitations page has no current retry path for permanently failed rows, and I did not want to imply one exists.

Service behavior: master vs this PR

Ext_id is checked at four points in the service pipeline. @taken_external_ids is pre-loaded from both CourseUser and unconfirmed Course::UserInvitation ext_ids, so a conflict with either type anchors the same rejection.

Processing step Master This PR
New user (no account) - new invitation No ext_id check - invitation always created Checks @taken_external_ids; conflict - external_id_taken failure
Existing user (not yet in course) - new enrollment No ext_id check - always enrolled Same conflict check via enroll_new_user
Re-invite: existing pending invitation ext_id field absent Updates ext_id if free; conflict - external_id_taken failure
Re-invite: existing course member ext_id field absent Updates ext_id if free; conflict - external_id_taken failure
CSV email matches user with unconfirmed account Treated as new user - invitation sent Unchanged - same behavior; unconfirmed account holders receive an invitation they can accept after confirming their email, not direct enrollment

Failed section: case matrix

Scenario "Failed (N)" count Red-row caption shown Caption count
Only failed_to_send rows = failed count Yes = N (all rows red)
Only CSV-issue rows (duplicate email/ext ID, ext ID taken) = duplicate count No -
Mixed: both types = failed + duplicates Yes = failed count only
Neither Section hidden - -

Cross-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_users runs before add_existing_users in 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_id capture; external_id_taken on 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: false propagated through jbuilder.

Frontend specs cover: InvitationResultSkippedTable updated-row rendering (bold + badge + tooltip); InvitationResultDialog - failed invitations in Failed section not Existing Invitations, failedRowsSubtitle shown only for failed_to_send rows with correct count, subtitle absent when only CSV-issue duplicates present, mixed-failure count correctly split between header and caption; InvitationResultUsersTable reason 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_taken in 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.

Current Result Display

Happy Path

image

All Failure Reasons Shown

image

@LWS49 LWS49 changed the base branch from master to lws49/feat-add-ext-id May 26, 2026 07:36
@LWS49 LWS49 changed the title feat(invitations): complete external ID bulk invite flow feat(invitations): complete external ID bulk invite flow and redesign result dialog May 26, 2026
@LWS49 LWS49 force-pushed the lws49/feat-ext-id-invite-flow branch 4 times, most recently from c0dcc05 to e892fd9 Compare May 26, 2026 15:12
@LWS49 LWS49 force-pushed the lws49/feat-add-ext-id branch from d6e1808 to b75a804 Compare May 28, 2026 00:47
@LWS49 LWS49 force-pushed the lws49/feat-ext-id-invite-flow branch 7 times, most recently from a037a06 to 023720d Compare May 28, 2026 09:41
@LWS49 LWS49 force-pushed the lws49/feat-add-ext-id branch 2 times, most recently from 9c8e986 to 007edda Compare May 29, 2026 06:45
…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-ext-id-invite-flow branch 2 times, most recently from ed5418b to 0ef7442 Compare May 29, 2026 08:41
… 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
- show ext_id in manage users table, statistics students table, and score export
@LWS49 LWS49 force-pushed the lws49/feat-ext-id-invite-flow branch from 0ef7442 to 62635b4 Compare May 29, 2026 08:47
@LWS49 LWS49 marked this pull request as ready for review May 29, 2026 08:56
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