fix(crm): stop the invitation UI claiming more than it can know
Codex review round 1 on #1274. Three places where this branch replaced one overclaim with another. The badge tooltip said "Invitation sent". createInvitation inserts the customer_invitations row and only then queues the email, with no transaction around the pair, so an open invitation does not prove an email_queue row exists -- and even when it does, delivery is the queue processor's business minutes later. The tooltip now describes the invitation link itself and points at System health, which is the same distinction #1273 draws. Both conflicts are 409 and were being treated as one. Migration-era send-invite returns 409 with code CUSTOMER_ALREADY_ACTIVE when the customer already has a password, which happens if an open invitation for that address is accepted between createDirect and sendInvite. There is then no invitation row to cancel, so directing the admin to the Invitations tab points them at something that does not exist. The code is now read before the message is chosen. A dropped connection or a timeout rejects with no `response` at all, and the request may well have succeeded server-side. Saying "no invitation went out" there sends the admin into a retry that then 409s, which is the same trap the CUSTOMER_ALREADY_ACTIVE case sets. That branch is now explicitly unconfirmed and says where the answer is. 3 more tests, all 3 failing before this commit.
This commit is contained in:
@@ -189,15 +189,34 @@ export const InlineCustomerCreate: React.FC<Props> = ({ onCreated, onCancel, mod
|
|||||||
toast.success(t('customers.create.savedActiveToast',
|
toast.success(t('customers.create.savedActiveToast',
|
||||||
'Customer created and portal invitation queued. It is sent by the email queue — check System health if it does not arrive.'));
|
'Customer created and portal invitation queued. It is sent by the email queue — check System health if it does not arrive.'));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// A 409 means an invitation for this address is already open, which
|
// Three different things reach this branch and they need three
|
||||||
// is a different thing from the email failing: the customer is
|
// different answers. Only one of them means "no invitation exists".
|
||||||
// invited, and re-inviting is what was refused.
|
const status = err?.response?.status;
|
||||||
const alreadyInvited = err?.response?.status === 409;
|
const code = err?.response?.data?.code;
|
||||||
toast.warn(alreadyInvited
|
|
||||||
? t('customers.create.inviteAlreadyPendingToast',
|
if (status === 409 && code === 'CUSTOMER_ALREADY_ACTIVE') {
|
||||||
'Customer saved. An invitation for this address is already open — cancel it on the Invitations tab before sending a new one.')
|
// An open invitation for this address was accepted between
|
||||||
: t('customers.create.inviteFailedToast',
|
// createDirect and sendInvite. The customer has portal access
|
||||||
|
// already, so there is nothing to cancel and nothing to resend.
|
||||||
|
toast.info(t('customers.create.inviteAlreadyActiveToast',
|
||||||
|
'Customer saved. This address already has portal access, so no invitation was needed.'));
|
||||||
|
} else if (status === 409) {
|
||||||
|
// An invitation for this address is already open and the RE-invite
|
||||||
|
// was refused. The customer IS invited; telling them to retry
|
||||||
|
// would send them the wrong way.
|
||||||
|
toast.warn(t('customers.create.inviteAlreadyPendingToast',
|
||||||
|
'Customer saved. An invitation for this address is already open — cancel it on the Invitations tab before sending a new one.'));
|
||||||
|
} else if (!err?.response) {
|
||||||
|
// No response at all: a dropped connection or a timeout. The
|
||||||
|
// request may well have succeeded server-side, so claiming it
|
||||||
|
// failed sends the admin into a retry that then 409s. Say what is
|
||||||
|
// actually known and point at where the answer is.
|
||||||
|
toast.warn(t('customers.create.inviteUnconfirmedToast',
|
||||||
|
'Customer saved, but the invitation could not be confirmed — the server did not answer. Check the Invitations tab before sending another.'));
|
||||||
|
} else {
|
||||||
|
toast.warn(t('customers.create.inviteFailedToast',
|
||||||
'Customer saved as PASSIVE — no invitation went out. Retry "Send portal invitation" from the customer detail page.'));
|
'Customer saved as PASSIVE — no invitation went out. Retry "Send portal invitation" from the customer detail page.'));
|
||||||
|
}
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('sendInvite failed', err);
|
console.warn('sendInvite failed', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4852,7 +4852,9 @@
|
|||||||
"inviteFailedToast": "Kunde als PASSIV gespeichert — es ging keine Einladung raus. Wiederholen Sie „Portal-Einladung senden“ auf der Kundendetailseite.",
|
"inviteFailedToast": "Kunde als PASSIV gespeichert — es ging keine Einladung raus. Wiederholen Sie „Portal-Einladung senden“ auf der Kundendetailseite.",
|
||||||
"emailRequired": "Eine gültige E-Mail-Adresse ist erforderlich.",
|
"emailRequired": "Eine gültige E-Mail-Adresse ist erforderlich.",
|
||||||
"nameRequired": "Geben Sie mindestens einen Firmennamen oder einen Ansprechpartner an.",
|
"nameRequired": "Geben Sie mindestens einen Firmennamen oder einen Ansprechpartner an.",
|
||||||
"inviteAlreadyPendingToast": "Kunde gespeichert. Für diese Adresse ist bereits eine Einladung offen — stornieren Sie sie im Tab „Einladungen“, bevor Sie eine neue senden."
|
"inviteAlreadyPendingToast": "Kunde gespeichert. Für diese Adresse ist bereits eine Einladung offen — stornieren Sie sie im Tab „Einladungen“, bevor Sie eine neue senden.",
|
||||||
|
"inviteAlreadyActiveToast": "Kunde gespeichert. Diese Adresse hat bereits Portalzugang, eine Einladung war nicht nötig.",
|
||||||
|
"inviteUnconfirmedToast": "Kunde gespeichert, aber die Einladung ist unbestätigt — der Server hat nicht geantwortet. Prüfen Sie den Tab „Einladungen“, bevor Sie erneut senden."
|
||||||
},
|
},
|
||||||
"passive": {
|
"passive": {
|
||||||
"badge": "Passiv — nur Admin",
|
"badge": "Passiv — nur Admin",
|
||||||
@@ -5049,7 +5051,7 @@
|
|||||||
},
|
},
|
||||||
"invitePending": {
|
"invitePending": {
|
||||||
"badge": "Einladung offen",
|
"badge": "Einladung offen",
|
||||||
"hint": "Einladung verschickt, noch nicht angenommen. Der Kunde bleibt passiv, bis er ein Passwort gesetzt hat."
|
"hint": "Für diese Adresse ist ein Einladungslink offen und noch nicht angenommen. Das ist kein Beleg dafür, dass die E-Mail angekommen ist — prüfen Sie den Systemzustand, falls der Kunde nichts erhalten hat."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
|||||||
@@ -4852,7 +4852,9 @@
|
|||||||
"inviteFailedToast": "Customer saved as PASSIVE — no invitation went out. Retry \"Send portal invitation\" from the customer detail page.",
|
"inviteFailedToast": "Customer saved as PASSIVE — no invitation went out. Retry \"Send portal invitation\" from the customer detail page.",
|
||||||
"emailRequired": "A valid email is required.",
|
"emailRequired": "A valid email is required.",
|
||||||
"nameRequired": "Enter at least a company name or a contact name.",
|
"nameRequired": "Enter at least a company name or a contact name.",
|
||||||
"inviteAlreadyPendingToast": "Customer saved. An invitation for this address is already open — cancel it on the Invitations tab before sending a new one."
|
"inviteAlreadyPendingToast": "Customer saved. An invitation for this address is already open — cancel it on the Invitations tab before sending a new one.",
|
||||||
|
"inviteAlreadyActiveToast": "Customer saved. This address already has portal access, so no invitation was needed.",
|
||||||
|
"inviteUnconfirmedToast": "Customer saved, but the invitation could not be confirmed — the server did not answer. Check the Invitations tab before sending another."
|
||||||
},
|
},
|
||||||
"passive": {
|
"passive": {
|
||||||
"badge": "Passive — admin only",
|
"badge": "Passive — admin only",
|
||||||
@@ -5049,7 +5051,7 @@
|
|||||||
},
|
},
|
||||||
"invitePending": {
|
"invitePending": {
|
||||||
"badge": "Invitation pending",
|
"badge": "Invitation pending",
|
||||||
"hint": "Invitation sent, not accepted yet. The customer stays passive until they set a password."
|
"hint": "An invitation link for this address is open and has not been accepted. That is not proof the email reached them — check System health if they say it never arrived."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
|||||||
@@ -256,8 +256,13 @@ export const CustomerManagementPage: React.FC = () => {
|
|||||||
return invite ? (
|
return invite ? (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300"
|
className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300"
|
||||||
|
// Deliberately describes the invitation ROW, not a
|
||||||
|
// delivery. createInvitation inserts the row and then
|
||||||
|
// queues the email without a transaction, so an open
|
||||||
|
// invitation does not prove an email_queue row exists,
|
||||||
|
// let alone that anything was delivered.
|
||||||
title={t('customers.invitePending.hint',
|
title={t('customers.invitePending.hint',
|
||||||
'Invitation sent, not accepted yet. The customer stays passive until they set a password.') as string}
|
'An invitation link for this address is open and has not been accepted. That is not proof the email reached them — check System health if they say it never arrived.') as string}
|
||||||
>
|
>
|
||||||
<MailCheck className="w-3 h-3" />
|
<MailCheck className="w-3 h-3" />
|
||||||
{t('customers.invitePending.badge', 'Invitation pending')}
|
{t('customers.invitePending.badge', 'Invitation pending')}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ vi.mock('react-toastify', () => ({
|
|||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
const toastSuccess = toast.success as ReturnType<typeof vi.fn>;
|
const toastSuccess = toast.success as ReturnType<typeof vi.fn>;
|
||||||
const toastWarn = toast.warn as ReturnType<typeof vi.fn>;
|
const toastWarn = toast.warn as ReturnType<typeof vi.fn>;
|
||||||
|
const toastInfo = toast.info as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
vi.mock('../../../contexts/PermissionsContext', () => ({
|
vi.mock('../../../contexts/PermissionsContext', () => ({
|
||||||
usePermissions: () => ({
|
usePermissions: () => ({
|
||||||
@@ -190,4 +191,57 @@ describe('InlineCustomerCreate — what the toast may claim (#1261)', () => {
|
|||||||
expect(message).toMatch(/already open/i);
|
expect(message).toMatch(/already open/i);
|
||||||
expect(message).not.toMatch(/PASSIVE/);
|
expect(message).not.toMatch(/PASSIVE/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('separates CUSTOMER_ALREADY_ACTIVE from a pending-invitation 409', async () => {
|
||||||
|
// Codex review round 1. Both conflicts are 409. This one means an open
|
||||||
|
// invitation was accepted between createDirect and sendInvite, so there is
|
||||||
|
// no invitation row to cancel — sending the admin to the Invitations tab
|
||||||
|
// points them at something that does not exist.
|
||||||
|
createDirect.mockResolvedValue({ id: 8, email: '[email protected]' });
|
||||||
|
sendInvite.mockRejectedValue({
|
||||||
|
response: { status: 409, data: { code: 'CUSTOMER_ALREADY_ACTIVE' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWith(<InlineCustomerCreate mode="invite" onCreated={() => {}} onCancel={() => {}} />);
|
||||||
|
await fill();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Save & send portal invitation/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toastInfo).toHaveBeenCalled());
|
||||||
|
const message = String(toastInfo.mock.calls[0][0]);
|
||||||
|
expect(message).toMatch(/already has portal access/i);
|
||||||
|
expect(message).not.toMatch(/Invitations tab/i);
|
||||||
|
expect(toastWarn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays indeterminate when the server never answered', async () => {
|
||||||
|
// Codex review round 1. A dropped connection or timeout rejects with no
|
||||||
|
// `response`, but the request may have succeeded server-side. Declaring
|
||||||
|
// "no invitation went out" sends the admin into a retry that then 409s.
|
||||||
|
createDirect.mockResolvedValue({ id: 9, email: '[email protected]' });
|
||||||
|
sendInvite.mockRejectedValue(new Error('Network Error'));
|
||||||
|
|
||||||
|
renderWith(<InlineCustomerCreate mode="invite" onCreated={() => {}} onCancel={() => {}} />);
|
||||||
|
await fill();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Save & send portal invitation/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toastWarn).toHaveBeenCalled());
|
||||||
|
const message = String(toastWarn.mock.calls[0][0]);
|
||||||
|
expect(message).toMatch(/could not be confirmed/i);
|
||||||
|
expect(message).not.toMatch(/PASSIVE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not claim the invitation email was delivered', async () => {
|
||||||
|
// createInvitation inserts the customer_invitations row and only then
|
||||||
|
// queues the email, without a transaction — so an open invitation is not
|
||||||
|
// evidence that an email_queue row exists, let alone that it was sent.
|
||||||
|
list.mockResolvedValue([customer(10, '[email protected]')]);
|
||||||
|
listInvitations.mockResolvedValue([invitation(20, '[email protected]')]);
|
||||||
|
|
||||||
|
renderWith(<CustomerManagementPage />);
|
||||||
|
|
||||||
|
const badge = await screen.findByText('Invitation pending');
|
||||||
|
const tooltip = badge.getAttribute('title') || badge.closest('[title]')?.getAttribute('title') || '';
|
||||||
|
expect(tooltip).not.toMatch(/invitation sent/i);
|
||||||
|
expect(tooltip).toMatch(/not proof/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user