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:
Paul Nothaft
2026-09-02 14:16:24 +02:00
parent 1b8e5f83d7
commit 6bb12c6612
5 changed files with 95 additions and 13 deletions
@@ -189,15 +189,34 @@ export const InlineCustomerCreate: React.FC<Props> = ({ onCreated, onCancel, mod
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.'));
} catch (err: any) {
// A 409 means an invitation for this address is already open, which
// is a different thing from the email failing: the customer is
// invited, and re-inviting is what was refused.
const alreadyInvited = err?.response?.status === 409;
toast.warn(alreadyInvited
? 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.')
: t('customers.create.inviteFailedToast',
// Three different things reach this branch and they need three
// different answers. Only one of them means "no invitation exists".
const status = err?.response?.status;
const code = err?.response?.data?.code;
if (status === 409 && code === 'CUSTOMER_ALREADY_ACTIVE') {
// An open invitation for this address was accepted between
// 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.'));
}
// eslint-disable-next-line no-console
console.warn('sendInvite failed', err);
}
+4 -2
View File
@@ -4852,7 +4852,9 @@
"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.",
"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": {
"badge": "Passiv — nur Admin",
@@ -5049,7 +5051,7 @@
},
"invitePending": {
"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": {
+4 -2
View File
@@ -4852,7 +4852,9 @@
"inviteFailedToast": "Customer saved as PASSIVE — no invitation went out. Retry \"Send portal invitation\" from the customer detail page.",
"emailRequired": "A valid email is required.",
"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": {
"badge": "Passive — admin only",
@@ -5049,7 +5051,7 @@
},
"invitePending": {
"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": {
@@ -256,8 +256,13 @@ export const CustomerManagementPage: React.FC = () => {
return invite ? (
<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"
// 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',
'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" />
{t('customers.invitePending.badge', 'Invitation pending')}
@@ -29,6 +29,7 @@ vi.mock('react-toastify', () => ({
import { toast } from 'react-toastify';
const toastSuccess = toast.success 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', () => ({
usePermissions: () => ({
@@ -190,4 +191,57 @@ describe('InlineCustomerCreate — what the toast may claim (#1261)', () => {
expect(message).toMatch(/already open/i);
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);
});
});