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);
}