fix(crm): tell the admin whether a customer's invitation actually went out
Closes #1261. "Invite customer" is two calls: createDirect, then sendInvite. The mode wiring is right -- CustomerManagementPage passes mode='invite' and InlineCustomerCreate does call sendInvite -- so the reported symptom is not a missed branch. It is that nothing downstream distinguishes the outcomes. Three things could not be told apart afterwards: - The success toast claimed "portal invitation sent". sendInvite only queues an email_queue row; whether it was delivered is decided minutes later by the queue processor. The toast now says queued, and says what sends it. - When sendInvite failed, the warning read "Invitation email failed -- retry from the customer detail page", which sounds like the mail bounced. What actually remains is a PASSIVE customer with no invitation at all, so it says that instead. A 409 is now separated out: that means an invitation for the address is already open and the RE-invite was refused, so the customer is invited and telling them to retry sends them the wrong way. - The customers table rendered a customer whose invitation never went out identically to one the admin created as passive on purpose -- both showed only "Passive - admin only". Passive customers with an open invitation now show "Invitation pending", matched case-insensitively because customer_invitations lowercases the address while customer_accounts keeps what the admin typed. The invitations list was already being fetched for the tab; this only cross-references it. Active customers are left alone: they have portal access, so a stale invitation row for their address says nothing about them. 7 tests; the 5 that assert the new behaviour all fail before the change, and the 2 negative controls pass on both sides.
This commit is contained in:
@@ -16,6 +16,11 @@
|
||||
* stays saved (passive) and a warning toast asks the admin to retry
|
||||
* from the customer detail page.
|
||||
*
|
||||
* #1261 — the toasts here say what actually happened rather than what was
|
||||
* intended. Two calls means three outcomes, and the middle one used to be
|
||||
* indistinguishable from success: the invitation email is only QUEUED, so
|
||||
* "invitation sent" was a claim this code cannot make.
|
||||
*
|
||||
* Field set mirrors the customer detail page so admins see the same
|
||||
* shape regardless of where they're editing.
|
||||
*/
|
||||
@@ -182,10 +187,17 @@ export const InlineCustomerCreate: React.FC<Props> = ({ onCreated, onCancel, mod
|
||||
try {
|
||||
await customerAdminService.sendInvite(customer.id);
|
||||
toast.success(t('customers.create.savedActiveToast',
|
||||
'Customer created and portal invitation sent.'));
|
||||
'Customer created and portal invitation queued. It is sent by the email queue — check System health if it does not arrive.'));
|
||||
} catch (err: any) {
|
||||
toast.warn(t('customers.create.inviteFailedToast',
|
||||
'Customer saved (passive). Invitation email failed — retry from the customer detail page.'));
|
||||
// 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',
|
||||
'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);
|
||||
}
|
||||
|
||||
@@ -4848,10 +4848,11 @@
|
||||
"saveAsPassive": "Als passiven Kunden speichern",
|
||||
"saveAndInvite": "Speichern & Portal-Einladung senden",
|
||||
"savedPassiveToast": "Passiver Kunde erstellt.",
|
||||
"savedActiveToast": "Kunde erstellt und Portal-Einladung gesendet.",
|
||||
"inviteFailedToast": "Kunde gespeichert (passiv). Einladungs-E-Mail fehlgeschlagen — bitte aus dem Kundendetail erneut versuchen.",
|
||||
"savedActiveToast": "Kunde angelegt und Portal-Einladung eingereiht. Der Versand läuft über die E-Mail-Warteschlange — prüfen Sie den Systemzustand, falls sie nicht ankommt.",
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"passive": {
|
||||
"badge": "Passiv — nur Admin",
|
||||
@@ -5045,6 +5046,10 @@
|
||||
"addedN_other": "{{count}} hinzugefügt",
|
||||
"removedN_one": "{{count}} entfernt",
|
||||
"removedN_other": "{{count}} entfernt"
|
||||
},
|
||||
"invitePending": {
|
||||
"badge": "Einladung offen",
|
||||
"hint": "Einladung verschickt, noch nicht angenommen. Der Kunde bleibt passiv, bis er ein Passwort gesetzt hat."
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
|
||||
@@ -4848,10 +4848,11 @@
|
||||
"saveAsPassive": "Save as passive customer",
|
||||
"saveAndInvite": "Save & send portal invitation",
|
||||
"savedPassiveToast": "Passive customer created.",
|
||||
"savedActiveToast": "Customer created and portal invitation sent.",
|
||||
"inviteFailedToast": "Customer saved (passive). Invitation email failed — retry from the customer detail page.",
|
||||
"savedActiveToast": "Customer created and portal invitation queued. It is sent by the email queue — check System health if it does not arrive.",
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"passive": {
|
||||
"badge": "Passive — admin only",
|
||||
@@ -5045,6 +5046,10 @@
|
||||
"addedN_other": "{{count}} added",
|
||||
"removedN_one": "{{count}} removed",
|
||||
"removedN_other": "{{count}} removed"
|
||||
},
|
||||
"invitePending": {
|
||||
"badge": "Invitation pending",
|
||||
"hint": "Invitation sent, not accepted yet. The customer stays passive until they set a password."
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Link } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
UserPlus, UserCog, Trash2, Search, X, AlertTriangle, CheckCircle2, Clock,
|
||||
UserPlus, UserCog, Trash2, Search, X, AlertTriangle, CheckCircle2, Clock, MailCheck,
|
||||
} from 'lucide-react';
|
||||
import { InlineCustomerCreate } from '../../components/admin/InlineCustomerCreate';
|
||||
import { useMutationWithToast } from '../../hooks';
|
||||
@@ -89,6 +89,18 @@ export const CustomerManagementPage: React.FC = () => {
|
||||
);
|
||||
}, [customers, debouncedTerm]);
|
||||
|
||||
// #1261 — the "Invite customer" flow is createDirect-then-sendInvite, so a
|
||||
// customer whose second call never landed is left looking identical to one
|
||||
// the admin deliberately created as passive. Both showed only
|
||||
// "Passive — admin only", and there was nothing on this row to tell them
|
||||
// apart. Cross-reference the invitations we already fetch (the endpoint
|
||||
// returns unaccepted, unexpired ones) so an invited customer says so.
|
||||
const pendingInviteByEmail = useMemo(() => {
|
||||
const map = new Map<string, CustomerInvitationSummary>();
|
||||
for (const i of invitations || []) map.set(i.email.trim().toLowerCase(), i);
|
||||
return map;
|
||||
}, [invitations]);
|
||||
|
||||
const filteredInvitations = useMemo(() => {
|
||||
const list = invitations || [];
|
||||
if (!debouncedTerm.trim()) return list;
|
||||
@@ -239,11 +251,23 @@ export const CustomerManagementPage: React.FC = () => {
|
||||
status badge sits on its own line so a
|
||||
passive deactivated customer can still
|
||||
show both states clearly. */}
|
||||
{c.isPassive && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
|
||||
{t('customers.passive.badge', 'Passive — admin only')}
|
||||
</span>
|
||||
)}
|
||||
{c.isPassive && (() => {
|
||||
const invite = pendingInviteByEmail.get(c.email.trim().toLowerCase());
|
||||
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"
|
||||
title={t('customers.invitePending.hint',
|
||||
'Invitation sent, not accepted yet. The customer stays passive until they set a password.') as string}
|
||||
>
|
||||
<MailCheck className="w-3 h-3" />
|
||||
{t('customers.invitePending.badge', 'Invitation pending')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
|
||||
{t('customers.passive.badge', 'Passive — admin only')}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right">
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* "Invite customer" is two calls: createDirect, then sendInvite. When the
|
||||
* second one doesn't land, what's left behind is a passive customer — and the
|
||||
* customers table rendered that identically to one the admin created as
|
||||
* passive on purpose. Both showed "Passive — admin only", so the row could not
|
||||
* answer the only question the admin had: did the invitation go out? (#1261)
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k),
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('react-toastify', () => ({
|
||||
toast: { success: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
import { toast } from 'react-toastify';
|
||||
const toastSuccess = toast.success as ReturnType<typeof vi.fn>;
|
||||
const toastWarn = toast.warn as ReturnType<typeof vi.fn>;
|
||||
|
||||
vi.mock('../../../contexts/PermissionsContext', () => ({
|
||||
usePermissions: () => ({
|
||||
hasPermission: () => true,
|
||||
hasAnyPermission: () => true,
|
||||
hasAllPermissions: () => true,
|
||||
isSuperAdmin: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const list = vi.fn();
|
||||
const listInvitations = vi.fn();
|
||||
const createDirect = vi.fn();
|
||||
const sendInvite = vi.fn();
|
||||
vi.mock('../../../services/customerAdmin.service', () => ({
|
||||
customerAdminService: {
|
||||
list: (...a: unknown[]) => list(...a),
|
||||
listInvitations: (...a: unknown[]) => listInvitations(...a),
|
||||
createDirect: (...a: unknown[]) => createDirect(...a),
|
||||
sendInvite: (...a: unknown[]) => sendInvite(...a),
|
||||
deactivate: vi.fn(),
|
||||
cancelInvitation: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/businessProfile.service', () => ({
|
||||
businessProfileService: { get: vi.fn().mockResolvedValue({ profile: {} }) },
|
||||
}));
|
||||
|
||||
import { CustomerManagementPage } from '../CustomerManagementPage';
|
||||
import { InlineCustomerCreate } from '../../../components/admin/InlineCustomerCreate';
|
||||
|
||||
const customer = (id: number, email: string, extra: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
email,
|
||||
displayName: `Customer ${id}`,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
salutation: null,
|
||||
companyName: null,
|
||||
isActive: true,
|
||||
isPassive: true,
|
||||
eventCount: 0,
|
||||
lastLogin: null,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const invitation = (id: number, email: string) => ({
|
||||
id,
|
||||
email,
|
||||
expiresAt: '2026-12-01T00:00:00.000Z',
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
invitedBy: 'admin',
|
||||
});
|
||||
|
||||
function renderWith(node: React.ReactElement) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{node}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listInvitations.mockResolvedValue([]);
|
||||
list.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe('CustomerManagementPage — invited vs merely passive (#1261)', () => {
|
||||
it('marks a passive customer who has an open invitation', async () => {
|
||||
list.mockResolvedValue([customer(1, 'invited@example.com')]);
|
||||
listInvitations.mockResolvedValue([invitation(9, 'invited@example.com')]);
|
||||
|
||||
renderWith(<CustomerManagementPage />);
|
||||
|
||||
expect(await screen.findByText('Invitation pending')).toBeTruthy();
|
||||
expect(screen.queryByText('Passive — admin only')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a passive customer with no invitation reading as passive', async () => {
|
||||
list.mockResolvedValue([customer(2, 'nobody@example.com')]);
|
||||
listInvitations.mockResolvedValue([]);
|
||||
|
||||
renderWith(<CustomerManagementPage />);
|
||||
|
||||
expect(await screen.findByText('Passive — admin only')).toBeTruthy();
|
||||
expect(screen.queryByText('Invitation pending')).toBeNull();
|
||||
});
|
||||
|
||||
it('matches the invitation regardless of address casing', async () => {
|
||||
// customer_invitations stores the address lowercased; customer_accounts
|
||||
// preserves what the admin typed. A case-sensitive match would show every
|
||||
// mixed-case customer as never invited.
|
||||
list.mockResolvedValue([customer(3, 'Mixed.Case@Example.com')]);
|
||||
listInvitations.mockResolvedValue([invitation(11, 'mixed.case@example.com')]);
|
||||
|
||||
renderWith(<CustomerManagementPage />);
|
||||
|
||||
expect(await screen.findByText('Invitation pending')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not mark an active customer, who needs no invitation', async () => {
|
||||
list.mockResolvedValue([customer(4, 'active@example.com', { isPassive: false })]);
|
||||
listInvitations.mockResolvedValue([invitation(12, 'active@example.com')]);
|
||||
|
||||
renderWith(<CustomerManagementPage />);
|
||||
|
||||
await screen.findByText('Customer 4');
|
||||
expect(screen.queryByText('Invitation pending')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('InlineCustomerCreate — what the toast may claim (#1261)', () => {
|
||||
const fill = async () => {
|
||||
await userEvent.type(screen.getByPlaceholderText('name@example.com'), 'new@example.com');
|
||||
await userEvent.type(screen.getByLabelText(/Company name/i), 'Acme');
|
||||
};
|
||||
|
||||
it('says queued, not sent — this code cannot know it was delivered', async () => {
|
||||
createDirect.mockResolvedValue({ id: 5, email: 'new@example.com' });
|
||||
sendInvite.mockResolvedValue({ id: 1, email: 'new@example.com', expiresAt: 'x' });
|
||||
|
||||
renderWith(<InlineCustomerCreate mode="invite" onCreated={() => {}} onCancel={() => {}} />);
|
||||
await fill();
|
||||
await userEvent.click(screen.getByRole('button', { name: /Save & send portal invitation/i }));
|
||||
|
||||
await waitFor(() => expect(toastSuccess).toHaveBeenCalled());
|
||||
const message = String(toastSuccess.mock.calls[0][0]);
|
||||
expect(message).toMatch(/queued/i);
|
||||
expect(message).not.toMatch(/invitation sent/i);
|
||||
});
|
||||
|
||||
it('says the customer is PASSIVE when the invitation call failed', async () => {
|
||||
createDirect.mockResolvedValue({ id: 6, email: 'new@example.com' });
|
||||
sendInvite.mockRejectedValue({ response: { status: 500 } });
|
||||
|
||||
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());
|
||||
expect(String(toastWarn.mock.calls[0][0])).toMatch(/PASSIVE/);
|
||||
expect(toastSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('distinguishes a 409 — the customer IS invited, the re-invite was refused', async () => {
|
||||
createDirect.mockResolvedValue({ id: 7, email: 'new@example.com' });
|
||||
sendInvite.mockRejectedValue({ response: { status: 409 } });
|
||||
|
||||
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(/already open/i);
|
||||
expect(message).not.toMatch(/PASSIVE/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user