Skip to content

Commit 0227e24

Browse files
committed
feat(invitations): complete external ID bulk invite flow and redesign result dialog
Ext ID is now accepted in bulk invite CSVs. For existing enrolled users and pending invitations with no ext ID, the value is filled in and surfaced in a new "External IDs Updated" section. Conflicts (different ext ID already set, or ext ID taken by another member) route to Needs Attention, so existing values are never silently overwritten. Result dialog replaces the accordion with flat conditional sections. Needs Attention appears first so conflicts aren't missed before the admin closes the dialog. All sections use TanStack Table for visual consistency.
1 parent d6e1808 commit 0227e24

19 files changed

Lines changed: 1189 additions & 510 deletions

app/controllers/course/user_invitations_controller.rb

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,16 @@ def invalid_invitations
218218

219219
# Returns the invitation response based on file or entry invitation.
220220
def parse_invitation_result(new_invitations, existing_invitations, new_course_users,
221-
existing_course_users, duplicate_users)
222-
render_to_string(partial: 'invitation_result_data', locals: { new_invitations: new_invitations,
223-
existing_invitations: existing_invitations,
224-
new_course_users: new_course_users,
225-
existing_course_users: existing_course_users,
226-
duplicate_users: duplicate_users })
221+
existing_course_users, duplicate_users,
222+
updated_invitations, updated_course_users)
223+
render_to_string(partial: 'invitation_result_data',
224+
locals: { new_invitations: new_invitations,
225+
existing_invitations: existing_invitations,
226+
new_course_users: new_course_users,
227+
existing_course_users: existing_course_users,
228+
duplicate_users: duplicate_users,
229+
updated_invitations: updated_invitations,
230+
updated_course_users: updated_course_users })
227231
end
228232

229233
# Enables or disables registration codes in the given course.

app/models/concerns/course/unique_external_id_concern.rb

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,20 @@ def normalize_external_id
2727
# @return [void]
2828
def validate_unique_external_id_within_course
2929
return if external_id.blank?
30+
return unless external_id_taken_by_invitation? || external_id_taken_by_course_user?
3031

31-
invitation_exists = Course::UserInvitation.
32-
unconfirmed.
33-
where(course_id: course_id, external_id: external_id).
34-
where.not(id: id).
35-
exists?
36-
37-
course_user_exists = CourseUser.
38-
where(course_id: course_id, external_id: external_id).
39-
where.not(id: id).
40-
exists?
32+
errors.add(:external_id, :taken)
33+
end
4134

42-
return unless invitation_exists || course_user_exists
35+
def external_id_taken_by_invitation?
36+
scope = Course::UserInvitation.unconfirmed.where(course_id: course_id, external_id: external_id)
37+
scope = scope.where.not(id: id) if is_a?(Course::UserInvitation)
38+
scope.exists?
39+
end
4340

44-
errors.add(:external_id, :taken)
41+
def external_id_taken_by_course_user?
42+
scope = CourseUser.where(course_id: course_id, external_id: external_id)
43+
scope = scope.where.not(id: id) if is_a?(CourseUser)
44+
scope.exists?
4545
end
4646
end

app/services/concerns/course/user_invitation_service/process_invitation_concern.rb

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,33 @@ def add_existing_users(users)
6767
new_course_users = []
6868
users.each do |user|
6969
if (course_user = all_course_users[user[:user].id])
70-
existing_course_users << course_user
70+
handle_existing_course_user(user, course_user, existing_course_users)
7171
else
7272
enroll_new_user(user, user[:external_id].presence, new_course_users)
7373
end
7474
end
7575
[new_course_users, existing_course_users]
7676
end
7777

78+
def handle_existing_course_user(user, course_user, existing_course_users)
79+
csv_ext_id = user[:external_id].presence
80+
current_ext_id = course_user.external_id.presence
81+
82+
if csv_ext_id.nil? || csv_ext_id == current_ext_id
83+
existing_course_users << course_user
84+
elsif current_ext_id.nil?
85+
if @taken_external_ids.include?(csv_ext_id)
86+
@duplicate_users.push(user.merge(reason: :external_id_taken))
87+
else
88+
@taken_external_ids.add(csv_ext_id)
89+
course_user.external_id = csv_ext_id
90+
@updated_course_users << course_user
91+
end
92+
else
93+
@duplicate_users.push(user.merge(reason: :external_id_conflict))
94+
end
95+
end
96+
7897
def enroll_new_user(user, ext_id, new_course_users)
7998
if ext_id && @taken_external_ids.include?(ext_id)
8099
@duplicate_users.push(user.merge(reason: :external_id_taken))
@@ -129,14 +148,33 @@ def invite_new_users(users)
129148
users.each do |user|
130149
invitation = all_invitations[user[:email]]
131150
if invitation
132-
existing_invitations << invitation
151+
handle_existing_invitation(user, invitation, existing_invitations)
133152
else
134153
add_to_new_invitations(user, user[:external_id].presence, new_invitations)
135154
end
136155
end
137156
[new_invitations, existing_invitations]
138157
end
139158

159+
def handle_existing_invitation(user, invitation, existing_invitations)
160+
csv_ext_id = user[:external_id].presence
161+
current_ext_id = invitation.external_id.presence
162+
163+
if csv_ext_id.nil? || csv_ext_id == current_ext_id
164+
existing_invitations << invitation
165+
elsif current_ext_id.nil?
166+
if @taken_external_ids.include?(csv_ext_id)
167+
@duplicate_users.push(user.merge(reason: :external_id_taken))
168+
else
169+
@taken_external_ids.add(csv_ext_id)
170+
invitation.external_id = csv_ext_id
171+
@updated_invitations << invitation
172+
end
173+
else
174+
@duplicate_users.push(user.merge(reason: :external_id_conflict))
175+
end
176+
end
177+
140178
def add_to_new_invitations(user, ext_id, new_invitations)
141179
if ext_id && @taken_external_ids.include?(ext_id)
142180
@duplicate_users.push(user.merge(reason: :external_id_taken))

app/services/course/user_invitation_service.rb

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,28 +24,37 @@ def initialize(current_course_user, current_user, current_course)
2424
# because Rails does not handle duplicate nested attribute uniqueness constraints.
2525
#
2626
# @param [Array<Hash>|File|TempFile] users Invites the given users.
27-
# @return [Array<Integer>|nil] An array containing the the size of new_invitations, existing_invitations,
28-
# new_course_users and existing_course_users, duplicate_users respectively if success. nil when fail.
27+
# @return [Array<Integer>|nil] An array containing the size of new_invitations, existing_invitations,
28+
# new_course_users, existing_course_users, duplicate_users, updated_invitations, updated_course_users
29+
# respectively if success. nil when fail.
2930
# @raise [CSV::MalformedCSVError] When the file provided is invalid.
3031
def invite(users)
3132
new_invitations = nil
3233
existing_invitations = nil
3334
new_course_users = nil
3435
existing_course_users = nil
3536
duplicate_users = nil
37+
updated_invitations = nil
38+
updated_course_users = nil
3639

3740
success = Course.transaction do
3841
new_invitations, existing_invitations,
39-
new_course_users, existing_course_users, duplicate_users = invite_users(users)
42+
new_course_users, existing_course_users,
43+
duplicate_users, updated_invitations, updated_course_users = invite_users(users)
4044
raise ActiveRecord::Rollback unless new_invitations.all?(&:save)
4145
raise ActiveRecord::Rollback unless new_course_users.all?(&:save)
46+
raise ActiveRecord::Rollback unless updated_invitations.all?(&:save)
47+
raise ActiveRecord::Rollback unless updated_course_users.all?(&:save)
4248

4349
true
4450
end
4551

4652
send_registered_emails(new_course_users) if success
4753
send_invitation_emails(new_invitations) if success
48-
success ? [new_invitations, existing_invitations, new_course_users, existing_course_users, duplicate_users] : nil
54+
if success
55+
[new_invitations, existing_invitations, new_course_users, existing_course_users,
56+
duplicate_users, updated_invitations, updated_course_users]
57+
end
4958
end
5059

5160
# Resends invitation emails to CourseUsers to the given course.
@@ -77,6 +86,8 @@ def resend_invitation(invitations)
7786
def invite_users(users)
7887
unique_users, parse_duplicates = parse_invitations(users)
7988
@duplicate_users = parse_duplicates
80-
process_invitations(unique_users) + [@duplicate_users]
89+
@updated_invitations = []
90+
@updated_course_users = []
91+
process_invitations(unique_users) + [@duplicate_users, @updated_invitations, @updated_course_users]
8192
end
8293
end

app/views/course/user_invitations/_invitation_result_data.json.jbuilder

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ json.existingCourseUsers existing_course_users.each do |course_user|
3838
json.phantom course_user.phantom?
3939
end
4040

41-
json.duplicateUsers duplicate_users.each do |duplicate_user, index|
41+
json.duplicateUsers duplicate_users.each.with_index do |duplicate_user, index|
4242
json.id index
4343
json.name duplicate_user[:name]
4444
json.email duplicate_user[:email]
@@ -47,3 +47,22 @@ json.duplicateUsers duplicate_users.each do |duplicate_user, index|
4747
json.phantom duplicate_user[:phantom]
4848
json.reason duplicate_user[:reason]
4949
end
50+
51+
json.updatedInvitations updated_invitations.each do |invitation|
52+
json.id invitation.id
53+
json.name invitation.name
54+
json.email invitation.email
55+
json.externalId invitation.external_id
56+
json.role invitation.role
57+
json.phantom invitation.phantom
58+
json.sentAt invitation.sent_at
59+
end
60+
61+
json.updatedCourseUsers updated_course_users.each do |course_user|
62+
json.id course_user.id if course_user.id
63+
json.name course_user.name.strip
64+
json.email course_user.user.email
65+
json.externalId course_user.external_id
66+
json.role course_user.role
67+
json.phantom course_user.phantom?
68+
end

0 commit comments

Comments
 (0)