diff --git a/js/src/app/router/router.tsx b/js/src/app/router/router.tsx index 9ea6b47..8de43e5 100644 --- a/js/src/app/router/router.tsx +++ b/js/src/app/router/router.tsx @@ -5,7 +5,7 @@ import { RequireAdmin } from "@/app/router/guards/RequireAdmin"; import { RequireAuth } from "@/app/router/guards/RequireAuth"; import AdminPage from "@/features/admin/Admin.page"; import AdminLoginPage from "@/features/admin/AdminLogin.page"; -import EmailAdminPage from "@/features/emails/EmailAdminPage"; +import AdminEmailComposerPage from "@/features/emails/AdminEmailComposer.page"; import HomePage from "@/features/home/Home.page"; import SamplePage from "@/features/sample/Sample.page"; import SampleAdminPage from "@/features/sample/SampleAdmin.page"; @@ -20,11 +20,6 @@ export const router = createBrowserRouter([ element: , children: [{ index: true, element: }], }, - { - element: , - children: [{ path: "email/admin", element: }], - }, - // Authenticated: guard -> layout -> page. Admin nests a second guard + layout. { element: , @@ -40,6 +35,7 @@ export const router = createBrowserRouter([ element: , children: [ { path: "admin", element: }, + { path: "admin/emails", element: }, { path: "sample/admin", element: }, ], }, diff --git a/js/src/features/emails/AdminEmailComposer.page.test.tsx b/js/src/features/emails/AdminEmailComposer.page.test.tsx new file mode 100644 index 0000000..a855f24 --- /dev/null +++ b/js/src/features/emails/AdminEmailComposer.page.test.tsx @@ -0,0 +1,105 @@ +import AdminEmailComposerPage from "@/features/emails/AdminEmailComposer.page"; +import { + renderWithProviders, + screen, + waitFor, + within, +} from "@/lib/test/render"; +import { server } from "@/lib/test/server"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { expect, test } from "vitest"; + +async function selectTemplate( + user: ReturnType, + name: string, +) { + await user.click(screen.getByPlaceholderText("Select email template")); + await user.click(await screen.findByText(name)); + await screen.findByText("4 recipients"); +} + +test("requires a template before opening confirmation", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /review send/i })); + + expect( + await screen.findByText("Email template is required"), + ).toBeInTheDocument(); +}); + +test("renders a paired template and sends its backend batch payload", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await selectTemplate(user, "Pair"); + + expect(screen.getAllByText("Matched users")).toHaveLength(2); + expect(screen.getByText("4 recipients")).toBeInTheDocument(); + expect(screen.getByLabelText("Plain text body")).toHaveAttribute("readonly"); + expect(screen.getAllByText(/Alice and Bob/)).toHaveLength(2); + + await user.click(screen.getByRole("button", { name: /review send/i })); + + const dialog = await screen.findByRole("dialog", { name: /confirm send/i }); + + expect(within(dialog).getByText("Matched users")).toBeInTheDocument(); + expect(within(dialog).getByText("4")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /send email/i })); + + expect(await screen.findByText("Email send complete")).toBeInTheDocument(); + expect(screen.getByText("Sent 2, failed 0")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByText("Confirm send")).not.toBeInTheDocument(); + }); +}); + +test("shows failed recipient addresses returned by the backend", async () => { + const user = userEvent.setup(); + + server.use( + http.post("/api/email/send", () => + HttpResponse.json({ + message: "Sent 1 of 2 emails", + payload: { + failed: 1, + results: [ + { + error: null, + recipients: ["alice@example.com", "bob@example.com"], + sent: true, + }, + { + error: "Mailbox unavailable", + recipients: ["carol@example.com", "david@example.com"], + sent: false, + }, + ], + sent: 1, + }, + success: true, + }), + ), + ); + + renderWithProviders(); + + await selectTemplate(user, "Pair"); + await user.click(screen.getByRole("button", { name: /review send/i })); + await user.click(await screen.findByRole("button", { name: /send email/i })); + + expect( + await screen.findByText("Email send completed with failures"), + ).toBeInTheDocument(); + expect(screen.getByText("Sent 1, failed 1")).toBeInTheDocument(); + expect( + screen.getByText( + /carol@example.com, david@example.com: Mailbox unavailable/, + ), + ).toBeInTheDocument(); +}); diff --git a/js/src/features/emails/AdminEmailComposer.page.tsx b/js/src/features/emails/AdminEmailComposer.page.tsx new file mode 100644 index 0000000..4a41b7e --- /dev/null +++ b/js/src/features/emails/AdminEmailComposer.page.tsx @@ -0,0 +1,375 @@ +import { + buildEmailRequest, + countRecipients, + getMessageVariables, + renderTemplate, + SendEmailResponse, +} from "@/features/emails/api/buildEmailRequest"; +import { + getTemplate, + isSelectableEmailTemplateName, + selectableEmailTemplateNames, +} from "@/features/emails/api/EmailTemplate"; +import { + emailComposerSchema, + EmailComposerValues, +} from "@/features/emails/api/schemas"; +import { useSendAdminEmail } from "@/features/emails/api/useSendAdminEmail"; +import { useTemplateAudience } from "@/features/emails/api/useTemplateAudience"; +import { + Alert, + Badge, + Button, + Card, + Divider, + Group, + Loader, + Modal, + Select, + SimpleGrid, + Stack, + Text, + Textarea, + TextInput, + Title, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { notifications } from "@mantine/notifications"; +import { + IconCheck, + IconEyeCheck, + IconFileText, + IconMail, + IconSend, + IconUserCheck, + IconUsers, +} from "@tabler/icons-react"; +import { zodResolver } from "mantine-form-zod-resolver"; +import { useMemo, useState } from "react"; + +const initialValues: EmailComposerValues = { + templateName: "", +}; + +function formatCount(value: number) { + return value.toLocaleString(); +} + +function getRecipientLabel(audience: "all-users" | "matched-pairs") { + return audience === "all-users" ? "All users" : "Matched users"; +} + +function getRecipientIcon(audience: "all-users" | "matched-pairs") { + return audience === "all-users" ? + + : ; +} + +function getFailedResults(sendResult: SendEmailResponse | null) { + return sendResult?.results.filter((result) => !result.sent) ?? []; +} + +export default function AdminEmailComposerPage() { + const [isConfirmationOpen, setIsConfirmationOpen] = useState(false); + const [sendResult, setSendResult] = useState(null); + const form = useForm({ + initialValues, + validate: zodResolver(emailComposerSchema), + }); + const selectedTemplateName = + isSelectableEmailTemplateName(form.values.templateName) ? + form.values.templateName + : undefined; + const selectedTemplate = + selectedTemplateName ? getTemplate(selectedTemplateName) : undefined; + const { + data: templateAudience, + error: templateAudienceError, + isError: isTemplateAudienceError, + isPending: isTemplateAudienceQueryPending, + } = useTemplateAudience(selectedTemplateName); + const isTemplateAudiencePending = + selectedTemplateName !== undefined && isTemplateAudienceQueryPending; + const sendEmail = useSendAdminEmail(); + + const emailRequest = useMemo( + () => + selectedTemplate && templateAudience ? + buildEmailRequest(selectedTemplate, templateAudience) + : undefined, + [selectedTemplate, templateAudience], + ); + const previewMessage = emailRequest?.messages[0]; + const previewVariables = + previewMessage ? getMessageVariables(previewMessage) : undefined; + const previewSubject = + selectedTemplate && previewVariables ? + renderTemplate(selectedTemplate.subject, previewVariables) + : "Subject"; + const previewBody = + selectedTemplate && previewVariables ? + renderTemplate(selectedTemplate.body, previewVariables) + : "Plain text body"; + const recipientCount = + emailRequest ? countRecipients(emailRequest.messages) : 0; + const recipientLabel = + templateAudience ? + getRecipientLabel(templateAudience.audience) + : "Recipients"; + const failedResults = getFailedResults(sendResult); + + const openConfirmation = () => { + const validation = form.validate(); + + if (!validation.hasErrors && emailRequest) { + setIsConfirmationOpen(true); + } + }; + + const handleTemplateChange = (templateName: string | null) => { + form.setFieldValue("templateName", templateName ?? ""); + setSendResult(null); + }; + + const confirmSend = async () => { + if (!emailRequest) { + return; + } + + const result = await sendEmail.mutateAsync(emailRequest); + + setIsConfirmationOpen(false); + setSendResult(result); + notifications.show({ + color: result.failed > 0 ? "yellow" : "green", + icon: , + message: `Sent ${result.sent}, failed ${result.failed}`, + title: "Email send complete", + }); + }; + + return ( + <> + + + + Emails + Composer + + {sendResult ? + 0 ? "yellow" : "green"} + leftSection={} + > + Sent + + : null} + + {sendResult ? + 0 ? "yellow" : "green"} + icon={} + title={ + sendResult.failed > 0 ? + "Email send completed with failures" + : "Email send complete" + } + > + + + Sent {sendResult.sent}, failed {sendResult.failed} + + {failedResults.length > 0 ? + + Failed recipients + {failedResults.map((result) => ( + + {result.recipients.join(", ")} + {result.error ? `: ${result.error}` : ""} + + ))} + + : null} + + + : null} + {sendEmail.isError ? + + {sendEmail.error.message} + + : null} + + + + + + + Email template + +