Skip to content

Mark permanently non-retryable invitations & update UI#8219

Open
adi-herwana-nus wants to merge 4 commits intomasterfrom
adi/handle-user-invitation-retries
Open

Mark permanently non-retryable invitations & update UI#8219
adi-herwana-nus wants to merge 4 commits intomasterfrom
adi/handle-user-invitation-retries

Conversation

@adi-herwana-nus
Copy link
Contributor

Screenshot 2026-02-06 at 15 51 41 Screenshot 2026-02-06 at 19 12 39

Copy link

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 a non-retryable state to invitations when email delivery fails permanently, and updates both course + system-admin invitation UIs to surface a “Failed” category and prevent resending those invitations.

Changes:

  • Add is_retryable boolean to course + instance invitation models, scopes, controllers, and API payloads.
  • Add mail-delivery error handling to mark invitations non-retryable on permanent SMTP failures.
  • Update invitation UIs (tables, bar chart, action buttons, translations) to support Pending / Accepted / Failed views.

Reviewed changes

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

Show a summary per file
File Description
spec/services/instance/user_invitation_service_spec.rb Adds expectations that non-retryable instance invitations are not resent/updated.
spec/services/course/user_invitation_service_spec.rb Adds expectations that non-retryable course invitations are not resent/updated.
spec/jobs/mail_delivery_job_spec.rb New spec covering permanent vs transient SMTP errors and retryability.
spec/controllers/system/admin/instance/user_invitations_controller_spec.rb Ensures non-retryable instance invitations are excluded from resend loading.
spec/controllers/course/user_invitations_controller_spec.rb Ensures non-retryable course invitations are excluded from resend loading.
db/schema.rb Reflects new is_retryable columns.
db/migrate/20260206070824_add_retryable_flag_to_user_invitations.rb Adds is_retryable to course + instance invitation tables.
config/initializers/mail_delivery_job.rb Configures mail delivery job discard behavior + marking records invalid.
client/locales/en.json Updates/renames invitation-related message IDs and adds “Failed” strings.
client/locales/ko.json Updates/renames invitation-related message IDs and adds “Failed” strings.
client/locales/zh.json Updates/renames invitation-related message IDs and adds “Failed” strings.
client/app/types/system/instance/invitations.ts Adds isRetryable to instance invitation DTOs.
client/app/types/course/userInvitations.ts Adds isRetryable, tightens role typing, introduces InvitationType.
client/app/theme/palette.js Adds palette color for failed invitation status.
client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx Adjusts column lookup offsets for headers/filters with initial columns.
client/app/bundles/system/admin/instance/instance/pages/InstanceUsersInvitations.tsx Reworks instance invitation page into a radio-selected Pending/Accepted/Failed view.
client/app/bundles/system/admin/instance/instance/components/tables/UserInvitationsTable.tsx Migrates to shared Table component and adds actions/toolbars by type.
client/app/bundles/system/admin/instance/instance/components/buttons/PendingInvitationsButtons.tsx Renames translation IDs/component naming for invitation action buttons.
client/app/bundles/course/user-invitations/translations.ts Centralizes course invitation page/table translations.
client/app/bundles/course/user-invitations/pages/InvitationsIndex/index.tsx Reworks course invitation page into a radio-selected Pending/Accepted/Failed view.
client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx Migrates to shared Table component; conditionally shows resend actions.
client/app/bundles/course/user-invitations/components/misc/InvitationsBarChart.tsx Adds “Failed” series and switches to shared translations hook.
client/app/bundles/course/user-invitations/components/buttons/InvitationActionButtons.tsx Hides resend button when invitation is not retryable; updates translations usage.
app/views/system/admin/instance/user_invitations/_instance_user_invitation_list_data.json.jbuilder Exposes isRetryable in instance invitation JSON.
app/views/course/users/_tabs_data.json.jbuilder Excludes non-retryable invitations from invitations count.
app/views/course/user_invitations/_course_user_invitation_list_data.json.jbuilder Exposes isRetryable in course invitation JSON.
app/models/instance/user_invitation.rb Adds retryable scope + mark_email_as_invalid.
app/models/course/user_invitation.rb Adds retryable scope + mark_email_as_invalid.
app/controllers/system/admin/instance/user_invitations_controller.rb Filters resend loading to retryable invitations only.
app/controllers/course/user_invitations_controller.rb Filters resend loading to retryable invitations only.

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

Comment on lines 26 to 45
ActionMailer::MailDeliveryJob.sidekiq_retries_exhausted do |msg, _exception|
# Sidekiq job payload structure:
# msg['args'] is an array with a single element (the ActiveJob serialized hash)
# The hash contains 'arguments' key with the job's arguments
job_data = msg['args'].first
next unless job_data.is_a?(Hash)

arguments = job_data['arguments']
next unless arguments.is_a?(Array) && arguments.length >= 4

args_hash = arguments[3]
next unless args_hash.is_a?(Hash)

# Deserialize the GlobalID to get the actual record
serialized_record = args_hash['args']&.first
next unless serialized_record.is_a?(Hash) && serialized_record['_aj_globalid']

record = GlobalID::Locator.locate(serialized_record['_aj_globalid'])
error = StandardError.new("Retries exhausted: #{msg['error_class']} - #{msg['error_message']}")
record.mark_email_as_invalid(error) if record&.respond_to?(:mark_email_as_invalid)\
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

sidekiq_retries_exhausted is not a standard API on ActionMailer::MailDeliveryJob (ActiveJob). In production this will likely raise NoMethodError during boot and prevent the app from starting. Consider moving this logic to a Sidekiq server-side hook (e.g. Sidekiq.configure_server { |c| c.death_handlers << ... }) or wiring it into a job class that actually includes Sidekiq's retry hooks (e.g. the ActiveJob Sidekiq wrapper), instead of calling it on MailDeliveryJob directly.

Suggested change
ActionMailer::MailDeliveryJob.sidekiq_retries_exhausted do |msg, _exception|
# Sidekiq job payload structure:
# msg['args'] is an array with a single element (the ActiveJob serialized hash)
# The hash contains 'arguments' key with the job's arguments
job_data = msg['args'].first
next unless job_data.is_a?(Hash)
arguments = job_data['arguments']
next unless arguments.is_a?(Array) && arguments.length >= 4
args_hash = arguments[3]
next unless args_hash.is_a?(Hash)
# Deserialize the GlobalID to get the actual record
serialized_record = args_hash['args']&.first
next unless serialized_record.is_a?(Hash) && serialized_record['_aj_globalid']
record = GlobalID::Locator.locate(serialized_record['_aj_globalid'])
error = StandardError.new("Retries exhausted: #{msg['error_class']} - #{msg['error_message']}")
record.mark_email_as_invalid(error) if record&.respond_to?(:mark_email_as_invalid)\
Sidekiq.configure_server do |config|
config.death_handlers << lambda do |job, exception|
# Sidekiq job payload structure:
# job['args'] is an array with a single element (the ActiveJob serialized hash)
# The hash contains 'arguments' key with the job's arguments
job_data = job['args'].first
next unless job_data.is_a?(Hash)
arguments = job_data['arguments']
next unless arguments.is_a?(Array) && arguments.length >= 4
args_hash = arguments[3]
next unless args_hash.is_a?(Hash)
# Deserialize the GlobalID to get the actual record
serialized_record = args_hash['args']&.first
next unless serialized_record.is_a?(Hash) && serialized_record['_aj_globalid']
record = GlobalID::Locator.locate(serialized_record['_aj_globalid'])
error_message = if exception
"Retries exhausted: #{exception.class} - #{exception.message}"
else
"Retries exhausted: #{job['error_class']} - #{job['error_message']}"
end
error = StandardError.new(error_message)
record.mark_email_as_invalid(error) if record&.respond_to?(:mark_email_as_invalid)
end

Copilot uses AI. Check for mistakes.
Comment on lines 126 to 130
forEach: (header, index) => ({
id: header.id,
render: customHeaderRender(header),
className: getRealColumn(index)?.className,
className: getRealColumn(index - initialColumnsLength)?.className,
sorting: header.column.getCanSort()
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

Only the header mapping subtracts initialColumnsLength, but the body cell mapping still uses getRealColumn(index) (where index includes initial/index/selector columns). This will keep className/colSpan/cellUnless misaligned whenever indexing.indices or rowSelectable is enabled. Apply the same index - initialColumnsLength adjustment in the body cell lookups too (and ensure negative indices are handled).

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The cell mapping already works as intended.

end

it 'does not change the is_retryable flag' do
expect { subject rescue Net::SMTPServerBusy }.not_to change { invitation.reload.is_retryable }
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

This expectation rescues all StandardErrors (not just Net::SMTPServerBusy) and would silently hide unexpected failures. Use an explicit begin/rescue Net::SMTPServerBusy (or rescue Net::SMTPServerBusy => _e) within the block so only the intended exception is swallowed.

Suggested change
expect { subject rescue Net::SMTPServerBusy }.not_to change { invitation.reload.is_retryable }
expect do
begin
subject
rescue Net::SMTPServerBusy
end
end.not_to change { invitation.reload.is_retryable }

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Reworked the test case.

- add retryable flag to user_invitations (course and instance)
- mark invitation as non-retryable if out of retries
- if invitation fails with permanent error (e.g. bad addr), immediately mark as non-retryable
@adi-herwana-nus adi-herwana-nus force-pushed the adi/handle-user-invitation-retries branch from 925d801 to 03ea566 Compare February 6, 2026 12:17
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