Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/controllers/course/user_invitations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,11 @@ def resend_invitation_params
def load_invitations
@invitations ||= begin
ids = resend_invitation_params
ids ||= current_course.invitations.unconfirmed.select(:id)
ids ||= current_course.invitations.retryable.unconfirmed.select(:id)
if ids.blank?
[]
else
current_course.invitations.unconfirmed.where('course_user_invitations.id IN (?)', ids)
current_course.invitations.retryable.unconfirmed.where('course_user_invitations.id IN (?)', ids)
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@ def invitation_service
def invitations
@invitations ||= begin
ids = resend_invitation_params
ids ||= @instance.invitations.unconfirmed.select(:id)
ids ||= @instance.invitations.retryable.unconfirmed.select(:id)
if ids.blank?
[]
else
@instance.invitations.unconfirmed.where('instance_user_invitations.id IN (?)', ids)
@instance.invitations.retryable.unconfirmed.where('instance_user_invitations.id IN (?)', ids)
end
end
end
Expand Down
7 changes: 7 additions & 0 deletions app/models/course/user_invitation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class Course::UserInvitation < ApplicationRecord

# Invitations that haven't been confirmed, i.e. pending the user's acceptance.
scope :unconfirmed, -> { where(confirmed_at: nil) }
scope :retryable, -> { where(is_retryable: true) }

INVITATION_KEY_IDENTIFIER = 'I'

Expand All @@ -38,6 +39,12 @@ def confirmed?
confirmed_at.present?
end

# Called by MailDeliveryJob when a permanent SMTP error occurs (e.g. invalid address).
# Marks the invitation as not retryable to prevent further delivery attempts.
def mark_email_as_invalid(_error)
update_column(:is_retryable, false)
end

# Determines roles that current user can invite to current course
#
# @param [String] own_role Current user's role in current course
Expand Down
7 changes: 7 additions & 0 deletions app/models/instance/user_invitation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class Instance::UserInvitation < ApplicationRecord

# Invitations that haven't been confirmed, i.e. pending the user's acceptance.
scope :unconfirmed, -> { where(confirmed_at: nil) }
scope :retryable, -> { where(is_retryable: true) }

INVITATION_KEY_IDENTIFIER = 'J'

Expand All @@ -37,6 +38,12 @@ def confirmed?
confirmed_at.present?
end

# Called by MailDeliveryJob when a permanent SMTP error occurs (e.g. invalid address).
# Marks the invitation as not retryable to prevent further delivery attempts.
def mark_email_as_invalid(_error)
update_column(:is_retryable, false)
end

private

# Generates the invitation key. instance invitation keys generated start with J.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ json.role invitation.role
json.phantom invitation.phantom
json.timelineAlgorithm invitation.timeline_algorithm
json.invitationKey invitation.invitation_key
json.isRetryable invitation.is_retryable
json.confirmed invitation.confirmed?
json.sentAt invitation.sent_at
json.confirmedAt invitation.confirmed_at
2 changes: 1 addition & 1 deletion app/views/course/users/_tabs_data.json.jbuilder
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# frozen_string_literal: true
json.requestsCount current_course.enrol_requests.pending.count
json.invitationsCount current_course.invitations.unconfirmed.count
json.invitationsCount current_course.invitations.retryable.unconfirmed.count
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ json.name invitation.name
json.email invitation.email
json.role invitation.role
json.invitationKey invitation.invitation_key
json.isRetryable invitation.is_retryable
json.confirmed invitation.confirmed?
json.sentAt invitation.sent_at
json.confirmedAt invitation.confirmed_at
Original file line number Diff line number Diff line change
@@ -1,59 +1,57 @@
import { FC, memo, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
import { defineMessages } from 'react-intl';
import equal from 'fast-deep-equal';
import { InvitationRowData } from 'types/course/userInvitations';
import { InvitationMiniEntity } from 'types/course/userInvitations';

import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import EmailButton from 'lib/components/core/buttons/EmailButton';
import { useAppDispatch } from 'lib/hooks/store';
import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';

import { deleteInvitation, resendInvitationEmail } from '../../operations';

interface Props extends WrappedComponentProps {
invitation: InvitationRowData;
interface Props {
invitation: InvitationMiniEntity;
isRetryable?: boolean;
}
const styles = {
buttonStyle: {
padding: '0px 8px',
},
};

const translations = defineMessages({
resendTooltip: {
id: 'course.userInvitations.PendingInvitationsButton.resendTooltip',
id: 'course.userInvitations.InvitationActionButtons.resendTooltip',
defaultMessage: 'Resend Invitation',
},
resendSuccess: {
id: 'course.userInvitations.PendingInvitationsButton.resendSuccess',
id: 'course.userInvitations.InvitationActionButtons.resendSuccess',
defaultMessage: 'Resent email invitation to {email}!',
},
resendFailure: {
id: 'course.userInvitations.PendingInvitationsButton.resendFailure',
id: 'course.userInvitations.InvitationActionButtons.resendFailure',
defaultMessage: 'Failed to resend invitation - {error}',
},
deletionTooltip: {
id: 'course.userInvitations.PendingInvitationsButton.deletionTooltip',
id: 'course.userInvitations.InvitationActionButtons.deletionTooltip',
defaultMessage: 'Delete Invitation',
},
deletionConfirm: {
id: 'course.userInvitations.PendingInvitationsButton.deletionConfirm',
id: 'course.userInvitations.InvitationActionButtons.deletionConfirm',
defaultMessage:
'Are you sure you wish to delete invitation to {name} ({email})?',
},
deletionSuccess: {
id: 'course.userInvitations.PendingInvitationsButton.deletionSuccess',
id: 'course.userInvitations.InvitationActionButtons.deletionSuccess',
defaultMessage: 'Invitation for {name} was deleted.',
},
deletionFailure: {
id: 'course.userInvitations.PendingInvitationsButton.deletionFailure',
id: 'course.userInvitations.InvitationActionButtons.deletionFailure',
defaultMessage: 'Failed to delete user - {error}',
},
});

const PendingInvitationsButtons: FC<Props> = (props) => {
const { intl, invitation } = props;
const InvitationActionButtons: FC<Props> = (props) => {
const { invitation, isRetryable } = props;
const dispatch = useAppDispatch();
const { t } = useTranslation();
const [isResending, setIsResending] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);

Expand All @@ -62,7 +60,7 @@ const PendingInvitationsButtons: FC<Props> = (props) => {
return dispatch(resendInvitationEmail(invitation.id))
.then(() => {
toast.success(
intl.formatMessage(translations.resendSuccess, {
t(translations.resendSuccess, {
email: invitation.email,
}),
);
Expand All @@ -72,7 +70,7 @@ const PendingInvitationsButtons: FC<Props> = (props) => {
? error.response.data.errors
: '';
toast.error(
intl.formatMessage(translations.resendFailure, {
t(translations.resendFailure, {
error: errorMessage,
}),
);
Expand All @@ -85,7 +83,7 @@ const PendingInvitationsButtons: FC<Props> = (props) => {
return dispatch(deleteInvitation(invitation.id))
.then(() => {
toast.success(
intl.formatMessage(translations.deletionSuccess, {
t(translations.deletionSuccess, {
name: invitation.name,
}),
);
Expand All @@ -96,7 +94,7 @@ const PendingInvitationsButtons: FC<Props> = (props) => {
? error.response.data.errors
: '';
toast.error(
intl.formatMessage(translations.deletionFailure, {
t(translations.deletionFailure, {
error: errorMessage,
}),
);
Expand All @@ -105,33 +103,30 @@ const PendingInvitationsButtons: FC<Props> = (props) => {
};

return (
<div style={{ whiteSpace: 'nowrap' }}>
<EmailButton
className={`invitation-resend-${invitation.id}`}
disabled={isResending || isDeleting}
onClick={onResend}
sx={styles.buttonStyle}
tooltip={intl.formatMessage(translations.resendTooltip)}
/>
<div className="flex whitespace-nowrap space-x-3">
{isRetryable && (
<EmailButton
className={`invitation-resend-${invitation.id}`}
disabled={isResending || isDeleting}
onClick={onResend}
tooltip={t(translations.resendTooltip)}
/>
)}
<DeleteButton
className={`invitation-delete-${invitation.id}`}
confirmMessage={intl.formatMessage(translations.deletionConfirm, {
confirmMessage={t(translations.deletionConfirm, {
name: invitation.name,
email: invitation.email,
})}
disabled={isResending || isDeleting}
loading={isDeleting}
onClick={onDelete}
sx={styles.buttonStyle}
tooltip={intl.formatMessage(translations.deletionTooltip)}
tooltip={t(translations.deletionTooltip)}
/>
</div>
);
};

export default memo(
injectIntl(PendingInvitationsButtons),
(prevProps, nextProps) => {
return equal(prevProps.invitation, nextProps.invitation);
},
);
export default memo(InvitationActionButtons, (prevProps, nextProps) => {
return equal(prevProps.invitation, nextProps.invitation);
});
Original file line number Diff line number Diff line change
@@ -1,36 +1,34 @@
import { defineMessages, FormattedMessage } from 'react-intl';
import palette from 'theme/palette';

import BarChart from 'lib/components/core/BarChart';
import useTranslation from 'lib/hooks/useTranslation';

import translations from '../../translations';

interface BarchartProps {
accepted: number;
pending: number;
failed: number;
}

const translations = defineMessages({
accepted: {
id: 'course.userInvitations.InvitationsBarChart.accepted',
defaultMessage: 'Accepted Invitations',
},
pending: {
id: 'course.userInvitations.InvitationsBarChart.pending',
defaultMessage: 'Pending',
},
});

const InvitationsBarChart = (props: BarchartProps): JSX.Element => {
const { accepted, pending } = props;
const { accepted, pending, failed } = props;
const { t } = useTranslation();
const data = [
{
count: pending,
color: palette.invitationStatus.pending,
label: <FormattedMessage {...translations.pending} />,
label: t(translations.pending),
},
{
count: accepted,
color: palette.invitationStatus.accepted,
label: <FormattedMessage {...translations.accepted} />,
label: t(translations.accepted),
},
{
count: failed,
color: palette.invitationStatus.failed,
label: t(translations.failed),
},
];

Expand Down
Loading