Mark permanently non-retryable invitations & update UI#8219
Mark permanently non-retryable invitations & update UI#8219adi-herwana-nus wants to merge 4 commits intomasterfrom
Conversation
adi-herwana-nus
commented
Feb 6, 2026
There was a problem hiding this comment.
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_retryableboolean 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.
client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx
Outdated
Show resolved
Hide resolved
...nt/app/bundles/system/admin/instance/instance/components/buttons/InvitationActionButtons.tsx
Show resolved
Hide resolved
| 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)\ |
There was a problem hiding this comment.
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.
| 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 |
| forEach: (header, index) => ({ | ||
| id: header.id, | ||
| render: customHeaderRender(header), | ||
| className: getRealColumn(index)?.className, | ||
| className: getRealColumn(index - initialColumnsLength)?.className, | ||
| sorting: header.column.getCanSort() |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
The cell mapping already works as intended.
spec/jobs/mail_delivery_job_spec.rb
Outdated
| end | ||
|
|
||
| it 'does not change the is_retryable flag' do | ||
| expect { subject rescue Net::SMTPServerBusy }.not_to change { invitation.reload.is_retryable } |
There was a problem hiding this comment.
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.
| 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 } |
There was a problem hiding this comment.
Reworked the test case.
client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx
Outdated
Show resolved
Hide resolved
- 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
925d801 to
03ea566
Compare