fix(crm): label the two invitation conflicts and stop guessing after a 5xx

Codex review round 2 on #1274. Both findings are the same shape as round 1:
a message that asserts more than the response supports, and sends the admin
somewhere that makes it worse.

A 5xx is no longer treated as a clean failure. createInvitation inserts the
customer_invitations row and only then queues the email, with no transaction
around the pair, so a 500 out of the queueing step leaves an OPEN invitation
behind. Telling the admin "no invitation went out, retry" there walks them into
a 409 that still queues nothing. 5xx now joins the no-response case as
unconfirmed; a plain 4xx keeps the clean-failure message, because that is the
one shape where nothing was written.

The two already-active conflicts now say so. The send-invite route returns
CUSTOMER_ALREADY_ACTIVE from its own check, but createInvitation rechecks
customer_accounts afterwards and threw a bare ConflictError -- code CONFLICT,
indistinguishable from the pending-invitation conflict. An invitation accepted
between the two checks therefore landed in the pending branch, telling the
admin to cancel an invitation that acceptance had just closed. Both conflicts
in the service now carry a code of their own, so the client reads the code
rather than inferring from the status.

4 more tests. One round-1 test changed with the behaviour it pinned: its 500
now asserts the unconfirmed message, and a new 400 case covers the clean
failure it used to stand for.
This commit is contained in:
Paul Nothaft
2026-09-02 14:52:03 +02:00
parent 6bb12c6612
commit bc90b4db62
5 changed files with 90 additions and 20 deletions
@@ -126,7 +126,16 @@ async function createInvitation({ email, invitedById, prefill }) {
.first();
if (existingCustomer && existingCustomer.password_hash) {
// Already-active customer with this email — duplicate, reject.
throw new ConflictError('A customer account with this email already exists', 'email');
//
// Carries the same code the send-invite route uses for its own
// already-active check (#1261). Both conflicts are 409 and both can mean
// "this address already has portal access" — the route reads the customer
// before this recheck runs, so an invitation accepted in between lands
// here instead — and a caller that cannot tell the two apart ends up
// telling the admin to cancel an invitation acceptance already closed.
const err = new ConflictError('A customer account with this email already exists', 'email');
err.code = 'CUSTOMER_ALREADY_ACTIVE';
throw err;
}
// If the existing customer is PASSIVE (password_hash IS NULL), this
// is the "promote to active" path: the admin clicked "Send portal
@@ -139,7 +148,9 @@ async function createInvitation({ email, invitedById, prefill }) {
.where('expires_at', '>', new Date())
.first();
if (pendingInvite) {
throw new ConflictError('A pending invitation already exists for this email', 'email');
const err = new ConflictError('A pending invitation already exists for this email', 'email');
err.code = 'INVITATION_ALREADY_PENDING';
throw err;
}
// 64-char hex = 32 bytes = 256 bits of entropy. Same as admin invites.