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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user